Răsfoiți Sursa

test(v13): cover issue cases for hotfix round 3 [phase hotfix 8]

New test coverage for the 7 correctness fixes shipped in this round.
All tests are positive + negative pairs that pin the new behavior so a
future regression surfaces at parse / typecheck time.

Issue 1 — phase5-hotfix.test.ts: shim-only book_rules.md fallback
  - Common new-book path: broken story_frame YAML + book_rules shim →
    null + warning, NOT a silent default-empty fallback.

Issue 2 — pages/truth-files-state.test.ts (new file): legacy: true UI
  - deriveFilePresentation: Edit allowed for outline files, blocked for
    legacy shims, links to the authoritative outline path.

Issue 3 — pipeline-agent.test.ts + studio/phase5-hotfix.test.ts:
  - write_truth_file accepts roles/major + roles/minor (en locale),
    rejects unknown tier dirs, preserves traversal safety.
  - Studio truth GET/PUT and listing surface en-locale role files.

Issue 7 — chapter-memo-parser.test.ts: empty-section rejection
  - All-blank payload rejected with empty-sections error.
  - Whitespace-only or "TODO" payload rejected.
  - Sparse-but-non-empty memo accepted (Phase 6 principle).
  - "## 不要做" with "无。" accepted (relaxed threshold).
  - "## 不要做" left blank still rejected.

(Issue 4 end-to-end English planner test was bundled with the issue 4
fix commit because it asserts on the prompt content the fix produces.
Issue 5 + 6 tests were similarly bundled with their fixes.)
Ma 6 luni în urmă
părinte
comite
ce08cab268

+ 95 - 2
packages/core/src/__tests__/chapter-memo-parser.test.ts

@@ -3,7 +3,7 @@ import { parseMemo, PlannerParseError } from "../utils/chapter-memo-parser.js";
 
 const SECTIONS = `
 ## 当前任务
-主角进入七号门查看被动过手脚的痕迹。
+主角进入七号门现场,比对锁芯刮痕与监控时间线,把"被动过手脚"从猜测钉成实证。
 
 ## 读者此刻在等什么
 1) 读者在等七号门异常是否实锤
@@ -14,7 +14,7 @@ const SECTIONS = `
 - 暂不掀:幕后主使 → 压到第 20 章
 
 ## 日常/过渡承担什么任务
-不适用 - 本章无日常过渡
+不适用 - 本章为高压实证章,无日常过渡段。
 
 ## 关键抉择过三连问
 - 主角本章最关键的一次选择:
@@ -137,4 +137,97 @@ describe("parseMemo", () => {
     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.*不要做/);
+    });
+  });
 });

+ 30 - 0
packages/core/src/__tests__/phase5-hotfix.test.ts

@@ -196,6 +196,36 @@ describe("Phase 5 hotfix 3 — broken frontmatter fallback", () => {
     expect(warnMessage).toMatch(/story_frame\.md frontmatter is malformed/);
   });
 
