| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- import { describe, it, expect } from "vitest";
- import { parseMemo, PlannerParseError } from "../utils/chapter-memo-parser.js";
- const SECTIONS = `
- ## 当前任务
- 主角进入七号门现场,比对锁芯刮痕与监控时间线,把"被动过手脚"从猜测钉成实证。
- ## 读者此刻在等什么
- 1) 读者在等七号门异常是否实锤
- 2) 本章完全兑现——钉成现场实证
- ## 该兑现的 / 暂不掀的
- - 该兑现:七号门异常 → 钉成现场实证
- - 暂不掀:幕后主使 → 压到第 20 章
- ## 日常/过渡承担什么任务
- 不适用 - 本章为高压实证章,无日常过渡段。
- ## 关键抉择过三连问
- - 主角本章最关键的一次选择:
- - 为什么这么做?因为线索只剩这一条
- - 符合当前利益吗?符合
- - 符合他的人设吗?符合
- - 对手/配角本章最关键的一次选择:
- - 为什么这么做?为了掩盖踪迹
- - 符合当前利益吗?符合
- - 符合他的人设吗?符合
- ## 章尾必须发生的改变
- - 信息改变:主角掌握实证
- - 关系改变:主角对阿泽产生戒心
- ## 不要做
- - 不要让对手突然降智
- - 不要直接点破幕后主使
- `.trim();
- function makeRaw(
- opts: {
- chapter?: number | string;
- goal?: string;
- isGoldenOpening?: boolean | string;
- threadRefs?: ReadonlyArray<string> | null | unknown;
- body?: string;
- dropFrontmatter?: boolean;
- } = {},
- ): string {
- if (opts.dropFrontmatter) {
- return opts.body ?? SECTIONS;
- }
- const threadRefsText = Array.isArray(opts.threadRefs)
- ? `threadRefs:\n${opts.threadRefs.map((id) => ` - ${id}`).join("\n")}`
- : opts.threadRefs === null
- ? "threadRefs: null"
- : opts.threadRefs === undefined
- ? "threadRefs: []"
- : `threadRefs: ${String(opts.threadRefs)}`;
- const frontmatter = [
- `chapter: ${opts.chapter ?? 12}`,
- `goal: ${opts.goal ?? "把七号门被动过手脚钉成现场实证"}`,
- `isGoldenOpening: ${opts.isGoldenOpening ?? false}`,
- threadRefsText,
- ].join("\n");
- return `---\n${frontmatter}\n---\n${opts.body ?? SECTIONS}\n`;
- }
- describe("parseMemo", () => {
- it("parses a valid frontmatter + 7 sections", () => {
- const memo = parseMemo(makeRaw({ threadRefs: ["H03", "S004"] }), 12, false);
- expect(memo.chapter).toBe(12);
- expect(memo.goal).toBe("把七号门被动过手脚钉成现场实证");
- expect(memo.isGoldenOpening).toBe(false);
- expect(memo.threadRefs).toEqual(["H03", "S004"]);
- expect(memo.body).toContain("## 当前任务");
- expect(memo.body).toContain("## 不要做");
- });
- it("throws when frontmatter delimiters are missing", () => {
- expect(() => parseMemo(SECTIONS, 12, false)).toThrow(PlannerParseError);
- expect(() => parseMemo(SECTIONS, 12, false)).toThrow(/frontmatter/);
- });
- it("throws when goal exceeds 50 chars", () => {
- const longGoal = "把异常钉成实证".repeat(10);
- expect(() => parseMemo(makeRaw({ goal: longGoal }), 12, false)).toThrow(/goal too long/);
- });
- it("throws when chapter mismatches expected", () => {
- expect(() => parseMemo(makeRaw({ chapter: 99 }), 12, false)).toThrow(/chapter mismatch/);
- });
- it.each([
- "## 当前任务",
- "## 读者此刻在等什么",
- "## 该兑现的 / 暂不掀的",
- "## 日常/过渡承担什么任务",
- "## 关键抉择过三连问",
- "## 章尾必须发生的改变",
- "## 不要做",
- ])("throws when body is missing section %s", (heading) => {
- const body = SECTIONS.replace(heading, "## SECTION-REMOVED");
- expect(() => parseMemo(makeRaw({ body }), 12, false)).toThrow(/missing sections/);
- });
- it("silently coerces non-array threadRefs to empty array", () => {
- const raw = makeRaw({ threadRefs: null });
- const memo = parseMemo(raw, 12, false);
- expect(memo.threadRefs).toEqual([]);
- });
- it("uses caller-provided isGoldenOpening, not LLM frontmatter value", () => {
- // LLM claims true but caller says false — caller wins
- const raw = makeRaw({ isGoldenOpening: true });
- const memo = parseMemo(raw, 12, false);
- expect(memo.isGoldenOpening).toBe(false);
- // LLM claims false but caller says true — caller wins
- const raw2 = makeRaw({ isGoldenOpening: false });
- const memo2 = parseMemo(raw2, 12, true);
- expect(memo2.isGoldenOpening).toBe(true);
- });
- it("throws when YAML frontmatter is not an object", () => {
- const raw = `---\n- just\n- a\n- list\n---\n${SECTIONS}\n`;
- expect(() => parseMemo(raw, 12, false)).toThrow(/frontmatter is not an object/);
- });
- it("throws when chapter is not an integer", () => {
- const raw = `---\nchapter: 12.5\ngoal: x\nisGoldenOpening: false\nthreadRefs: []\n---\n${SECTIONS}\n`;
- expect(() => parseMemo(raw, 12, false)).toThrow(/chapter must be an integer/);
- });
- it("throws on invalid YAML", () => {
- const raw = `---\nchapter: 12\n bad indent: : :\n---\n${SECTIONS}\n`;
- expect(() => parseMemo(raw, 12, false)).toThrow(/invalid YAML/);
- });
- // Phase hotfix 7: empty / blank section payloads must be rejected.
- describe("empty section detection", () => {
- it("rejects a memo where every heading is present but payloads are blank", () => {
- const blankBody = [
- "## 当前任务",
- "",
- "## 读者此刻在等什么",
- "",
- "## 该兑现的 / 暂不掀的",
- "",
- "## 日常/过渡承担什么任务",
- "",
- "## 关键抉择过三连问",
- "",
- "## 章尾必须发生的改变",
- "",
- "## 不要做",
- "",
- ].join("\n");
- expect(() => parseMemo(makeRaw({ body: blankBody }), 12, false))
- .toThrow(/empty sections/);
- });
- it("rejects a memo where one section has only whitespace / placeholder", () => {
- const body = SECTIONS.replace(
- /## 当前任务\n[\s\S]*?\n\n## 读者此刻在等什么/,
- "## 当前任务\n \n\n## 读者此刻在等什么",
- );
- expect(() => parseMemo(makeRaw({ body }), 12, false))
- .toThrow(/empty sections.*当前任务/);
- });
- it("rejects a memo where one section has 'TODO' (under 20 chars)", () => {
- const body = SECTIONS.replace(
- /## 章尾必须发生的改变\n[\s\S]*?\n\n## 不要做/,
- "## 章尾必须发生的改变\nTODO\n\n## 不要做",
- );
- expect(() => parseMemo(makeRaw({ body }), 12, false))
- .toThrow(/empty sections.*章尾必须发生的改变/);
- });
- it("accepts a sparse-but-non-empty memo (Phase 6 sparse-memo principle)", () => {
- // Each section just barely meets the threshold — this is the
- // breath/transition chapter case the principle wants to keep legal.
- const sparseBody = [
- "## 当前任务",
- "主角与协作者在码头会合,交换上一案的情报与下一步行动安排。",
- "",
- "## 读者此刻在等什么",
- "读者在等线索是否真被坐实。本章只给出半成品的暗示。",
- "",
- "## 该兑现的 / 暂不掀的",
- "该兑现:暗示线索成形;暂不掀:幕后是谁,留到第 20 章。",
- "",
- "## 日常/过渡承担什么任务",
- "码头闲笔铺人物关系,让协作者的犹豫成为下一章的钩。",
- "",
- "## 关键抉择过三连问",
- "主角选信协作者,因为情报缺口只能从这里补;符合利益与人设。",
- "",
- "## 章尾必须发生的改变",
- "关系改变:主角和协作者从交易关系微微转向共担风险的同伴。",
- "",
- "## 不要做",
- "无",
- ].join("\n");
- const memo = parseMemo(makeRaw({ body: sparseBody }), 12, false);
- expect(memo.body).toContain("## 当前任务");
- expect(memo.body).toContain("## 不要做");
- });
- it('accepts "## 不要做" with very short content like "无" / "N/A" (relaxed threshold)', () => {
- // The "do not" section uses a 5-char minimum so books with no extra
- // chapter-level prohibitions can say so without inventing filler.
- const body = SECTIONS.replace(
- /## 不要做\n[\s\S]*$/,
- "## 不要做\n无。",
- );
- const memo = parseMemo(makeRaw({ body }), 12, false);
- expect(memo.body).toContain("无。");
- });
- it("rejects empty '## 不要做' even with the relaxed threshold", () => {
- const body = SECTIONS.replace(
- /## 不要做\n[\s\S]*$/,
- "## 不要做\n",
- );
- expect(() => parseMemo(makeRaw({ body }), 12, false))
- .toThrow(/empty sections.*不要做/);
- });
- });
- });
|