writer-parser.test.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import { describe, it, expect } from "vitest";
  2. import { parseWriterOutput, parseCreativeOutput, type ParsedWriterOutput } from "../agents/writer-parser.js";
  3. import type { GenreProfile } from "../models/genre-profile.js";
  4. import { countChapterLength } from "../utils/length-metrics.js";
  5. const defaultGenreProfile: GenreProfile = {
  6. name: "测试",
  7. id: "test",
  8. language: "zh",
  9. chapterTypes: [],
  10. fatigueWords: [],
  11. numericalSystem: true,
  12. powerScaling: false,
  13. eraResearch: false,
  14. pacingRule: "",
  15. satisfactionTypes: [],
  16. auditDimensions: [],
  17. };
  18. function callParseOutput(
  19. chapterNumber: number,
  20. content: string,
  21. genreProfile: GenreProfile = defaultGenreProfile,
  22. countingMode: "zh_chars" | "en_words" = "zh_chars",
  23. ): ParsedWriterOutput {
  24. return parseWriterOutput(chapterNumber, content, genreProfile, countingMode);
  25. }
  26. // ---------------------------------------------------------------------------
  27. // Full tagged output
  28. // ---------------------------------------------------------------------------
  29. describe("WriterAgent parseOutput", () => {
  30. const fullOutput = [
  31. "=== PRE_WRITE_CHECK ===",
  32. "| 检查项 | 本章记录 | 备注 |",
  33. "|--------|----------|------|",
  34. "| 上下文范围 | 第1章 | |",
  35. "",
  36. "=== CHAPTER_TITLE ===",
  37. "吞天之始",
  38. "",
  39. "=== CHAPTER_CONTENT ===",
  40. "陈风站在悬崖边,俯视着脚下的万丈深渊。",
  41. "一股强烈的吸力从深渊中传来,仿佛有什么东西在召唤他。",
  42. "",
  43. "=== POST_SETTLEMENT ===",
  44. "| 结算项 | 本章记录 | 备注 |",
  45. "|--------|----------|------|",
  46. "| 资源账本 | 期初0 / 增量+100 / 期末100 | |",
  47. "",
  48. "=== UPDATED_STATE ===",
  49. "# 状态卡",
  50. "| 字段 | 值 |",
  51. "|------|-----|",
  52. "| 章节 | 1 |",
  53. "",
  54. "=== UPDATED_LEDGER ===",
  55. "# 资源账本",
  56. "| 章节 | 期初 | 来源 | 增量 | 期末 |",
  57. "|------|------|------|------|------|",
  58. "| 1 | 0 | 深渊果实 | +100 | 100 |",
  59. "",
  60. "=== UPDATED_HOOKS ===",
  61. "# 伏笔池",
  62. "| ID | 伏笔 | 状态 |",
  63. "|-----|------|------|",
  64. "| H001 | 深渊之物 | open |",
  65. ].join("\n");
  66. it("extracts all sections from a complete tagged output", () => {
  67. const result = callParseOutput(1, fullOutput);
  68. expect(result.chapterNumber).toBe(1);
  69. expect(result.title).toBe("吞天之始");
  70. expect(result.content).toContain("陈风站在悬崖边");
  71. expect(result.content).toContain("召唤他");
  72. expect(result.preWriteCheck).toContain("检查项");
  73. expect(result.postSettlement).toContain("资源账本");
  74. expect(result.updatedState).toContain("状态卡");
  75. expect(result.updatedLedger).toContain("深渊果实");
  76. expect(result.updatedHooks).toContain("H001");
  77. });
  78. it("calculates wordCount with the shared counting helper", () => {
  79. const result = callParseOutput(1, fullOutput);
  80. const expectedContent =
  81. "陈风站在悬崖边,俯视着脚下的万丈深渊。\n一股强烈的吸力从深渊中传来,仿佛有什么东西在召唤他。";
  82. expect(result.wordCount).toBe(countChapterLength(expectedContent, "zh_chars"));
  83. });
  84. // -------------------------------------------------------------------------
  85. // Missing sections
  86. // -------------------------------------------------------------------------
  87. it("returns default title when CHAPTER_TITLE is missing", () => {
  88. const output = [
  89. "=== CHAPTER_CONTENT ===",
  90. "Some content here.",
  91. ].join("\n");
  92. const result = callParseOutput(42, output);
  93. expect(result.title).toBe("第42章");
  94. });
  95. it("returns empty content when CHAPTER_CONTENT is missing", () => {
  96. const output = [
  97. "=== CHAPTER_TITLE ===",
  98. "A Title",
  99. ].join("\n");
  100. const result = callParseOutput(1, output);
  101. expect(result.content).toBe("");
  102. expect(result.wordCount).toBe(0);
  103. });
  104. it("returns fallback strings for missing state sections", () => {
  105. const output = [
  106. "=== CHAPTER_TITLE ===",
  107. "Title",
  108. "",
  109. "=== CHAPTER_CONTENT ===",
  110. "Content.",
  111. ].join("\n");
  112. const result = callParseOutput(1, output);
  113. expect(result.updatedState).toBe("(状态卡未更新)");
  114. expect(result.updatedLedger).toBe("(账本未更新)");
  115. expect(result.updatedHooks).toBe("(伏笔池未更新)");
  116. });
  117. it("returns empty string for missing PRE_WRITE_CHECK", () => {
  118. const output = [
  119. "=== CHAPTER_TITLE ===",
  120. "Title",
  121. "",
  122. "=== CHAPTER_CONTENT ===",
  123. "Content.",
  124. ].join("\n");
  125. const result = callParseOutput(1, output);
  126. expect(result.preWriteCheck).toBe("");
  127. });
  128. it("returns empty string for missing POST_SETTLEMENT", () => {
  129. const output = [
  130. "=== CHAPTER_TITLE ===",
  131. "Title",
  132. "",
  133. "=== CHAPTER_CONTENT ===",
  134. "Content.",
  135. ].join("\n");
  136. const result = callParseOutput(1, output);
  137. expect(result.postSettlement).toBe("");
  138. });
  139. // -------------------------------------------------------------------------
  140. // Edge cases
  141. // -------------------------------------------------------------------------
  142. it("handles completely empty input", () => {
  143. const result = callParseOutput(1, "");
  144. expect(result.chapterNumber).toBe(1);
  145. expect(result.title).toBe("第1章");
  146. expect(result.content).toBe("");
  147. expect(result.wordCount).toBe(0);
  148. expect(result.updatedState).toBe("(状态卡未更新)");
  149. expect(result.updatedLedger).toBe("(账本未更新)");
  150. expect(result.updatedHooks).toBe("(伏笔池未更新)");
  151. });
  152. it("handles content with no tags at all", () => {
  153. const result = callParseOutput(5, "Just some random text without tags");
  154. expect(result.title).toBe("第5章");
  155. expect(result.content).toBe("");
  156. expect(result.wordCount).toBe(0);
  157. });
  158. it("preserves multiline content within a section", () => {
  159. const output = [
  160. "=== CHAPTER_CONTENT ===",
  161. "第一段:这里是开头。",
  162. "",
  163. "第二段:这里是中间。",
  164. "",
  165. "第三段:这里是结尾。",
  166. "",
  167. "=== POST_SETTLEMENT ===",
  168. "No settlement.",
  169. ].join("\n");
  170. const result = callParseOutput(1, output);
  171. expect(result.content).toContain("第一段");
  172. expect(result.content).toContain("第二段");
  173. expect(result.content).toContain("第三段");
  174. });
  175. it("trims whitespace from extracted section values", () => {
  176. const output = [
  177. "=== CHAPTER_TITLE ===",
  178. " 吞天之始 ",
  179. "",
  180. "=== CHAPTER_CONTENT ===",
  181. " 内容 ",
  182. ].join("\n");
  183. const result = callParseOutput(1, output);
  184. expect(result.title).toBe("吞天之始");
  185. expect(result.content).toBe("内容");
  186. });
  187. it("correctly counts Chinese characters in wordCount", () => {
  188. const chineseContent = "这是一段测试文本,包含二十个中文字符加上标点符号。";
  189. const output = [
  190. "=== CHAPTER_CONTENT ===",
  191. chineseContent,
  192. ].join("\n");
  193. const result = callParseOutput(1, output);
  194. // wordCount is content.length which counts each character (including punctuation)
  195. expect(result.wordCount).toBe(chineseContent.length);
  196. });
  197. it("counts English content with the shared counting helper when requested", () => {
  198. const englishContent = "He looked at the sky.";
  199. const output = [
  200. "=== CHAPTER_CONTENT ===",
  201. englishContent,
  202. ].join("\n");
  203. const result = callParseOutput(1, output, defaultGenreProfile, "en_words");
  204. expect(result.wordCount).toBe(countChapterLength(englishContent, "en_words"));
  205. });
  206. });
  207. // ---------------------------------------------------------------------------
  208. // Fallback parsing for local/small models (#13)
  209. // ---------------------------------------------------------------------------
  210. describe("parseCreativeOutput fallback", () => {
  211. it("extracts content from markdown heading when tags are missing", () => {
  212. const raw = `# 第1章 觉醒之日
  213. 林风缓缓睁开了眼睛,映入眼帘的是一片陌生的天花板。他的脑海中充斥着混乱的记忆碎片,${"一段很长的正文内容".repeat(30)}完。`;
  214. const result = parseCreativeOutput(1, raw);
  215. expect(result.title).toBe("觉醒之日");
  216. expect(result.content.length).toBeGreaterThan(100);
  217. expect(result.content).toContain("林风");
  218. });
  219. it("extracts content from 正文 label when tags are missing", () => {
  220. const raw = `章节标题:暗夜追踪
  221. 正文:
  222. ${"黑暗中一道身影掠过屋顶,无声无息。".repeat(20)}`;
  223. const result = parseCreativeOutput(5, raw);
  224. expect(result.title).toBe("暗夜追踪");
  225. expect(result.content.length).toBeGreaterThan(100);
  226. });
  227. it("falls back to longest prose block when no structure is found", () => {
  228. const prose = "这是一段完整的小说正文,描述了主角在黑暗中探索未知世界的经历。".repeat(10);
  229. const raw = `PRE_WRITE_CHECK: 已完成自检
  230. CHAPTER_TITLE: 探索
  231. ${prose}`;
  232. const result = parseCreativeOutput(3, raw);
  233. expect(result.content.length).toBeGreaterThan(100);
  234. });
  235. it("returns empty content when raw output is too short", () => {
  236. const result = parseCreativeOutput(1, "太短了");
  237. expect(result.content).toBe("");
  238. expect(result.title).toBe("第1章");
  239. });
  240. it("still works with proper === TAG === format", () => {
  241. const raw = `=== PRE_WRITE_CHECK ===
  242. 自检完成
  243. === CHAPTER_TITLE ===
  244. 正常标题
  245. === CHAPTER_CONTENT ===
  246. 正常的章节内容,这里是完整的正文。`;
  247. const result = parseCreativeOutput(1, raw);
  248. expect(result.title).toBe("正常标题");
  249. expect(result.content).toBe("正常的章节内容,这里是完整的正文。");
  250. });
  251. it("counts creative output with the shared helper when a counting mode is supplied", () => {
  252. const raw = `=== CHAPTER_TITLE ===
  253. English Chapter
  254. === CHAPTER_CONTENT ===
  255. He looked at the sky.`;
  256. const result = parseCreativeOutput(1, raw, "en_words");
  257. expect(result.wordCount).toBe(countChapterLength("He looked at the sky.", "en_words"));
  258. });
  259. });