Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions packages/core/src/__tests__/architect.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ArchitectAgent } from "../agents/architect.js";
import type { BookConfig } from "../models/book.js";
import type { LLMClient } from "../llm/provider.js";

const ZERO_USAGE = {
promptTokens: 0,
Expand Down Expand Up @@ -705,4 +706,151 @@ describe("ArchitectAgent", () => {
expect.objectContaining({ temperature: 0.7, maxTokens: 16384 }),
);
});

// ---- Phase 5 段落式架构稿专项 ----

// 测试 stub:chat 会被 vi.spyOn 拦截,client.defaults 运行时不会被读取。
// 故意不填 temperature / maxTokens 等数字——避免在测试里留下"推荐配置"的
// 错误示范(maxTokens 填错会误导后续抄到生产,触发 CLAUDE.md 禁止的
// maxTokens 回归)。只保留类型要求的身份字段。
const buildPhase5Agent = (): ArchitectAgent =>
new ArchitectAgent({
client: {
provider: "openai",
apiFormat: "chat",
stream: false,
} as unknown as LLMClient,
model: "test-model",
projectRoot: process.cwd(),
});

const phase5Book = (): BookConfig => ({
id: "phase5-book",
title: "测试书",
platform: "qidian",
genre: "xuanhuan",
status: "active",
targetChapters: 50,
chapterWordCount: 3000,
language: "zh",
createdAt: "2026-04-19T00:00:00.000Z",
updatedAt: "2026-04-19T00:00:00.000Z",
});

it("generateFoundation parses story_frame / volume_map / roles sections", async () => {
const agent = buildPhase5Agent();
const book = phase5Book();

vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
.mockResolvedValue({
content: [
"=== SECTION: story_frame ===",
"## 主题与基调",
"段落 1 主题段落。",
"",
"## 核心冲突",
"段落 2 冲突段落。",
"",
"=== SECTION: volume_map ===",
"## 段 1",
"卷一段落。",
"",
"=== SECTION: roles ===",
"---ROLE---",
"tier: major",
"name: 林辞",
"---CONTENT---",
"## 核心标签",
"冷静、执着",
"",
"---ROLE---",
"tier: minor",
"name: 配角A",
"---CONTENT---",
"次要角色描写",
"",
"=== SECTION: book_rules ===",
"---",
"version: \"1.0\"",
"protagonist:",
" name: 林辞",
"---",
"",
"=== SECTION: pending_hooks ===",
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
"|---|---|---|---|---|---|---|---|",
"| H001 | 1 | 主线 | open | 0 | 3 | 近期 | 初始线索 |",
].join("\n"),
usage: ZERO_USAGE,
});

const output = await agent.generateFoundation(book);

expect(output.storyFrame).toContain("主题与基调");
expect(output.volumeMap).toContain("段 1");
expect(output.roles).toBeDefined();
expect(output.roles!.length).toBe(2);
expect(output.roles![0]).toMatchObject({ tier: "major", name: "林辞" });
expect(output.roles![1]).toMatchObject({ tier: "minor", name: "配角A" });
});

