Просмотр исходного кода

test: add unit tests for models, state-manager, and writer-parser

102 tests covering:
- Zod schema validation for all model types
- State manager file operations, locking, and snapshots
- Writer output parsing (chapter content, state updates, hooks)
majx_mac 8 месяцев назад
Родитель
Сommit
7eb3ec5e82

+ 440 - 0
packages/core/src/__tests__/models.test.ts

@@ -0,0 +1,440 @@
+import { describe, it, expect } from "vitest";
+import {
+  BookConfigSchema,
+  PlatformSchema,
+  GenreSchema,
+  BookStatusSchema,
+} from "../models/book.js";
+import { ChapterMetaSchema, ChapterStatusSchema } from "../models/chapter.js";
+import {
+  ProjectConfigSchema,
+  LLMConfigSchema,
+  NotifyChannelSchema,
+} from "../models/project.js";
+
+// ---------------------------------------------------------------------------
+// BookConfig
+// ---------------------------------------------------------------------------
+
+describe("BookConfigSchema", () => {
+  const validBook = {
+    id: "test-book-1",
+    title: "Test Novel",
+    platform: "tomato",
+    genre: "xuanhuan",
+    status: "active",
+    targetChapters: 200,
+    chapterWordCount: 3000,
+    createdAt: "2026-01-01T00:00:00Z",
+    updatedAt: "2026-01-01T00:00:00Z",
+  };
+
+  it("accepts a valid BookConfig", () => {
+    const result = BookConfigSchema.parse(validBook);
+    expect(result.id).toBe("test-book-1");
+    expect(result.title).toBe("Test Novel");
+    expect(result.platform).toBe("tomato");
+  });
+
+  it("applies default targetChapters and chapterWordCount", () => {
+    const minimal = {
+      id: "b1",
+      title: "B1",
+      platform: "qidian",
+      genre: "xianxia",
+      status: "incubating",
+      createdAt: "2026-01-01T00:00:00Z",
+      updatedAt: "2026-01-01T00:00:00Z",
+    };
+    const result = BookConfigSchema.parse(minimal);
+    expect(result.targetChapters).toBe(200);
+    expect(result.chapterWordCount).toBe(3000);
+  });
+
+  it("rejects empty id", () => {
+    expect(() =>
+      BookConfigSchema.parse({ ...validBook, id: "" }),
+    ).toThrow();
+  });
+
+  it("rejects empty title", () => {
+    expect(() =>
+      BookConfigSchema.parse({ ...validBook, title: "" }),
+    ).toThrow();
+  });
+
+  it("rejects invalid platform", () => {
+    expect(() =>
+      BookConfigSchema.parse({ ...validBook, platform: "kindle" }),
+    ).toThrow();
+  });
+
+  it("rejects invalid genre", () => {
+    expect(() =>
+      BookConfigSchema.parse({ ...validBook, genre: "romance" }),
+    ).toThrow();
+  });
+
+  it("rejects invalid status", () => {
+    expect(() =>
+      BookConfigSchema.parse({ ...validBook, status: "archived" }),
+    ).toThrow();
+  });
+
+  it("rejects chapterWordCount below 1000", () => {
+    expect(() =>
+      BookConfigSchema.parse({ ...validBook, chapterWordCount: 500 }),
+    ).toThrow();
+  });
+
+  it("rejects targetChapters below 1", () => {
+    expect(() =>
+      BookConfigSchema.parse({ ...validBook, targetChapters: 0 }),
+    ).toThrow();
+  });
+
+  it("rejects non-integer targetChapters", () => {
+    expect(() =>
+      BookConfigSchema.parse({ ...validBook, targetChapters: 10.5 }),
+    ).toThrow();
+  });
+
+  it("rejects invalid datetime strings", () => {
+    expect(() =>
+      BookConfigSchema.parse({ ...validBook, createdAt: "not-a-date" }),
+    ).toThrow();
+  });
+});
+
+describe("PlatformSchema", () => {
+  it.each(["tomato", "feilu", "qidian", "other"] as const)(
+    "accepts '%s'",
+    (value) => {
+      expect(PlatformSchema.parse(value)).toBe(value);
+    },
+  );
+
+  it("rejects unknown platform", () => {
+    expect(() => PlatformSchema.parse("amazon")).toThrow();
+  });
+});
+
+describe("GenreSchema", () => {
+  const validGenres = [
+    "xuanhuan",
+    "xianxia",
+    "urban",
+    "game",
+    "fanfic",
+    "horror",
+    "short",
+    "other",
+  ] as const;
+
+  it.each(validGenres)("accepts '%s'", (value) => {
+    expect(GenreSchema.parse(value)).toBe(value);
+  });
+
+  it("rejects unknown genre", () => {
+    expect(() => GenreSchema.parse("scifi")).toThrow();
+  });
+});
+
+describe("BookStatusSchema", () => {
+  const validStatuses = [
+    "incubating",
+    "outlining",
+    "active",
+    "paused",
+    "completed",
+    "dropped",
+  ] as const;
+
+  it.each(validStatuses)("accepts '%s'", (value) => {
+    expect(BookStatusSchema.parse(value)).toBe(value);
+  });
+
+  it("rejects unknown status", () => {
+    expect(() => BookStatusSchema.parse("archived")).toThrow();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// ChapterMeta
+// ---------------------------------------------------------------------------
+
+describe("ChapterMetaSchema", () => {
+  const validChapter = {
+    number: 1,
+    title: "Chapter One",
+    status: "drafted",
+    wordCount: 3000,
+    createdAt: "2026-01-01T00:00:00Z",
+    updatedAt: "2026-01-01T00:00:00Z",
+    auditIssues: [],
+  };
+
+  it("accepts a valid ChapterMeta", () => {
+    const result = ChapterMetaSchema.parse(validChapter);
+    expect(result.number).toBe(1);
+    expect(result.title).toBe("Chapter One");
+    expect(result.status).toBe("drafted");
+  });
+
+  it("applies default wordCount of 0", () => {
+    const minimal = {
+      number: 5,
+      title: "Ch5",
+      status: "card-generated",
+      createdAt: "2026-01-01T00:00:00Z",
+      updatedAt: "2026-01-01T00:00:00Z",
+    };
+    const result = ChapterMetaSchema.parse(minimal);
+    expect(result.wordCount).toBe(0);
+  });
+
+  it("applies default empty auditIssues", () => {
+    const minimal = {
+      number: 1,
+      title: "Ch1",
+      status: "drafted",
+      createdAt: "2026-01-01T00:00:00Z",
+      updatedAt: "2026-01-01T00:00:00Z",
+    };
+    const result = ChapterMetaSchema.parse(minimal);
+    expect(result.auditIssues).toEqual([]);
+  });
+
+  it("accepts optional reviewNote", () => {
+    const withNote = { ...validChapter, reviewNote: "Looks good" };
+    const result = ChapterMetaSchema.parse(withNote);
+    expect(result.reviewNote).toBe("Looks good");
+  });
+
+  it("omits reviewNote when not provided", () => {
+    const result = ChapterMetaSchema.parse(validChapter);
+    expect(result.reviewNote).toBeUndefined();
+  });
+
+  it("rejects chapter number < 1", () => {
+    expect(() =>
+      ChapterMetaSchema.parse({ ...validChapter, number: 0 }),
+    ).toThrow();
+  });
+
+  it("rejects negative chapter number", () => {
+    expect(() =>
+      ChapterMetaSchema.parse({ ...validChapter, number: -1 }),
+    ).toThrow();
+  });
+
+  it("rejects invalid status", () => {
+    expect(() =>
+      ChapterMetaSchema.parse({ ...validChapter, status: "writing" }),
+    ).toThrow();
+  });
+
+  it("rejects non-integer chapter number", () => {
+    expect(() =>
+      ChapterMetaSchema.parse({ ...validChapter, number: 1.5 }),
+    ).toThrow();
+  });
+});
+
+describe("ChapterStatusSchema", () => {
+  const allStatuses = [
+    "card-generated",
+    "drafting",
+    "drafted",
+    "auditing",
+    "audit-passed",
+    "audit-failed",
+    "revising",
+    "ready-for-review",
+    "approved",
+    "rejected",
+    "published",
+  ] as const;
+
+  it.each(allStatuses)("accepts '%s'", (value) => {
+    expect(ChapterStatusSchema.parse(value)).toBe(value);
+  });
+
+  it("has exactly 11 valid statuses", () => {
+    expect(ChapterStatusSchema.options).toHaveLength(11);
+  });
+
+  it("rejects unknown status", () => {
+    expect(() => ChapterStatusSchema.parse("editing")).toThrow();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// ProjectConfig
+// ---------------------------------------------------------------------------
+
+describe("ProjectConfigSchema", () => {
+  const validProject = {
+    name: "my-project",
+    version: "0.1.0" as const,
+    llm: {
+      provider: "anthropic" as const,
+      baseUrl: "https://api.example.com/v1",
+      apiKey: "sk-test-key",
+      model: "claude-sonnet-4-5-20250514",
+    },
+    notify: [],
+  };
+
+  it("accepts a valid ProjectConfig", () => {
+    const result = ProjectConfigSchema.parse(validProject);
+    expect(result.name).toBe("my-project");
+    expect(result.version).toBe("0.1.0");
+  });
+
+  it("applies default daemon config", () => {
+    const result = ProjectConfigSchema.parse(validProject);
+    expect(result.daemon.maxConcurrentBooks).toBe(3);
+    expect(result.daemon.schedule.radarCron).toBe("0 9 * * *");
+    expect(result.daemon.schedule.writeCron).toBe("0 14 * * *");
+    expect(result.daemon.schedule.auditCron).toBe("0 17 * * *");
+  });
+
+  it("applies default empty notify array", () => {
+    const withoutNotify = {
+      name: "p1",
+      version: "0.1.0" as const,
+      llm: validProject.llm,
+    };
+    const result = ProjectConfigSchema.parse(withoutNotify);
+    expect(result.notify).toEqual([]);
+  });
+
+  it("rejects wrong version", () => {
+    expect(() =>
+      ProjectConfigSchema.parse({ ...validProject, version: "1.0.0" }),
+    ).toThrow();
+  });
+
+  it("rejects empty project name", () => {
+    expect(() =>
+      ProjectConfigSchema.parse({ ...validProject, name: "" }),
+    ).toThrow();
+  });
+
+  it("rejects missing LLM config", () => {
+    expect(() =>
+      ProjectConfigSchema.parse({ name: "p", version: "0.1.0" }),
+    ).toThrow();
+  });
+});
+
+describe("LLMConfigSchema", () => {
+  it("accepts valid LLM config", () => {
+    const result = LLMConfigSchema.parse({
+      provider: "openai",
+      baseUrl: "https://api.openai.com/v1",
+      apiKey: "sk-xxx",
+      model: "gpt-4o",
+    });
+    expect(result.provider).toBe("openai");
+  });
+
+  it("rejects invalid provider", () => {
+    expect(() =>
+      LLMConfigSchema.parse({
+        provider: "mistral",
+        baseUrl: "https://api.example.com",
+        apiKey: "key",
+        model: "m",
+      }),
+    ).toThrow();
+  });
+
+  it("rejects invalid URL", () => {
+    expect(() =>
+      LLMConfigSchema.parse({
+        provider: "custom",
+        baseUrl: "not-a-url",
+        apiKey: "key",
+        model: "m",
+      }),
+    ).toThrow();
+  });
+
+  it("rejects empty apiKey", () => {
+    expect(() =>
+      LLMConfigSchema.parse({
+        provider: "anthropic",
+        baseUrl: "https://api.example.com",
+        apiKey: "",
+        model: "m",
+      }),
+    ).toThrow();
+  });
+
+  it("rejects empty model", () => {
+    expect(() =>
+      LLMConfigSchema.parse({
+        provider: "anthropic",
+        baseUrl: "https://api.example.com",
+        apiKey: "key",
+        model: "",
+      }),
+    ).toThrow();
+  });
+});
+
+describe("NotifyChannelSchema", () => {
+  it("accepts telegram channel", () => {
+    const result = NotifyChannelSchema.parse({
+      type: "telegram",
+      botToken: "123:ABC",
+      chatId: "-100123",
+    });
+    expect(result.type).toBe("telegram");
+  });
+
+  it("accepts feishu channel", () => {
+    const result = NotifyChannelSchema.parse({
+      type: "feishu",
+      webhookUrl: "https://open.feishu.cn/webhook/xxx",
+    });
+    expect(result.type).toBe("feishu");
+  });
+
+  it("accepts wechat-work channel", () => {
+    const result = NotifyChannelSchema.parse({
+      type: "wechat-work",
+      webhookUrl: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx",
+    });
+    expect(result.type).toBe("wechat-work");
+  });
+
+  it("rejects telegram with missing botToken", () => {
+    expect(() =>
+      NotifyChannelSchema.parse({
+        type: "telegram",
+        chatId: "-100",
+      }),
+    ).toThrow();
+  });
+
+  it("rejects feishu with invalid URL", () => {
+    expect(() =>
+      NotifyChannelSchema.parse({
+        type: "feishu",
+        webhookUrl: "not-a-url",
+      }),
+    ).toThrow();
+  });
+
+  it("rejects unknown channel type", () => {
+    expect(() =>
+      NotifyChannelSchema.parse({
+        type: "slack",
+        webhookUrl: "https://hooks.slack.com/xxx",
+      }),
+    ).toThrow();
+  });
+});

+ 366 - 0
packages/core/src/__tests__/state-manager.test.ts

@@ -0,0 +1,366 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtemp, rm, writeFile, readFile, mkdir, stat } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { StateManager } from "../state/manager.js";
+import type { BookConfig } from "../models/book.js";
+import type { ChapterMeta } from "../models/chapter.js";
+
+describe("StateManager", () => {
+  let tempDir: string;
+  let manager: StateManager;
+
+  beforeEach(async () => {
+    tempDir = await mkdtemp(join(tmpdir(), "inkos-test-"));
+    manager = new StateManager(tempDir);
+  });
+
+  afterEach(async () => {
+    await rm(tempDir, { recursive: true, force: true });
+  });
+
+  // -------------------------------------------------------------------------
+  // BookConfig persistence
+  // -------------------------------------------------------------------------
+
+  describe("saveBookConfig / loadBookConfig", () => {
+    const bookConfig: BookConfig = {
+      id: "test-book",
+      title: "Test Novel",
+      platform: "tomato",
+      genre: "xuanhuan",
+      status: "active",
+      targetChapters: 200,
+      chapterWordCount: 3000,
+      createdAt: "2026-01-01T00:00:00Z",
+      updatedAt: "2026-01-01T00:00:00Z",
+    };
+
+    it("round-trips a BookConfig through save and load", async () => {
+      await manager.saveBookConfig("test-book", bookConfig);
+      const loaded = await manager.loadBookConfig("test-book");
+      expect(loaded).toEqual(bookConfig);
+    });
+
+    it("creates the book directory on save", async () => {
+      await manager.saveBookConfig("new-book", {
+        ...bookConfig,
+        id: "new-book",
+      });
+      const dirStat = await stat(manager.bookDir("new-book"));
+      expect(dirStat.isDirectory()).toBe(true);
+    });
+
+    it("throws when loading a non-existent book", async () => {
+      await expect(manager.loadBookConfig("nope")).rejects.toThrow();
+    });
+  });
+
+  // -------------------------------------------------------------------------
+  // ChapterIndex persistence
+  // -------------------------------------------------------------------------
+
+  describe("saveChapterIndex / loadChapterIndex", () => {
+    const chapters: ReadonlyArray<ChapterMeta> = [
+      {
+        number: 1,
+        title: "Ch1",
+        status: "drafted",
+        wordCount: 3000,
+        createdAt: "2026-01-01T00:00:00Z",
+        updatedAt: "2026-01-01T00:00:00Z",
+        auditIssues: [],
+      },
+      {
+        number: 2,
+        title: "Ch2",
+        status: "drafting",
+        wordCount: 0,
+        createdAt: "2026-01-02T00:00:00Z",
+        updatedAt: "2026-01-02T00:00:00Z",
+        auditIssues: ["pacing issue"],
+      },
+    ];
+
+    it("round-trips chapter index through save and load", async () => {
+      await manager.saveChapterIndex("book-a", chapters);
+      const loaded = await manager.loadChapterIndex("book-a");
+      expect(loaded).toEqual(chapters);
+    });
+
+    it("returns empty array when no index exists", async () => {
+      const loaded = await manager.loadChapterIndex("nonexistent");
+      expect(loaded).toEqual([]);
+    });
+
+    it("creates the chapters directory on save", async () => {
+      await manager.saveChapterIndex("book-b", []);
+      const dirStat = await stat(
+        join(manager.bookDir("book-b"), "chapters"),
+      );
+      expect(dirStat.isDirectory()).toBe(true);
+    });
+  });
+
+  // -------------------------------------------------------------------------
+  // getNextChapterNumber
+  // -------------------------------------------------------------------------
+
+  describe("getNextChapterNumber", () => {
+    it("returns 1 for an empty book (no chapters)", async () => {
+      const next = await manager.getNextChapterNumber("empty-book");
+      expect(next).toBe(1);
+    });
+
+    it("returns max+1 when chapters exist", async () => {
+      const chapters: ReadonlyArray<ChapterMeta> = [
+        {
+          number: 1,
+          title: "Ch1",
+          status: "published",
+          wordCount: 3000,
+          createdAt: "2026-01-01T00:00:00Z",
+          updatedAt: "2026-01-01T00:00:00Z",
+          auditIssues: [],
+        },
+        {
+          number: 5,
+          title: "Ch5",
+          status: "drafted",
+          wordCount: 2800,
+          createdAt: "2026-01-05T00:00:00Z",
+          updatedAt: "2026-01-05T00:00:00Z",
+          auditIssues: [],
+        },
+        {
+          number: 3,
+          title: "Ch3",
+          status: "approved",
+          wordCount: 3100,
+          createdAt: "2026-01-03T00:00:00Z",
+          updatedAt: "2026-01-03T00:00:00Z",
+          auditIssues: [],
+        },
+      ];
+      await manager.saveChapterIndex("book-x", chapters);
+      const next = await manager.getNextChapterNumber("book-x");
+      expect(next).toBe(6);
+    });
+
+    it("returns 2 when only chapter 1 exists", async () => {
+      const chapters: ReadonlyArray<ChapterMeta> = [
+        {
+          number: 1,
+          title: "Ch1",
+          status: "drafted",
+          wordCount: 3000,
+          createdAt: "2026-01-01T00:00:00Z",
+          updatedAt: "2026-01-01T00:00:00Z",
+          auditIssues: [],
+        },
+      ];
+      await manager.saveChapterIndex("book-y", chapters);
+      const next = await manager.getNextChapterNumber("book-y");
+      expect(next).toBe(2);
+    });
+  });
+
+  // -------------------------------------------------------------------------
+  // listBooks
+  // -------------------------------------------------------------------------
+
+  describe("listBooks", () => {
+    it("returns empty array when no books directory exists", async () => {
+      const books = await manager.listBooks();
+      expect(books).toEqual([]);
+    });
+
+    it("returns book IDs for directories with book.json", async () => {
+      const bookConfig: BookConfig = {
+        id: "alpha",
+        title: "Alpha",
+        platform: "tomato",
+        genre: "urban",
+        status: "active",
+        targetChapters: 100,
+        chapterWordCount: 3000,
+        createdAt: "2026-01-01T00:00:00Z",
+        updatedAt: "2026-01-01T00:00:00Z",
+      };
+      await manager.saveBookConfig("alpha", bookConfig);
+      await manager.saveBookConfig("beta", { ...bookConfig, id: "beta", title: "Beta" });
+
+      // Create a decoy directory without book.json
+      await mkdir(join(manager.booksDir, "not-a-book"), { recursive: true });
+
+      const books = await manager.listBooks();
+      expect(books).toContain("alpha");
+      expect(books).toContain("beta");
+      expect(books).not.toContain("not-a-book");
+      expect(books).toHaveLength(2);
+    });
+  });
+
+  // -------------------------------------------------------------------------
+  // snapshotState / restoreState
+  // -------------------------------------------------------------------------
+
+  describe("snapshotState / restoreState", () => {
+    const bookId = "snap-book";
+
+    beforeEach(async () => {
+      const storyDir = join(manager.bookDir(bookId), "story");
+      await mkdir(storyDir, { recursive: true });
+      await writeFile(
+        join(storyDir, "current_state.md"),
+        "# State at ch1",
+        "utf-8",
+      );
+      await writeFile(
+        join(storyDir, "particle_ledger.md"),
+        "# Ledger at ch1",
+        "utf-8",
+      );
+      await writeFile(
+        join(storyDir, "pending_hooks.md"),
+        "# Hooks at ch1",
+        "utf-8",
+      );
+    });
+
+    it("snapshots current state files to a numbered directory", async () => {
+      await manager.snapshotState(bookId, 1);
+
+      const snapshotDir = join(
+        manager.bookDir(bookId),
+        "story",
+        "snapshots",
+        "1",
+      );
+      const state = await readFile(
+        join(snapshotDir, "current_state.md"),
+        "utf-8",
+      );
+      expect(state).toBe("# State at ch1");
+
+      const ledger = await readFile(
+        join(snapshotDir, "particle_ledger.md"),
+        "utf-8",
+      );
+      expect(ledger).toBe("# Ledger at ch1");
+
+      const hooks = await readFile(
+        join(snapshotDir, "pending_hooks.md"),
+        "utf-8",
+      );
+      expect(hooks).toBe("# Hooks at ch1");
+    });
+
+    it("restores state from a previous snapshot", async () => {
+      await manager.snapshotState(bookId, 1);
+
+      // Modify the current state files
+      const storyDir = join(manager.bookDir(bookId), "story");
+      await writeFile(
+        join(storyDir, "current_state.md"),
+        "# State at ch2 (modified)",
+        "utf-8",
+      );
+      await writeFile(
+        join(storyDir, "particle_ledger.md"),
+        "# Ledger at ch2 (modified)",
+        "utf-8",
+      );
+      await writeFile(
+        join(storyDir, "pending_hooks.md"),
+        "# Hooks at ch2 (modified)",
+        "utf-8",
+      );
+
+      const restored = await manager.restoreState(bookId, 1);
+      expect(restored).toBe(true);
+
+      // Verify restored content
+      const state = await readFile(
+        join(storyDir, "current_state.md"),
+        "utf-8",
+      );
+      expect(state).toBe("# State at ch1");
+
+      const ledger = await readFile(
+        join(storyDir, "particle_ledger.md"),
+        "utf-8",
+      );
+      expect(ledger).toBe("# Ledger at ch1");
+    });
+
+    it("returns false when restoring from non-existent snapshot", async () => {
+      const restored = await manager.restoreState(bookId, 999);
+      expect(restored).toBe(false);
+    });
+  });
+
+  // -------------------------------------------------------------------------
+  // acquireBookLock
+  // -------------------------------------------------------------------------
+
+  describe("acquireBookLock", () => {
+    it("acquires a lock and returns a release function", async () => {
+      // Ensure book directory exists
+      await mkdir(manager.bookDir("lock-book"), { recursive: true });
+
+      const release = await manager.acquireBookLock("lock-book");
+      expect(typeof release).toBe("function");
+
+      // Lock file should exist
+      const lockPath = join(manager.bookDir("lock-book"), ".write.lock");
+      const lockStat = await stat(lockPath);
+      expect(lockStat.isFile()).toBe(true);
+
+      // Release the lock
+      await release();
+
+      // Lock file should be gone
+      await expect(stat(lockPath)).rejects.toThrow();
+    });
+
+    it("throws when lock is already held", async () => {
+      await mkdir(manager.bookDir("lock-book-2"), { recursive: true });
+
+      const release = await manager.acquireBookLock("lock-book-2");
+
+      await expect(
+        manager.acquireBookLock("lock-book-2"),
+      ).rejects.toThrow(/is locked/);
+
+      await release();
+    });
+
+    it("allows re-acquiring lock after release", async () => {
+      await mkdir(manager.bookDir("lock-book-3"), { recursive: true });
+
+      const release1 = await manager.acquireBookLock("lock-book-3");
+      await release1();
+
+      const release2 = await manager.acquireBookLock("lock-book-3");
+      expect(typeof release2).toBe("function");
+      await release2();
+    });
+  });
+
+  // -------------------------------------------------------------------------
+  // Path helpers
+  // -------------------------------------------------------------------------
+
+  describe("path helpers", () => {
+    it("booksDir points to <projectRoot>/books", () => {
+      expect(manager.booksDir).toBe(join(tempDir, "books"));
+    });
+
+    it("bookDir returns <booksDir>/<bookId>", () => {
+      expect(manager.bookDir("my-book")).toBe(
+        join(tempDir, "books", "my-book"),
+      );
+    });
+  });
+});

+ 213 - 0
packages/core/src/__tests__/writer-parser.test.ts

@@ -0,0 +1,213 @@
+import { describe, it, expect } from "vitest";
+import { WriterAgent } from "../agents/writer.js";
+import type { WriteChapterOutput } from "../agents/writer.js";
+
+/**
+ * WriterAgent.parseOutput is private, so we access it via prototype to test
+ * the extraction logic directly without needing to mock the full LLM pipeline.
+ */
+function callParseOutput(
+  chapterNumber: number,
+  content: string,
+): WriteChapterOutput {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const proto = WriterAgent.prototype as any;
+  return proto.parseOutput.call(null, chapterNumber, content);
+}
+
+// ---------------------------------------------------------------------------
+// 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 as the length of chapter content", () => {
+    const result = callParseOutput(1, fullOutput);
+    const expectedContent =
+      "陈风站在悬崖边,俯视着脚下的万丈深渊。\n一股强烈的吸力从深渊中传来,仿佛有什么东西在召唤他。";
+    expect(result.wordCount).toBe(expectedContent.length);
+  });
+
+  // -------------------------------------------------------------------------
+  // 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);
+  });
+});

+ 7 - 0
packages/core/vitest.config.ts

@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+  test: {
+    include: ["src/__tests__/**/*.test.ts"],
+  },
+});