| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- import { describe, it, expect } from "vitest";
- import { parseWriterOutput, parseCreativeOutput, type ParsedWriterOutput } from "../agents/writer-parser.js";
- import type { GenreProfile } from "../models/genre-profile.js";
- import { countChapterLength } from "../utils/length-metrics.js";
- const defaultGenreProfile: GenreProfile = {
- name: "测试",
- id: "test",
- language: "zh",
- chapterTypes: [],
- fatigueWords: [],
- numericalSystem: true,
- powerScaling: false,
- eraResearch: false,
- pacingRule: "",
- satisfactionTypes: [],
- auditDimensions: [],
- };
- function callParseOutput(
- chapterNumber: number,
- content: string,
- genreProfile: GenreProfile = defaultGenreProfile,
- countingMode: "zh_chars" | "en_words" = "zh_chars",
- ): ParsedWriterOutput {
- return parseWriterOutput(chapterNumber, content, genreProfile, countingMode);
- }
- // ---------------------------------------------------------------------------
- // Full tagged output
- // ---------------------------------------------------------------------------
- describe("WriterAgent parseOutput", () => {
- const fullOutput = [
- "=== PRE_WRITE_CHECK ===",
- "| 检查项 | 本章记录 | 备注 |",
- "|--------|----------|------|",
- "| 上下文范围 | 第1章 | |",
- "",
- "=== CHAPTER_TITLE ===",
- "吞天之始",
- "",
- "=== CHAPTER_CONTENT ===",
- "陈风站在悬崖边,俯视着脚下的万丈深渊。",
- "一股强烈的吸力从深渊中传来,仿佛有什么东西在召唤他。",
- "",
- "=== POST_SETTLEMENT ===",
- "| 结算项 | 本章记录 | 备注 |",
- "|--------|----------|------|",
- "| 资源账本 | 期初0 / 增量+100 / 期末100 | |",
- "",
- "=== UPDATED_STATE ===",
- "# 状态卡",
- "| 字段 | 值 |",
- "|------|-----|",
- "| 章节 | 1 |",
- "",
- "=== UPDATED_LEDGER ===",
- "# 资源账本",
- "| 章节 | 期初 | 来源 | 增量 | 期末 |",
- "|------|------|------|------|------|",
- "| 1 | 0 | 深渊果实 | +100 | 100 |",
- "",
- "=== UPDATED_HOOKS ===",
- "# 伏笔池",
- "| ID | 伏笔 | 状态 |",
- "|-----|------|------|",
- "| H001 | 深渊之物 | open |",
- ].join("\n");
- it("extracts all sections from a complete tagged output", () => {
- const result = callParseOutput(1, fullOutput);
- expect(result.chapterNumber).toBe(1);
- expect(result.title).toBe("吞天之始");
- expect(result.content).toContain("陈风站在悬崖边");
- expect(result.content).toContain("召唤他");
- expect(result.preWriteCheck).toContain("检查项");
- expect(result.postSettlement).toContain("资源账本");
- expect(result.updatedState).toContain("状态卡");
- expect(result.updatedLedger).toContain("深渊果实");
- expect(result.updatedHooks).toContain("H001");
- });
- it("calculates wordCount with the shared counting helper", () => {
- const result = callParseOutput(1, fullOutput);
- const expectedContent =
- "陈风站在悬崖边,俯视着脚下的万丈深渊。\n一股强烈的吸力从深渊中传来,仿佛有什么东西在召唤他。";
- expect(result.wordCount).toBe(countChapterLength(expectedContent, "zh_chars"));
- });
- // -------------------------------------------------------------------------
- // Missing sections
- // -------------------------------------------------------------------------
- it("returns default title when CHAPTER_TITLE is missing", () => {
- const output = [
- "=== CHAPTER_CONTENT ===",
- "Some content here.",
- ].join("\n");
- const result = callParseOutput(42, output);
- expect(result.title).toBe("第42章");
- });
- it("returns empty content when CHAPTER_CONTENT is missing", () => {
- const output = [
- "=== CHAPTER_TITLE ===",
- "A Title",
- ].join("\n");
- const result = callParseOutput(1, output);
- expect(result.content).toBe("");
- expect(result.wordCount).toBe(0);
- });
- it("returns fallback strings for missing state sections", () => {
- const output = [
- "=== CHAPTER_TITLE ===",
- "Title",
- "",
- "=== CHAPTER_CONTENT ===",
- "Content.",
- ].join("\n");
- const result = callParseOutput(1, output);
- expect(result.updatedState).toBe("(状态卡未更新)");
- expect(result.updatedLedger).toBe("(账本未更新)");
- expect(result.updatedHooks).toBe("(伏笔池未更新)");
- });
- it("returns empty string for missing PRE_WRITE_CHECK", () => {
- const output = [
- "=== CHAPTER_TITLE ===",
- "Title",
- "",
- "=== CHAPTER_CONTENT ===",
- "Content.",
- ].join("\n");
- const result = callParseOutput(1, output);
- expect(result.preWriteCheck).toBe("");
- });
- it("returns empty string for missing POST_SETTLEMENT", () => {
- const output = [
- "=== CHAPTER_TITLE ===",
- "Title",
- "",
- "=== CHAPTER_CONTENT ===",
- "Content.",
- ].join("\n");
- const result = callParseOutput(1, output);
- expect(result.postSettlement).toBe("");
- });
- // -------------------------------------------------------------------------
- // Edge cases
- // -------------------------------------------------------------------------
- it("handles completely empty input", () => {
- const result = callParseOutput(1, "");
- expect(result.chapterNumber).toBe(1);
- expect(result.title).toBe("第1章");
- expect(result.content).toBe("");
- expect(result.wordCount).toBe(0);
- expect(result.updatedState).toBe("(状态卡未更新)");
- expect(result.updatedLedger).toBe("(账本未更新)");
- expect(result.updatedHooks).toBe("(伏笔池未更新)");
- });
- it("handles content with no tags at all", () => {
- const result = callParseOutput(5, "Just some random text without tags");
- expect(result.title).toBe("第5章");
- expect(result.content).toBe("");
- expect(result.wordCount).toBe(0);
- });
- it("preserves multiline content within a section", () => {
- const output = [
- "=== CHAPTER_CONTENT ===",
- "第一段:这里是开头。",
- "",
- "第二段:这里是中间。",
- "",
- "第三段:这里是结尾。",
- "",
- "=== POST_SETTLEMENT ===",
- "No settlement.",
- ].join("\n");
- const result = callParseOutput(1, output);
- expect(result.content).toContain("第一段");
- expect(result.content).toContain("第二段");
- expect(result.content).toContain("第三段");
- });
- it("trims whitespace from extracted section values", () => {
- const output = [
- "=== CHAPTER_TITLE ===",
- " 吞天之始 ",
- "",
- "=== CHAPTER_CONTENT ===",
- " 内容 ",
- ].join("\n");
- const result = callParseOutput(1, output);
- expect(result.title).toBe("吞天之始");
- expect(result.content).toBe("内容");
- });
- it("correctly counts Chinese characters in wordCount", () => {
- const chineseContent = "这是一段测试文本,包含二十个中文字符加上标点符号。";
- const output = [
- "=== CHAPTER_CONTENT ===",
- chineseContent,
- ].join("\n");
- const result = callParseOutput(1, output);
- // wordCount is content.length which counts each character (including punctuation)
- expect(result.wordCount).toBe(chineseContent.length);
- });
- it("counts English content with the shared counting helper when requested", () => {
- const englishContent = "He looked at the sky.";
- const output = [
- "=== CHAPTER_CONTENT ===",
- englishContent,
- ].join("\n");
- const result = callParseOutput(1, output, defaultGenreProfile, "en_words");
- expect(result.wordCount).toBe(countChapterLength(englishContent, "en_words"));
- });
- });
- // ---------------------------------------------------------------------------
- // Fallback parsing for local/small models (#13)
- // ---------------------------------------------------------------------------
- describe("parseCreativeOutput fallback", () => {
- it("extracts content from markdown heading when tags are missing", () => {
- const raw = `# 第1章 觉醒之日
- 林风缓缓睁开了眼睛,映入眼帘的是一片陌生的天花板。他的脑海中充斥着混乱的记忆碎片,${"一段很长的正文内容".repeat(30)}完。`;
- const result = parseCreativeOutput(1, raw);
- expect(result.title).toBe("觉醒之日");
- expect(result.content.length).toBeGreaterThan(100);
- expect(result.content).toContain("林风");
- });
- it("extracts content from 正文 label when tags are missing", () => {
- const raw = `章节标题:暗夜追踪
- 正文:
- ${"黑暗中一道身影掠过屋顶,无声无息。".repeat(20)}`;
- const result = parseCreativeOutput(5, raw);
- expect(result.title).toBe("暗夜追踪");
- expect(result.content.length).toBeGreaterThan(100);
- });
- it("falls back to longest prose block when no structure is found", () => {
- const prose = "这是一段完整的小说正文,描述了主角在黑暗中探索未知世界的经历。".repeat(10);
- const raw = `PRE_WRITE_CHECK: 已完成自检
- CHAPTER_TITLE: 探索
- ${prose}`;
- const result = parseCreativeOutput(3, raw);
- expect(result.content.length).toBeGreaterThan(100);
- });
- it("returns empty content when raw output is too short", () => {
- const result = parseCreativeOutput(1, "太短了");
- expect(result.content).toBe("");
- expect(result.title).toBe("第1章");
- });
- it("still works with proper === TAG === format", () => {
- const raw = `=== PRE_WRITE_CHECK ===
- 自检完成
- === CHAPTER_TITLE ===
- 正常标题
- === CHAPTER_CONTENT ===
- 正常的章节内容,这里是完整的正文。`;
- const result = parseCreativeOutput(1, raw);
- expect(result.title).toBe("正常标题");
- expect(result.content).toBe("正常的章节内容,这里是完整的正文。");
- });
- it("counts creative output with the shared helper when a counting mode is supplied", () => {
- const raw = `=== CHAPTER_TITLE ===
- English Chapter
- === CHAPTER_CONTENT ===
- He looked at the sky.`;
- const result = parseCreativeOutput(1, raw, "en_words");
- expect(result.wordCount).toBe(countChapterLength("He looked at the sky.", "en_words"));
- });
- });
|