it("writeFoundationFiles writes outline/ and roles/ when Phase 5 fields present", async () => {
const { mkdtemp, rm, access } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");

const agent = buildPhase5Agent();
const tmpDir = await mkdtemp(join(tmpdir(), "inkos-arch-test-"));
try {
await agent.writeFoundationFiles(tmpDir, {
storyBible: "legacy shim body",
volumeOutline: "legacy outline",
bookRules: "---\nversion: \"1.0\"\n---\n",
currentState: "",
pendingHooks: "| hook_id |",
storyFrame: "## 主题\n\n段落内容",
volumeMap: "## 卷一\n\n卷一段落",
roles: [
{ tier: "major", name: "林辞", content: "主角描写" },
{ tier: "minor", name: "配角A", content: "配角描写" },
],
}, false, "zh");

await expect(access(join(tmpDir, "story", "outline", "story_frame.md"))).resolves.not.toThrow();
await expect(access(join(tmpDir, "story", "outline", "volume_map.md"))).resolves.not.toThrow();
await expect(access(join(tmpDir, "story", "roles", "主要角色", "林辞.md"))).resolves.not.toThrow();
await expect(access(join(tmpDir, "story", "roles", "次要角色", "配角A.md"))).resolves.not.toThrow();
// Shim 文件也要在(向后兼容读取点用)
await expect(access(join(tmpDir, "story", "story_bible.md"))).resolves.not.toThrow();
await expect(access(join(tmpDir, "story", "character_matrix.md"))).resolves.not.toThrow();
await expect(access(join(tmpDir, "story", "book_rules.md"))).resolves.not.toThrow();
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});

it("writeFoundationFiles falls back to legacy layout when storyFrame is empty", async () => {
const { mkdtemp, rm, access, readFile } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");

const agent = buildPhase5Agent();
const tmpDir = await mkdtemp(join(tmpdir(), "inkos-arch-legacy-test-"));
try {
await agent.writeFoundationFiles(tmpDir, {
storyBible: "# Legacy Story Bible\n",
volumeOutline: "# Legacy Volume Outline\n",
bookRules: "# Legacy Book Rules\n",
currentState: "# Current State\n",
pendingHooks: "| hook_id |\n",
}, false, "zh");

const storyBible = await readFile(join(tmpDir, "story", "story_bible.md"), "utf-8");
expect(storyBible).toContain("Legacy Story Bible");
// outline/ 目录是创建的但里面没 story_frame.md
await expect(access(join(tmpDir, "story", "outline", "story_frame.md"))).rejects.toThrow();
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
});
26 changes: 26 additions & 0 deletions packages/core/src/__tests__/context-transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,30 @@ describe("createBookContextTransform", () => {
const result = await transform(original);
expect(result).toBe(original);
});

it("injects upgrade hint when book is legacy layout (no outline/story_frame.md)", async () => {
const transform = createBookContextTransform(bookId, projectRoot);
const result = await transform([
{ role: "user" as const, content: "写下一章", timestamp: Date.now() },
]);

const injected = result[0] as { role: string; content: string };
expect(injected.content).toContain("旧的条目式格式");
expect(injected.content).toContain("sub_agent(architect, { revise: true");
});

it("does NOT inject upgrade hint when book is Phase 5 layout", async () => {
const outlineDir = join(projectRoot, "books", bookId, "story", "outline");
await mkdir(outlineDir, { recursive: true });
await writeFile(join(outlineDir, "story_frame.md"), "## 主题\n段落式内容");

const transform = createBookContextTransform(bookId, projectRoot);
const result = await transform([
{ role: "user" as const, content: "写下一章", timestamp: Date.now() },
]);

const injected = result[0] as { role: string; content: string };
expect(injected.content).not.toContain("旧的条目式格式");
expect(injected.content).not.toContain("revise: true");
});
});
151 changes: 151 additions & 0 deletions packages/core/src/__tests__/outline-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
readStoryFrame,
readVolumeMap,
readRoleCards,
readCharacterContext,
readCurrentStateWithFallback,
isNewLayoutBook,
isCurrentStateSeedPlaceholder,
} from "../utils/outline-paths.js";

describe("outline-paths", () => {
let bookDir: string;

beforeEach(async () => {
bookDir = await mkdtemp(join(tmpdir(), "inkos-test-book-"));
await mkdir(join(bookDir, "story"), { recursive: true });
});

afterEach(async () => {
await rm(bookDir, { recursive: true, force: true });
});

// --- isNewLayoutBook ---

it("isNewLayoutBook returns false when outline/story_frame.md is missing", async () => {
expect(await isNewLayoutBook(bookDir)).toBe(false);
});

it("isNewLayoutBook returns true when outline/story_frame.md exists", 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);
});

// --- readStoryFrame ---

it("readStoryFrame prefers new path when present", async () => {
await mkdir(join(bookDir, "story", "outline"), { recursive: true });
await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "段落式内容", "utf-8");
await writeFile(join(bookDir, "story", "story_bible.md"), "legacy content", "utf-8");
expect(await readStoryFrame(bookDir)).toBe("段落式内容");
});

it("readStoryFrame falls back to legacy story_bible.md when new path missing", async () => {
await writeFile(join(bookDir, "story", "story_bible.md"), "legacy 条目式", "utf-8");
expect(await readStoryFrame(bookDir)).toBe("legacy 条目式");
});

it("readStoryFrame returns fallback placeholder when both paths missing", async () => {
expect(await readStoryFrame(bookDir, "(未创建)")).toBe("(未创建)");
});

