Explorar el Código

test: add architect, chapter-splitter, and provider tests

Ma hace 7 meses
padre
commit
7e558f3afe

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

@@ -0,0 +1,213 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ArchitectAgent } from "../agents/architect.js";
+import type { BookConfig } from "../models/book.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+describe("ArchitectAgent", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("uses English prompts when generating foundation from imported English chapters", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "english-book",
+      title: "English Book",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "en",
+      createdAt: "2026-03-24T00:00:00.000Z",
+      updatedAt: "2026-03-24T00:00:00.000Z",
+    };
+
+    const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# Story Bible",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# Volume Outline",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "# Book Rules",
+          "",
+          "=== SECTION: current_state ===",
+          "# Current State",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "# Pending Hooks",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    await agent.generateFoundationFromImport(
+      book,
+      "Chapter 1: Prelude\n\nA cold wind crossed the harbor.",
+    );
+
+    const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
+    expect(messages[0]?.content).toContain("MUST be written in English");
+    expect(messages[1]?.content).toContain("Generate the complete foundation");
+    expect(messages[1]?.content).not.toContain("请从中反向推导");
+  });
+
+  it("does not embed Chinese section headings in imported English foundation prompts", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "english-book",
+      title: "English Book",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "en",
+      createdAt: "2026-03-24T00:00:00.000Z",
+      updatedAt: "2026-03-24T00:00:00.000Z",
+    };
+
+    const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# Story Bible",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# Volume Outline",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "# Book Rules",
+          "",
+          "=== SECTION: current_state ===",
+          "# Current State",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "# Pending Hooks",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    await agent.generateFoundationFromImport(
+      book,
+      "Chapter 1: Prelude\n\nA cold wind crossed the harbor.",
+    );
+
+    const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
+    expect(messages[0]?.content).toContain("## 01_Worldview");
+    expect(messages[0]?.content).toContain("## Narrative Perspective");
+    expect(messages[0]?.content).not.toContain("## 01_世界观");
+    expect(messages[0]?.content).not.toContain("## 叙事视角");
+  });
+
+  it("strips assistant-style trailing coda from the final pending hooks section", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "zh-book",
+      title: "雾港回灯",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 50,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-03-24T00:00:00.000Z",
+      updatedAt: "2026-03-24T00:00:00.000Z",
+    };
+
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H01 | 1 | 主线 | 未开启 | 无 | 10章 | 主线核心钩子 |",
+          "",
+          "如果你愿意,我下一步可以继续为这本《雾港回灯》输出:",
+          "1. 前10章逐章细纲",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    const result = await agent.generateFoundation(book);
+
+    expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 无 | 10章 | 主线核心钩子 |");
+    expect(result.pendingHooks).not.toContain("如果你愿意");
+    expect(result.pendingHooks).not.toContain("前10章逐章细纲");
+  });
+});

+ 156 - 0
packages/core/src/__tests__/chapter-splitter.test.ts

