|
|
@@ -0,0 +1,343 @@
|
|
|
+/**
|
|
|
+ * v13 hotfix round 4 — tests for:
|
|
|
+ * Issue 1: old-book editing (no outline/story_frame.md) — isNewLayoutBook
|
|
|
+ * Issue 2: per-chapter promotion pass
|
|
|
+ * Issue 3: word-boundary + column-scoped regex for advanced_count
|
|
|
+ *
|
|
|
+ * Studio server tests for Issue 1 live in packages/studio/src/api/v13-hotfix-round4.test.ts.
|
|
|
+ */
|
|
|
+
|
|
|
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
+import { tmpdir } from "node:os";
|
|
|
+import { join } from "node:path";
|
|
|
+import {
|
|
|
+ isNewLayoutBook,
|
|
|
+} from "../utils/outline-paths.js";
|
|
|
+import {
|
|
|
+ deriveAdvancedCountsFromSummaries,
|
|
|
+ escapeRegex,
|
|
|
+ rerunPromotionPass,
|
|
|
+} from "../utils/hook-promotion.js";
|
|
|
+import {
|
|
|
+ parsePendingHooksMarkdown,
|
|
|
+ renderHookSnapshot,
|
|
|
+} from "../utils/story-markdown.js";
|
|
|
+import type { StoredHook } from "../state/memory-db.js";
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Issue 1: isNewLayoutBook — old vs new book detection
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe("Issue 1 — isNewLayoutBook detection", () => {
|
|
|
+ let bookDir: string;
|
|
|
+
|
|
|
+ beforeEach(async () => {
|
|
|
+ bookDir = await mkdtemp(join(tmpdir(), "inkos-layout-detect-"));
|
|
|
+ await mkdir(join(bookDir, "story"), { recursive: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ afterEach(async () => {
|
|
|
+ await rm(bookDir, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ it("returns false when outline/story_frame.md does not exist (legacy book)", async () => {
|
|
|
+ await writeFile(join(bookDir, "story", "story_bible.md"), "# Story Bible", "utf-8");
|
|
|
+ expect(await isNewLayoutBook(bookDir)).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("returns true when outline/story_frame.md exists (new layout book)", async () => {
|
|
|
+ await mkdir(join(bookDir, "story", "outline"), { recursive: true });
|
|
|
+ await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "# Frame", "utf-8");
|
|
|
+ expect(await isNewLayoutBook(bookDir)).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("returns false when story/ dir does not exist at all", async () => {
|
|
|
+ const emptyDir = await mkdtemp(join(tmpdir(), "inkos-empty-"));
|
|
|
+ try {
|
|
|
+ expect(await isNewLayoutBook(emptyDir)).toBe(false);
|
|
|
+ } finally {
|
|
|
+ await rm(emptyDir, { recursive: true, force: true });
|
|
|
+ }
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Issue 1: write_truth_file — old book allows story_bible.md writes
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe("Issue 1 — write_truth_file per-book shim detection", () => {
|
|
|
+ let bookDir: string;
|
|
|
+
|
|
|
+ beforeEach(async () => {
|
|
|
+ bookDir = await mkdtemp(join(tmpdir(), "inkos-wt-shim-"));
|
|
|
+ await mkdir(join(bookDir, "story"), { recursive: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ afterEach(async () => {
|
|
|
+ await rm(bookDir, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ it("old book: isNewLayoutBook returns false so story_bible.md is writable", async () => {
|
|
|
+ await writeFile(join(bookDir, "story", "story_bible.md"), "# Old", "utf-8");
|
|
|
+ expect(await isNewLayoutBook(bookDir)).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("new book: isNewLayoutBook returns true so story_bible.md is a shim", async () => {
|
|
|
+ await mkdir(join(bookDir, "story", "outline"), { recursive: true });
|
|
|
+ await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "# Frame", "utf-8");
|
|
|
+ await writeFile(join(bookDir, "story", "story_bible.md"), "# Shim", "utf-8");
|
|
|
+ expect(await isNewLayoutBook(bookDir)).toBe(true);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Issue 2: per-chapter promotion pass
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe("Issue 2 — rerunPromotionPass", () => {
|
|
|
+ it("promotes hook when mentioned in 2+ chapters of chapter_summaries", () => {
|
|
|
+ const hooks: StoredHook[] = [
|
|
|
+ {
|
|
|
+ hookId: "H05",
|
|
|
+ startChapter: 3,
|
|
|
+ type: "小承诺",
|
|
|
+ status: "open",
|
|
|
+ lastAdvancedChapter: 7,
|
|
|
+ expectedPayoff: "15章",
|
|
|
+ payoffTiming: "near-term",
|
|
|
+ notes: "",
|
|
|
+ promoted: false,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+ const summaries = [
|
|
|
+ "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
|
|
|
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
|
+ "| 4 | A | X | event | change | H05 推进 | 压抑 | 铺垫 |",
|
|
|
+ "| 7 | B | X | event2 | change2 | H05 推进 | 紧绷 | 推进 |",
|
|
|
+ ].join("\n");
|
|
|
+
|
|
|
+ const result = rerunPromotionPass(hooks, summaries);
|
|
|
+ expect(result.updated).toBe(true);
|
|
|
+ expect(result.flippedCount).toBe(1);
|
|
|
+ expect(result.hooks[0]!.promoted).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("does not promote when mentioned in only 1 chapter", () => {
|
|
|
+ const hooks: StoredHook[] = [
|
|
|
+ {
|
|
|
+ hookId: "H06",
|
|
|
+ startChapter: 5,
|
|
|
+ type: "小",
|
|
|
+ status: "open",
|
|
|
+ lastAdvancedChapter: 0,
|
|
|
+ expectedPayoff: "",
|
|
|
+ notes: "",
|
|
|
+ promoted: false,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+ const summaries = [
|
|
|
+ "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
|
|
|
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
|
+ "| 5 | A | X | event | change | H06 推进 | 压抑 | 铺垫 |",
|
|
|
+ ].join("\n");
|
|
|
+
|
|
|
+ const result = rerunPromotionPass(hooks, summaries);
|
|
|
+ expect(result.updated).toBe(false);
|
|
|
+ expect(result.flippedCount).toBe(0);
|
|
|
+ expect(result.hooks[0]!.promoted).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("skips already-promoted hooks", () => {
|
|
|
+ const hooks: StoredHook[] = [
|
|
|
+ {
|
|
|
+ hookId: "H07",
|
|
|
+ startChapter: 1,
|
|
|
+ type: "主线",
|
|
|
+ status: "open",
|
|
|
+ lastAdvancedChapter: 5,
|
|
|
+ expectedPayoff: "",
|
|
|
+ notes: "",
|
|
|
+ promoted: true,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+ const summaries = [
|
|
|
+ "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
|
|
|
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
|
+ "| 3 | A | X | event | change | H07 推进 | 压抑 | 铺垫 |",
|
|
|
+ "| 5 | B | X | event2 | change2 | H07 推进 | 紧绷 | 推进 |",
|
|
|
+ ].join("\n");
|
|
|
+
|
|
|
+ const result = rerunPromotionPass(hooks, summaries);
|
|
|
+ expect(result.updated).toBe(false);
|
|
|
+ expect(result.flippedCount).toBe(0);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe("Issue 2 — per-chapter promotion persists to pending_hooks.md", () => {
|
|
|
+ let bookDir: string;
|
|
|
+
|
|
|
+ beforeEach(async () => {
|
|
|
+ bookDir = await mkdtemp(join(tmpdir(), "inkos-perchapt-promo-"));
|
|
|
+ await mkdir(join(bookDir, "story"), { recursive: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ afterEach(async () => {
|
|
|
+ await rm(bookDir, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ it("after writing chapter where H05 is mentioned 2nd time, pending_hooks.md shows H05 promoted=true", async () => {
|
|
|
+ const hooks: StoredHook[] = [
|
|
|
+ {
|
|
|
+ hookId: "H05",
|
|
|
+ startChapter: 3,
|
|
|
+ type: "小承诺",
|
|
|
+ status: "open",
|
|
|
+ lastAdvancedChapter: 7,
|
|
|
+ expectedPayoff: "15章",
|
|
|
+ payoffTiming: "near-term",
|
|
|
+ notes: "sister promise",
|
|
|
+ promoted: false,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+ const storyDir = join(bookDir, "story");
|
|
|
+ const ledgerPath = join(storyDir, "pending_hooks.md");
|
|
|
+ await writeFile(ledgerPath, renderHookSnapshot(hooks, "zh"), "utf-8");
|
|
|
+
|
|
|
+ // Simulate chapter_summaries with H05 mentioned in 2 chapters
|
|
|
+ await writeFile(join(storyDir, "chapter_summaries.md"), [
|
|
|
+ "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
|
|
|
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
|
+ "| 4 | 旧信 | 林辞 | 收到家书 | 回忆 | H05 推进 | 压抑 | 铺垫 |",
|
|
|
+ "| 7 | 码头 | 林辞 | 查账 | 汇合 | H05 推进 | 紧绷 | 推进 |",
|
|
|
+ ].join("\n"), "utf-8");
|
|
|
+
|
|
|
+ // Run the promotion pass (same logic as runner.ts step 3c)
|
|
|
+ const ledgerRaw = await readFile(ledgerPath, "utf-8");
|
|
|
+ const parsedHooks = parsePendingHooksMarkdown(ledgerRaw);
|
|
|
+ const summariesRaw = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8");
|
|
|
+ const result = rerunPromotionPass(parsedHooks, summariesRaw);
|
|
|
+
|
|
|
+ expect(result.updated).toBe(true);
|
|
|
+ // Write back
|
|
|
+ await writeFile(ledgerPath, renderHookSnapshot([...result.hooks], "zh"), "utf-8");
|
|
|
+
|
|
|
+ // Verify the file on disk
|
|
|
+ const updated = parsePendingHooksMarkdown(await readFile(ledgerPath, "utf-8"));
|
|
|
+ expect(updated.find((h) => h.hookId === "H05")!.promoted).toBe(true);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Issue 3: word-boundary + column-scoped regex
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe("Issue 3 — escapeRegex", () => {
|
|
|
+ it("escapes special characters", () => {
|
|
|
+ // `-` is not a regex metachar outside character classes, so not escaped
|
|
|
+ expect(escapeRegex("H-01")).toBe("H-01");
|
|
|
+ expect(escapeRegex("H.01")).toBe("H\\.01");
|
|
|
+ expect(escapeRegex("test(1)")).toBe("test\\(1\\)");
|
|
|
+ expect(escapeRegex("hook[0]")).toBe("hook\\[0\\]");
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe("Issue 3 — deriveAdvancedCountsFromSummaries", () => {
|
|
|
+ it("H01 does NOT match H010 in the same row", () => {
|
|
|
+ const summaries = [
|
|
|
+ "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
|
|
|
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
|
+ "| 5 | title | char | event | change | H010 推进 | mood | type |",
|
|
|
+ ].join("\n");
|
|
|
+
|
|
|
+ const counts = deriveAdvancedCountsFromSummaries(summaries, ["H01"]);
|
|
|
+ expect(counts.get("H01")).toBeUndefined();
|
|
|
+ });
|
|
|
+
|
|
|
+ it("H010 matches H010 correctly", () => {
|
|
|
+ const summaries = [
|
|
|
+ "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
|
|
|
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
|
+ "| 5 | title | char | event | change | H010 推进 | mood | type |",
|
|
|
+ ].join("\n");
|
|
|
+
|
|
|
+ const counts = deriveAdvancedCountsFromSummaries(summaries, ["H010"]);
|
|
|
+ expect(counts.get("H010")).toBe(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("hook_id in title column does NOT count as advancement", () => {
|
|
|
+ // H01 appears in the title column (index 1), not hookActivity (index 5)
|
|
|
+ const summaries = [
|
|
|
+ "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
|
|
|
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
|
+ "| 5 | H01的故事 | char | event | change | 其他线索 | mood | type |",
|
|
|
+ ].join("\n");
|
|
|
+
|
|
|
+ const counts = deriveAdvancedCountsFromSummaries(summaries, ["H01"]);
|
|
|
+ expect(counts.get("H01")).toBeUndefined();
|
|
|
+ });
|
|
|
+
|
|
|
+ it("hook_id in hookActivity column DOES count", () => {
|
|
|
+ const summaries = [
|
|
|
+ "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
|
|
|
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
|
+ "| 5 | 普通标题 | char | event | change | H01 推进 | mood | type |",
|
|
|
+ "| 6 | 另一个 | char | event | change | H01 推进 | mood | type |",
|
|
|
+ ].join("\n");
|
|
|
+
|
|
|
+ const counts = deriveAdvancedCountsFromSummaries(summaries, ["H01"]);
|
|
|
+ expect(counts.get("H01")).toBe(2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("works with en-locale headers", () => {
|
|
|
+ const summaries = [
|
|
|
+ "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
|
|
|
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
|
+ "| 5 | old letter | Lin | found letter | memory | H01 advanced | tense | setup |",
|
|
|
+ "| 6 | dock | Lin | check | converge | H01 advanced | tense | progress |",
|
|
|
+ ].join("\n");
|
|
|
+
|
|
|
+ const counts = deriveAdvancedCountsFromSummaries(summaries, ["H01"]);
|
|
|
+ expect(counts.get("H01")).toBe(2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("hook_id in events column (not hookActivity) does not count", () => {
|
|
|
+ const summaries = [
|
|
|
+ "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
|
|
|
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
|
+ "| 5 | title | char | H01 discovered | change | other stuff | mood | type |",
|
|
|
+ ].join("\n");
|
|
|
+
|
|
|
+ const counts = deriveAdvancedCountsFromSummaries(summaries, ["H01"]);
|
|
|
+ expect(counts.get("H01")).toBeUndefined();
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Issue 1: write_truth_file — old book allows story_bible.md writes
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe("Issue 1 — write_truth_file per-book shim detection", () => {
|
|
|
+ let bookDir: string;
|
|
|
+
|
|
|
+ beforeEach(async () => {
|
|
|
+ bookDir = await mkdtemp(join(tmpdir(), "inkos-wt-shim-"));
|
|
|
+ await mkdir(join(bookDir, "story"), { recursive: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ afterEach(async () => {
|
|
|
+ await rm(bookDir, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ it("old book: isNewLayoutBook returns false so story_bible.md is writable", async () => {
|
|
|
+ await writeFile(join(bookDir, "story", "story_bible.md"), "# Old", "utf-8");
|
|
|
+ expect(await isNewLayoutBook(bookDir)).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("new book: isNewLayoutBook returns true so story_bible.md is a shim", async () => {
|
|
|
+ await mkdir(join(bookDir, "story", "outline"), { recursive: true });
|
|
|
+ await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "# Frame", "utf-8");
|
|
|
+ await writeFile(join(bookDir, "story", "story_bible.md"), "# Shim", "utf-8");
|
|
|
+ expect(await isNewLayoutBook(bookDir)).toBe(true);
|
|
|
+ });
|
|
|
+});
|