// --- readVolumeMap ---

it("readVolumeMap falls back to legacy volume_outline.md", async () => {
await writeFile(join(bookDir, "story", "volume_outline.md"), "legacy 卷大纲", "utf-8");
expect(await readVolumeMap(bookDir)).toBe("legacy 卷大纲");
});

it("readVolumeMap prefers new path", async () => {
await mkdir(join(bookDir, "story", "outline"), { recursive: true });
await writeFile(join(bookDir, "story", "outline", "volume_map.md"), "段落式卷大纲", "utf-8");
await writeFile(join(bookDir, "story", "volume_outline.md"), "legacy", "utf-8");
expect(await readVolumeMap(bookDir)).toBe("段落式卷大纲");
});

// --- readRoleCards ---

it("readRoleCards returns empty when roles/ dir missing", async () => {
expect(await readRoleCards(bookDir)).toEqual([]);
});

it("readRoleCards reads major/minor role files", async () => {
const majorDir = join(bookDir, "story", "roles", "主要角色");
const minorDir = join(bookDir, "story", "roles", "次要角色");
await mkdir(majorDir, { recursive: true });
await mkdir(minorDir, { recursive: true });
await writeFile(join(majorDir, "林辞.md"), "主角内容", "utf-8");
await writeFile(join(minorDir, "配角A.md"), "配角内容", "utf-8");
const cards = await readRoleCards(bookDir);
expect(cards.length).toBe(2);
const major = cards.find((c) => c.name === "林辞");
const minor = cards.find((c) => c.name === "配角A");
expect(major).toMatchObject({ tier: "major", name: "林辞", content: "主角内容" });
expect(minor).toMatchObject({ tier: "minor", name: "配角A", content: "配角内容" });
});

// --- readCharacterContext ---

it("readCharacterContext renders role cards when present", async () => {
const majorDir = join(bookDir, "story", "roles", "主要角色");
await mkdir(majorDir, { recursive: true });
await writeFile(join(majorDir, "林辞.md"), "林辞的描写", "utf-8");
const result = await readCharacterContext(bookDir);
expect(result).toContain("林辞");
expect(result).toContain("主要角色");
});

it("readCharacterContext falls back to legacy character_matrix.md", async () => {
await writeFile(join(bookDir, "story", "character_matrix.md"), "legacy 角色矩阵", "utf-8");
const result = await readCharacterContext(bookDir);
expect(result).toBe("legacy 角色矩阵");
});

// --- readCurrentStateWithFallback ---

it("readCurrentStateWithFallback returns real content when not a seed", async () => {
await writeFile(
join(bookDir, "story", "current_state.md"),
"# 当前状态\n\n林辞已经到达京城,正在调查失踪案。",
"utf-8",
);
const result = await readCurrentStateWithFallback(bookDir);
expect(result).toContain("林辞已经到达京城");
});

it("readCurrentStateWithFallback derives initial state from roles when seed placeholder", async () => {
await writeFile(
join(bookDir, "story", "current_state.md"),
"建书时占位,运行时由 consolidator 每章追加。",
"utf-8",
);
const majorDir = join(bookDir, "story", "roles", "主要角色");
await mkdir(majorDir, { recursive: true });
await writeFile(
join(majorDir, "林辞.md"),
"## 当前现状\n\n刚从边关回到京城,尚未与旧友重逢。",
"utf-8",
);
const result = await readCurrentStateWithFallback(bookDir);
expect(result).toContain("林辞");
expect(result).toContain("初始状态");
});

// --- isCurrentStateSeedPlaceholder ---

it("isCurrentStateSeedPlaceholder returns true for empty or seed text", () => {
expect(isCurrentStateSeedPlaceholder("")).toBe(true);
expect(isCurrentStateSeedPlaceholder(" ")).toBe(true);
expect(isCurrentStateSeedPlaceholder("建书时占位,运行时追加")).toBe(true);
expect(isCurrentStateSeedPlaceholder("Seeded at book creation")).toBe(true);
});

it("isCurrentStateSeedPlaceholder returns false for real content", () => {
expect(isCurrentStateSeedPlaceholder("# 状态\n\n林辞已经出发,目标是西域。这是一段很长的内容。".repeat(10))).toBe(false);
});
});
Loading
Loading