chapter-memo-parser.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import { describe, it, expect } from "vitest";
  2. import { parseMemo, PlannerParseError } from "../utils/chapter-memo-parser.js";
  3. const SECTIONS = `
  4. ## 当前任务
  5. 主角进入七号门现场,比对锁芯刮痕与监控时间线,把"被动过手脚"从猜测钉成实证。
  6. ## 读者此刻在等什么
  7. 1) 读者在等七号门异常是否实锤
  8. 2) 本章完全兑现——钉成现场实证
  9. ## 该兑现的 / 暂不掀的
  10. - 该兑现:七号门异常 → 钉成现场实证
  11. - 暂不掀:幕后主使 → 压到第 20 章
  12. ## 日常/过渡承担什么任务
  13. 不适用 - 本章为高压实证章,无日常过渡段。
  14. ## 关键抉择过三连问
  15. - 主角本章最关键的一次选择:
  16. - 为什么这么做?因为线索只剩这一条
  17. - 符合当前利益吗?符合
  18. - 符合他的人设吗?符合
  19. - 对手/配角本章最关键的一次选择:
  20. - 为什么这么做?为了掩盖踪迹
  21. - 符合当前利益吗?符合
  22. - 符合他的人设吗?符合
  23. ## 章尾必须发生的改变
  24. - 信息改变:主角掌握实证
  25. - 关系改变:主角对阿泽产生戒心
  26. ## 不要做
  27. - 不要让对手突然降智
  28. - 不要直接点破幕后主使
  29. `.trim();
  30. function makeRaw(
  31. opts: {
  32. chapter?: number | string;
  33. goal?: string;
  34. isGoldenOpening?: boolean | string;
  35. threadRefs?: ReadonlyArray<string> | null | unknown;
  36. body?: string;
  37. dropFrontmatter?: boolean;
  38. } = {},
  39. ): string {
  40. if (opts.dropFrontmatter) {
  41. return opts.body ?? SECTIONS;
  42. }
  43. const threadRefsText = Array.isArray(opts.threadRefs)
  44. ? `threadRefs:\n${opts.threadRefs.map((id) => ` - ${id}`).join("\n")}`
  45. : opts.threadRefs === null
  46. ? "threadRefs: null"
  47. : opts.threadRefs === undefined
  48. ? "threadRefs: []"
  49. : `threadRefs: ${String(opts.threadRefs)}`;
  50. const frontmatter = [
  51. `chapter: ${opts.chapter ?? 12}`,
  52. `goal: ${opts.goal ?? "把七号门被动过手脚钉成现场实证"}`,
  53. `isGoldenOpening: ${opts.isGoldenOpening ?? false}`,
  54. threadRefsText,
  55. ].join("\n");
  56. return `---\n${frontmatter}\n---\n${opts.body ?? SECTIONS}\n`;
  57. }
  58. describe("parseMemo", () => {
  59. it("parses a valid frontmatter + 7 sections", () => {
  60. const memo = parseMemo(makeRaw({ threadRefs: ["H03", "S004"] }), 12, false);
  61. expect(memo.chapter).toBe(12);
  62. expect(memo.goal).toBe("把七号门被动过手脚钉成现场实证");
  63. expect(memo.isGoldenOpening).toBe(false);
  64. expect(memo.threadRefs).toEqual(["H03", "S004"]);
  65. expect(memo.body).toContain("## 当前任务");
  66. expect(memo.body).toContain("## 不要做");
  67. });
  68. it("throws when frontmatter delimiters are missing", () => {
  69. expect(() => parseMemo(SECTIONS, 12, false)).toThrow(PlannerParseError);
  70. expect(() => parseMemo(SECTIONS, 12, false)).toThrow(/frontmatter/);
  71. });
  72. it("throws when goal exceeds 50 chars", () => {
  73. const longGoal = "把异常钉成实证".repeat(10);
  74. expect(() => parseMemo(makeRaw({ goal: longGoal }), 12, false)).toThrow(/goal too long/);
  75. });
  76. it("throws when chapter mismatches expected", () => {
  77. expect(() => parseMemo(makeRaw({ chapter: 99 }), 12, false)).toThrow(/chapter mismatch/);
  78. });
  79. it.each([
  80. "## 当前任务",
  81. "## 读者此刻在等什么",
  82. "## 该兑现的 / 暂不掀的",
  83. "## 日常/过渡承担什么任务",
  84. "## 关键抉择过三连问",
  85. "## 章尾必须发生的改变",
  86. "## 不要做",
  87. ])("throws when body is missing section %s", (heading) => {
  88. const body = SECTIONS.replace(heading, "## SECTION-REMOVED");
  89. expect(() => parseMemo(makeRaw({ body }), 12, false)).toThrow(/missing sections/);
  90. });
  91. it("silently coerces non-array threadRefs to empty array", () => {
  92. const raw = makeRaw({ threadRefs: null });
  93. const memo = parseMemo(raw, 12, false);
  94. expect(memo.threadRefs).toEqual([]);
  95. });
  96. it("uses caller-provided isGoldenOpening, not LLM frontmatter value", () => {
  97. // LLM claims true but caller says false — caller wins
  98. const raw = makeRaw({ isGoldenOpening: true });
  99. const memo = parseMemo(raw, 12, false);
  100. expect(memo.isGoldenOpening).toBe(false);
  101. // LLM claims false but caller says true — caller wins
  102. const raw2 = makeRaw({ isGoldenOpening: false });
  103. const memo2 = parseMemo(raw2, 12, true);
  104. expect(memo2.isGoldenOpening).toBe(true);
  105. });
  106. it("throws when YAML frontmatter is not an object", () => {
  107. const raw = `---\n- just\n- a\n- list\n---\n${SECTIONS}\n`;
  108. expect(() => parseMemo(raw, 12, false)).toThrow(/frontmatter is not an object/);
  109. });
  110. it("throws when chapter is not an integer", () => {
  111. const raw = `---\nchapter: 12.5\ngoal: x\nisGoldenOpening: false\nthreadRefs: []\n---\n${SECTIONS}\n`;
  112. expect(() => parseMemo(raw, 12, false)).toThrow(/chapter must be an integer/);
  113. });
  114. it("throws on invalid YAML", () => {
  115. const raw = `---\nchapter: 12\n bad indent: : :\n---\n${SECTIONS}\n`;
  116. expect(() => parseMemo(raw, 12, false)).toThrow(/invalid YAML/);
  117. });
  118. // Phase hotfix 7: empty / blank section payloads must be rejected.
  119. describe("empty section detection", () => {
  120. it("rejects a memo where every heading is present but payloads are blank", () => {
  121. const blankBody = [
  122. "## 当前任务",
  123. "",
  124. "## 读者此刻在等什么",
  125. "",
  126. "## 该兑现的 / 暂不掀的",
  127. "",
  128. "## 日常/过渡承担什么任务",
  129. "",
  130. "## 关键抉择过三连问",
  131. "",
  132. "## 章尾必须发生的改变",
  133. "",
  134. "## 不要做",
  135. "",
  136. ].join("\n");
  137. expect(() => parseMemo(makeRaw({ body: blankBody }), 12, false))
  138. .toThrow(/empty sections/);
  139. });
  140. it("rejects a memo where one section has only whitespace / placeholder", () => {
  141. const body = SECTIONS.replace(
  142. /## 当前任务\n[\s\S]*?\n\n## 读者此刻在等什么/,
  143. "## 当前任务\n \n\n## 读者此刻在等什么",
  144. );
  145. expect(() => parseMemo(makeRaw({ body }), 12, false))
  146. .toThrow(/empty sections.*当前任务/);
  147. });
  148. it("rejects a memo where one section has 'TODO' (under 20 chars)", () => {
  149. const body = SECTIONS.replace(
  150. /## 章尾必须发生的改变\n[\s\S]*?\n\n## 不要做/,
  151. "## 章尾必须发生的改变\nTODO\n\n## 不要做",
  152. );
  153. expect(() => parseMemo(makeRaw({ body }), 12, false))
  154. .toThrow(/empty sections.*章尾必须发生的改变/);
  155. });
  156. it("accepts a sparse-but-non-empty memo (Phase 6 sparse-memo principle)", () => {
  157. // Each section just barely meets the threshold — this is the
  158. // breath/transition chapter case the principle wants to keep legal.
  159. const sparseBody = [
  160. "## 当前任务",
  161. "主角与协作者在码头会合,交换上一案的情报与下一步行动安排。",
  162. "",
  163. "## 读者此刻在等什么",
  164. "读者在等线索是否真被坐实。本章只给出半成品的暗示。",
  165. "",
  166. "## 该兑现的 / 暂不掀的",
  167. "该兑现:暗示线索成形;暂不掀:幕后是谁,留到第 20 章。",
  168. "",
  169. "## 日常/过渡承担什么任务",
  170. "码头闲笔铺人物关系,让协作者的犹豫成为下一章的钩。",
  171. "",
  172. "## 关键抉择过三连问",
  173. "主角选信协作者,因为情报缺口只能从这里补;符合利益与人设。",
  174. "",
  175. "## 章尾必须发生的改变",
  176. "关系改变:主角和协作者从交易关系微微转向共担风险的同伴。",
  177. "",
  178. "## 不要做",
  179. "无",
  180. ].join("\n");
  181. const memo = parseMemo(makeRaw({ body: sparseBody }), 12, false);
  182. expect(memo.body).toContain("## 当前任务");
  183. expect(memo.body).toContain("## 不要做");
  184. });
  185. it('accepts "## 不要做" with very short content like "无" / "N/A" (relaxed threshold)', () => {
  186. // The "do not" section uses a 5-char minimum so books with no extra
  187. // chapter-level prohibitions can say so without inventing filler.
  188. const body = SECTIONS.replace(
  189. /## 不要做\n[\s\S]*$/,
  190. "## 不要做\n无。",
  191. );
  192. const memo = parseMemo(makeRaw({ body }), 12, false);
  193. expect(memo.body).toContain("无。");
  194. });
  195. it("rejects empty '## 不要做' even with the relaxed threshold", () => {
  196. const body = SECTIONS.replace(
  197. /## 不要做\n[\s\S]*$/,
  198. "## 不要做\n",
  199. );
  200. expect(() => parseMemo(makeRaw({ body }), 12, false))
  201. .toThrow(/empty sections.*不要做/);
  202. });
  203. });
  204. });