+  it("readBookRules with broken story_frame frontmatter AND shim-only book_rules.md returns null (does NOT silently zero rules)", async () => {
+    // Phase hotfix 1 — common new-book path: story_frame YAML is broken,
+    // book_rules.md exists only as the architect-emitted compat shim with no
+    // YAML. Falling back to it as default-empty rules silently wipes
+    // protagonist / prohibitions / genreLock. Must return null + warn.
+    const storyDir = join(bookDir, "story");
+    await mkdir(join(storyDir, "outline"), { recursive: true });
+    await writeFile(
+      join(storyDir, "outline/story_frame.md"),
+      "---\nprotagonist: [unterminated\n---\n",
+      "utf-8",
+    );
+    await writeFile(
+      join(storyDir, "book_rules.md"),
+      "# 本书规则(兼容指针——已废弃)\n\n> 本文件仅为外部读取保留。权威 YAML frontmatter(protagonist / prohibitions / genreLock / ...)已迁移至 outline/story_frame.md 顶部。",
+      "utf-8",
+    );
+
+    const parsed = await readBookRules(bookDir);
+    expect(parsed).toBeNull();
+    // Two warnings expected: one for the broken story_frame frontmatter,
+    // one for the shim fallback.
+    expect(warnSpy).toHaveBeenCalled();
+    const allWarnings = warnSpy.mock.calls
+      .map((call) => String(call[0] ?? ""))
+      .join("\n");
+    expect(allWarnings).toMatch(/story_frame\.md frontmatter is malformed/);
+    expect(allWarnings).toMatch(/Phase 5 compat shim/);
+  });
+
   it("readBookRules with broken frontmatter AND no legacy returns null (with warning)", async () => {
     const storyDir = join(bookDir, "story");
     await mkdir(join(storyDir, "outline"), { recursive: true });

+ 72 - 0
packages/core/src/__tests__/pipeline-agent.test.ts

@@ -256,4 +256,76 @@ describe("agent pipeline tools", () => {
 
     expect(result.error).toContain("章节进度");
   });
+
+  // Phase hotfix 3: write_truth_file must accept both Chinese and English
+  // role-dir paths so English-layout books are writable, not just readable.
+  it("accepts roles/主要角色/<name>.md (zh locale)", async () => {
+    const result = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "write_truth_file",
+      {
+        bookId,
+        fileName: "roles/主要角色/林辞.md",
+        content: "# 林辞\n核心标签:沉默",
+      },
+    ));
+    expect(result.error).toBeUndefined();
+    const written = await readFile(
+      join(state.bookDir(bookId), "story", "roles/主要角色/林辞.md"),
+      "utf-8",
+    );
+    expect(written).toContain("核心标签");
+  });
+
+  it("accepts roles/major/<name>.md (en locale)", async () => {
+    const result = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "write_truth_file",
+      {
+        bookId,
+        fileName: "roles/major/Mara.md",
+        content: "# Mara\nCore tag: stoic",
+      },
+    ));
+    expect(result.error).toBeUndefined();
+    const written = await readFile(
+      join(state.bookDir(bookId), "story", "roles/major/Mara.md"),
+      "utf-8",
+    );
+    expect(written).toContain("Core tag");
+  });
+
+  it("accepts roles/minor/<name>.md (en locale)", async () => {
+    const result = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "write_truth_file",
+      {
+        bookId,
+        fileName: "roles/minor/Kit.md",
+        content: "# Kit\nMinor ally",
+      },
+    ));
+    expect(result.error).toBeUndefined();
+  });
+
+  it("rejects unknown role tier dirs (path-traversal safety preserved)", async () => {
+    const result = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "write_truth_file",
+      {
+        bookId,
+        fileName: "roles/其他/X.md",
+        content: "# X",
+      },
+    ));
+    expect(result.error).toBeDefined();
+  });
 });

+ 57 - 0
packages/studio/src/api/phase5-hotfix.test.ts

@@ -235,6 +235,63 @@ describe("Phase 5 hotfix 1 — Studio truth file endpoints", () => {
     expect(saved).toContain("# Updated");
   });
 