@@ -0,0 +1,156 @@
+import { describe, expect, it } from "vitest";
+import { splitChapters } from "../utils/chapter-splitter.js";
+
+describe("splitChapters", () => {
+  it("splits classical Chinese chapter headings with 第X回 by default", () => {
+    const input = [
+      "第一回:宴桃園豪傑三結義,斬黃巾英雄首立功",
+      "",
+      "滾滾長江東逝水,浪花淘盡英雄。",
+      "",
+      "第二回:張翼德怒鞭督郵,何國舅謀誅宦豎",
+      "",
+      "且說董卓專權,朝野震動。",
+    ].join("\n");
+
+    const chapters = splitChapters(input);
+
+    expect(chapters).toHaveLength(2);
+    expect(chapters[0]).toEqual({
+      title: "宴桃園豪傑三結義,斬黃巾英雄首立功",
+      content: "滾滾長江東逝水,浪花淘盡英雄。",
+    });
+    expect(chapters[1]).toEqual({
+      title: "張翼德怒鞭督郵,何國舅謀誅宦豎",
+      content: "且說董卓專權,朝野震動。",
+    });
+  });
+
+  it("uses a 第N回 fallback title when a classical Chinese heading has no title text", () => {
+    const input = [
+      "第一回",
+      "",
+      "天下大勢,分久必合,合久必分。",
+    ].join("\n");
+
+    const chapters = splitChapters(input);
+
+    expect(chapters).toHaveLength(1);
+    expect(chapters[0]?.title).toBe("第1回");
+  });
+
+  it("splits classical Chinese headings that use the round-zero numeral form", () => {
+    const input = [
+      "第九十九回:孔明秋雨退魏兵",
+      "",
+      "未知孔明怎生破魏,且看下文分解。",
+      "",
+      "第一○○回:漢兵劫寨破曹真,武侯鬥陣辱仲達",
+      "",
+      "卻說眾將聞孔明不追魏兵,俱入帳告曰。",
+    ].join("\n");
+
+    const chapters = splitChapters(input);
+
+    expect(chapters).toHaveLength(2);
+    expect(chapters[0]).toEqual({
+      title: "孔明秋雨退魏兵",
+      content: "未知孔明怎生破魏,且看下文分解。",
+    });
+    expect(chapters[1]).toEqual({
+      title: "漢兵劫寨破曹真,武侯鬥陣辱仲達",
+      content: "卻說眾將聞孔明不追魏兵,俱入帳告曰。",
+    });
+  });
+
+  it("splits English chapter headings with the default pattern", () => {
+    const input = [
+      "Chapter 1: Prelude",
+      "",
+      "The harbor bells rang before dawn.",
+      "",
+      "Chapter 2: Into the Fog",
+      "",
+      "Mara followed the last lantern into the mist.",
+    ].join("\n");
+
+    const chapters = splitChapters(input);
+
+    expect(chapters).toHaveLength(2);
+    expect(chapters[0]).toEqual({
+      title: "Prelude",
+      content: "The harbor bells rang before dawn.",
+    });
+    expect(chapters[1]).toEqual({
+      title: "Into the Fog",
+      content: "Mara followed the last lantern into the mist.",
+    });
+  });
+
+  it("uses an English fallback title when the chapter heading has no title text", () => {
+    const input = [
+      "Chapter 1",
+      "",
+      "The harbor bells rang before dawn.",
+    ].join("\n");
+
+    const chapters = splitChapters(input);
+
+    expect(chapters).toHaveLength(1);
+    expect(chapters[0]?.title).toBe("Chapter 1");
+  });
+
+  it("splits Roman numeral English chapter headings with the default pattern", () => {
+    const input = [
+      "CHAPTER I.",
+      "",
+      "The harbor bells rang before dawn.",
+      "",
+      "CHAPTER II.",
+      "",
+      "Mara followed the last lantern into the mist.",
+    ].join("\n");
+
+    const chapters = splitChapters(input);
+
+    expect(chapters).toHaveLength(2);
+    expect(chapters[0]).toEqual({
+      title: "Chapter 1",
+      content: "The harbor bells rang before dawn.",
+    });
+    expect(chapters[1]).toEqual({
+      title: "Chapter 2",
+      content: "Mara followed the last lantern into the mist.",
+    });
+  });
+
+  it("keeps English fallback titles when a custom regex matches Roman numeral headings", () => {
+    const input = [
+      "CHAPTER I.",
+      "",
+      "The harbor bells rang before dawn.",
+    ].join("\n");
+
+    const chapters = splitChapters(input, "^CHAPTER\\s+[IVXLCDM]+\\.$");
+
+    expect(chapters).toHaveLength(1);
+    expect(chapters[0]?.title).toBe("Chapter 1");
+  });
+
+  it("strips a Project Gutenberg trailer from the final chapter content", () => {
+    const input = [
+      "Chapter 1: Finale",
+      "",
+      "The harbor bells rang once and went silent.",
+      "",
+      "Project Gutenberg™ depends upon and cannot survive without widespread",
+      "public support and donations to carry out its mission.",
+    ].join("\n");
+
+    const chapters = splitChapters(input);
+
+    expect(chapters).toHaveLength(1);
+    expect(chapters[0]?.content).toBe("The harbor bells rang once and went silent.");
+    expect(chapters[0]?.content).not.toContain("Project Gutenberg");
+  });
+});

+ 57 - 0
packages/core/src/__tests__/provider.test.ts

@@ -0,0 +1,57 @@
+import { describe, expect, it, vi } from "vitest";
+import type OpenAI from "openai";
+import { chatCompletion, type LLMClient } from "../llm/provider.js";
+
+const ZERO_USAGE = {
+  prompt_tokens: 11,
+  completion_tokens: 7,
+  total_tokens: 18,
+} as const;
+
+describe("chatCompletion stream fallback", () => {
+  it("falls back to sync chat completion when streamed chat returns no chunks", async () => {
+    const create = vi.fn()
+      .mockResolvedValueOnce({
+        async *[Symbol.asyncIterator](): AsyncIterableIterator<unknown> {
+          return;
+        },
+      })
+      .mockResolvedValueOnce({
+        choices: [{ message: { content: "fallback content" } }],
+        usage: ZERO_USAGE,
+      });
+
+    const client: LLMClient = {
+      provider: "openai",
+      apiFormat: "chat",
+      stream: true,
+      _openai: {
+        chat: {
+          completions: {
+            create,
+          },
+        },
+      } as unknown as OpenAI,
+      defaults: {
+        temperature: 0.7,
+        maxTokens: 512,
+        thinkingBudget: 0, maxTokensCap: null,
+        extra: {},
+      },
+    };
+
+    const result = await chatCompletion(client, "test-model", [
+      { role: "user", content: "ping" },
+    ]);
+
+    expect(result.content).toBe("fallback content");
+    expect(result.usage).toEqual({
+      promptTokens: 11,
+      completionTokens: 7,
+      totalTokens: 18,
+    });
+    expect(create).toHaveBeenCalledTimes(2);
+    expect(create.mock.calls[0]?.[0]).toMatchObject({ stream: true });
+    expect(create.mock.calls[1]?.[0]).toMatchObject({ stream: false });
+  });
+});