改预设要能一边写一边看装出来的 messages,还要多模型对照,所以把编辑与试跑拆成两个视图塞进一个页面;版本存完整快照、可分叉,用例全局共用一份、运行记录按预设存。顺带让读预设文件先剥 BOM,静态资源改成不缓存,免得改了脚本还要强刷。
178 lines
8.0 KiB
TypeScript
178 lines
8.0 KiB
TypeScript
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||
import type { PresetPackage } from "../src/types/preset.js";
|
||
import { buildCaseMessages } from "../src/preset/test-run.js";
|
||
import { getTestRun, saveTestRun, updateVerdict } from "../src/preset/test-runs.js";
|
||
import { deleteTestGroup, getTestGroup, listTestGroups, saveTestGroup } from "../src/preset/test-cases.js";
|
||
import { deleteTestCase, listTestCases, saveTestCase } from "../src/preset/test-cases.js";
|
||
import { loadPresetLabState, savePresetLabState } from "../src/preset/lab-state.js";
|
||
|
||
function preset(): PresetPackage {
|
||
return {
|
||
id: "pp",
|
||
name: "pp",
|
||
source: "native",
|
||
prompts: [
|
||
{ id: "sys", name: "规则", enabled: true, role: "system", content: "你是{{char}}的叙事者", marker: false, sourceIdentifier: "sys" },
|
||
{ id: "worldInfoBefore", name: "世界书", enabled: true, role: "system", content: "", marker: true, sourceIdentifier: "worldInfoBefore" },
|
||
{ id: "charDescription", name: "角色描述", enabled: true, role: "system", content: "", marker: true, sourceIdentifier: "charDescription" },
|
||
{ id: "chatHistory", name: "历史", enabled: true, role: "system", content: "", marker: true, sourceIdentifier: "chatHistory" },
|
||
{ id: "currentTurn", name: "本轮", enabled: true, role: "user", content: "", marker: true, sourceIdentifier: "currentTurn" },
|
||
],
|
||
promptOrder: [
|
||
{ promptId: "sys", enabled: true, orderIndex: 0 },
|
||
{ promptId: "worldInfoBefore", enabled: true, orderIndex: 1 },
|
||
{ promptId: "charDescription", enabled: true, orderIndex: 2 },
|
||
{ promptId: "chatHistory", enabled: true, orderIndex: 3 },
|
||
{ promptId: "currentTurn", enabled: true, orderIndex: 4 },
|
||
],
|
||
generation: {},
|
||
unsupported: [],
|
||
importedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
let dir = "";
|
||
beforeEach(() => {
|
||
dir = mkdtempSync(path.join(os.tmpdir(), "preset-tests-"));
|
||
process.env.WRITING_AGENT_DATA_DIR = dir;
|
||
});
|
||
afterEach(() => {
|
||
delete process.env.WRITING_AGENT_DATA_DIR;
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
describe("测试用例组", () => {
|
||
it("保存、读取、列出、删除", () => {
|
||
const g = saveTestGroup({
|
||
name: "基础",
|
||
cases: [{ name: "c1", charName: "林", charDescription: "冷静", worldBook: "古镇", userInput: "进门" }],
|
||
});
|
||
expect(g.cases[0]!.id).toBeTruthy();
|
||
expect(getTestGroup(g.id)?.cases[0]!.charName).toBe("林");
|
||
expect(listTestGroups()).toHaveLength(1);
|
||
deleteTestGroup(g.id);
|
||
expect(listTestGroups()).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe("用例(工作台的展示单位)", () => {
|
||
it("新用例落成只装它的一份组文件,平铺列表能读回", () => {
|
||
const saved = saveTestCase({
|
||
case: { name: "开门", userInput: "我推开门" },
|
||
});
|
||
expect(saved.groupId).toBeTruthy();
|
||
expect(saved.case.id).toBeTruthy();
|
||
expect(listTestCases().map((item) => item.case.name)).toEqual(["开门"]);
|
||
expect(listTestGroups()).toHaveLength(1);
|
||
});
|
||
|
||
it("用例全局共享:换预设也能读到同一套", () => {
|
||
saveTestCase({ case: { name: "全局用例", userInput: "甲" } });
|
||
// 没有任何 presetId 参与,读取时也不需要
|
||
expect(listTestCases().map((item) => item.case.name)).toEqual(["全局用例"]);
|
||
expect(listTestGroups()[0]!.presetId).toBe("global");
|
||
});
|
||
|
||
it("旧版按预设分目录的用例会被并进全局目录", () => {
|
||
const legacyDir = path.join(dir, "presets", "tests", "pp");
|
||
mkdirSync(legacyDir, { recursive: true });
|
||
writeFileSync(
|
||
path.join(legacyDir, "old12345.json"),
|
||
JSON.stringify({
|
||
id: "old12345",
|
||
presetId: "pp",
|
||
name: "旧组",
|
||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||
cases: [{ id: "c1", name: "老用例", charName: "", charDescription: "", worldBook: "", userInput: "x", history: [] }],
|
||
}),
|
||
"utf8",
|
||
);
|
||
expect(listTestCases().map((item) => item.case.name)).toEqual(["老用例"]);
|
||
expect(getTestGroup("old12345")!.presetId).toBe("global");
|
||
});
|
||
|
||
it("存回旧组里的用例时不动同组其他用例", () => {
|
||
const group = saveTestGroup({
|
||
name: "旧组",
|
||
cases: [{ name: "A", userInput: "甲" }, { name: "B", userInput: "乙" }],
|
||
});
|
||
const target = group.cases[1]!;
|
||
saveTestCase({
|
||
groupId: group.id,
|
||
case: { ...target, userInput: "乙改" },
|
||
});
|
||
const after = getTestGroup(group.id)!.cases;
|
||
expect(after.map((item) => item.userInput)).toEqual(["甲", "乙改"]);
|
||
});
|
||
|
||
it("删除最后一份用例时把空组文件一起收掉", () => {
|
||
const saved = saveTestCase({ case: { name: "单条" } });
|
||
deleteTestCase(saved.groupId, saved.case.id);
|
||
expect(listTestCases()).toEqual([]);
|
||
expect(listTestGroups()).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("预设的试跑状态", () => {
|
||
it("按预设记住打开的是哪条用例,重读还在", () => {
|
||
expect(loadPresetLabState("pp").selectedCaseId).toBe(null);
|
||
savePresetLabState("pp", { selectedCaseId: "c1" });
|
||
expect(loadPresetLabState("pp").selectedCaseId).toBe("c1");
|
||
// 另一个预设不受影响
|
||
expect(loadPresetLabState("qq").selectedCaseId).toBe(null);
|
||
// 只传 previewCaseId 时不抹掉 selectedCaseId
|
||
savePresetLabState("pp", { previewCaseId: "c2" });
|
||
const state = loadPresetLabState("pp");
|
||
expect(state.selectedCaseId).toBe("c1");
|
||
expect(state.previewCaseId).toBe("c2");
|
||
});
|
||
});
|
||
|
||
describe("用例消息组装", () => {
|
||
it("char 描述、世界书、历史、用户输入按顺序进入 messages,且 {{char}} 被替换", () => {
|
||
const msgs = buildCaseMessages(preset(), {
|
||
id: "c", name: "c", charName: "林", charDescription: "冷静的侦探", worldBook: "古镇夜雨",
|
||
userInput: "我推门", history: [{ role: "assistant", content: "门开了。" }],
|
||
});
|
||
const text = msgs.map((m) => `${m.role}:${m.content}`);
|
||
expect(text[0]).toContain("你是林的叙事者");
|
||
expect(text.join("\n")).toContain("古镇夜雨");
|
||
expect(text.join("\n")).toContain("冷静的侦探");
|
||
expect(text[text.length - 1]).toBe("user:我推门");
|
||
expect(text).toContain("assistant:门开了。");
|
||
});
|
||
|
||
it("关掉 currentTurn 位置后,用户输入不再进入 messages", () => {
|
||
const p = preset();
|
||
const turn = p.promptOrder.find((item) => item.promptId === "currentTurn");
|
||
if (turn) turn.enabled = false;
|
||
const msgs = buildCaseMessages(p, {
|
||
id: "c", name: "c", charName: "林", charDescription: "", worldBook: "",
|
||
userInput: "这句不该出现", history: [],
|
||
});
|
||
expect(msgs.map((m) => m.content).join("\n")).not.toContain("这句不该出现");
|
||
});
|
||
|
||
it("只替换变动项:同一预设、不同用例的 system 规则保持一致", () => {
|
||
const a = buildCaseMessages(preset(), { id: "a", name: "a", charName: "甲", charDescription: "", worldBook: "", userInput: "x", history: [] });
|
||
const b = buildCaseMessages(preset(), { id: "b", name: "b", charName: "甲", charDescription: "", worldBook: "", userInput: "y", history: [] });
|
||
// 默认整形为 strict:system 会并入相邻 user,故只比较规则文本是否都在首条里\n expect(a[0]!.content.startsWith("你是甲的叙事者")).toBe(true);\n expect(b[0]!.content.startsWith("你是甲的叙事者")).toBe(true);\n expect(a[0]!.content.replace("x", "")).toBe(b[0]!.content.replace("y", ""));
|
||
});
|
||
});
|
||
|
||
describe("运行记录与手动标记", () => {
|
||
it("保存后可以修改标记并落盘", () => {
|
||
saveTestRun({
|
||
id: "r1", presetId: "pp", presetVersionId: "v1", groupId: "g", createdAt: new Date().toISOString(),
|
||
requests: [], issues: [],
|
||
results: [{ profileId: "p", profileName: "P", model: "m", caseId: "c", status: "done", verdict: null, startedAt: "" }],
|
||
});
|
||
updateVerdict("pp", "r1", "p", "c", "success");
|
||
expect(getTestRun("pp", "r1")!.results[0]!.verdict).toBe("success");
|
||
});
|
||
});
|
||
|