+  // Phase hotfix 3: en-locale role dirs (roles/major, roles/minor) must be
+  // first-class — readable, writable, listable. Previously the runtime
+  // could read them but Studio could not surface them.
+  it("serves roles/major/<name>.md (en locale)", async () => {
+    await mkdir(join(storyDir, "roles/major"), { recursive: true });
+    await writeFile(
+      join(storyDir, "roles/major/Mara.md"),
+      "# Mara\nCore tag: stoic",
+      "utf-8",
+    );
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request(
+      "http://localhost/api/books/hotfix-book/truth/roles/major/Mara.md",
+    );
+    expect(response.status).toBe(200);
+    const body = await response.json() as { content: string };
+    expect(body.content).toContain("Core tag");
+  });
+
+  it("writes roles/minor/<name>.md (en locale) via PUT", async () => {
+    await mkdir(join(storyDir, "roles/minor"), { recursive: true });
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request(
+      "http://localhost/api/books/hotfix-book/truth/roles/minor/Kit.md",
+      {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ content: "# Kit\nMinor ally" }),
+      },
+    );
+    expect(response.status).toBe(200);
+    const saved = await readFile(join(storyDir, "roles/minor/Kit.md"), "utf-8");
+    expect(saved).toContain("Minor ally");
+  });
+
+  it("lists en-locale role dirs alongside zh-locale dirs", async () => {
+    await mkdir(join(storyDir, "roles/major"), { recursive: true });
+    await mkdir(join(storyDir, "roles/minor"), { recursive: true });
+    await writeFile(join(storyDir, "roles/major/Mara.md"), "major", "utf-8");
+    await writeFile(join(storyDir, "roles/minor/Kit.md"), "minor", "utf-8");
+    await writeFile(join(storyDir, "roles/主要角色/主角甲.md"), "zh-major", "utf-8");
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/books/hotfix-book/truth");
+    const body = await response.json() as { files: ReadonlyArray<{ name: string }> };
+    const names = body.files.map((f) => f.name);
+    expect(names).toContain("roles/major/Mara.md");
+    expect(names).toContain("roles/minor/Kit.md");
+    expect(names).toContain("roles/主要角色/主角甲.md");
+  });
+
   it("lists outline/* and roles/**/*.md files in the truth browser", async () => {
     await writeFile(join(storyDir, "outline/story_frame.md"), "frame", "utf-8");
     await writeFile(join(storyDir, "outline/volume_map.md"), "map", "utf-8");

+ 61 - 0
packages/studio/src/pages/truth-files-state.test.ts

@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+import { deriveFilePresentation, SHIM_AUTHORITATIVE_PATH } from "./TruthFiles";
+
+describe("deriveFilePresentation", () => {
+  it("allows editing for non-legacy outline files", () => {
+    const result = deriveFilePresentation("outline/story_frame.md", {
+      content: "# frame",
+      legacy: false,
+    });
+    expect(result.legacy).toBe(false);
+    expect(result.canEdit).toBe(true);
+    expect(result.authoritativePath).toBeNull();
+  });
+
+  it("allows editing when legacy flag is absent", () => {
+    const result = deriveFilePresentation("outline/volume_map.md", {
+      content: "map",
+    });
+    expect(result.canEdit).toBe(true);
+    expect(result.legacy).toBe(false);
+  });
+
+  it("blocks editing and surfaces authoritative path when legacy: true", () => {
+    const result = deriveFilePresentation("book_rules.md", {
+      content: "# Legacy shim",
+      legacy: true,
+    });
+    expect(result.legacy).toBe(true);
+    expect(result.canEdit).toBe(false);
+    expect(result.authoritativePath).toBe("outline/story_frame.md");
+  });
+
+  it("blocks editing for story_bible.md shim and links to story_frame.md", () => {
+    const result = deriveFilePresentation("story_bible.md", {
+      content: "# bible shim",
+      legacy: true,
+    });
+    expect(result.legacy).toBe(true);
+    expect(result.canEdit).toBe(false);
+    expect(result.authoritativePath).toBe("outline/story_frame.md");
+  });
+
+  it("blocks editing when no file is selected", () => {
+    const result = deriveFilePresentation(null, undefined);
+    expect(result.canEdit).toBe(false);
+  });
+
+  it("blocks editing when file has no content (e.g. 404)", () => {
+    const result = deriveFilePresentation("outline/story_frame.md", {
+      content: null,
+    });
+    expect(result.canEdit).toBe(false);
+  });
+});
+
+describe("SHIM_AUTHORITATIVE_PATH", () => {
+  it("maps every shim file to its outline replacement", () => {
+    expect(SHIM_AUTHORITATIVE_PATH["story_bible.md"]).toBe("outline/story_frame.md");
+    expect(SHIM_AUTHORITATIVE_PATH["book_rules.md"]).toBe("outline/story_frame.md");
+  });
+});