|
@@ -0,0 +1,393 @@
|
|
|
|
|
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
|
|
+import { tmpdir } from "node:os";
|
|
|
|
|
+import { join } from "node:path";
|
|
|
|
|
+import { ArchitectAgent } from "../agents/architect.js";
|
|
|
|
|
+import { readBookRules as readStructuredBookRules } from "../agents/rules-reader.js";
|
|
|
|
|
+import { readBookRules as readPlannerBookRules } from "../agents/planner-context.js";
|
|
|
|
|
+import { readVolumeMap } from "../utils/outline-paths.js";
|
|
|
|
|
+import { StateManager } from "../state/manager.js";
|
|
|
|
|
+import type { BookConfig } from "../models/book.js";
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// Phase 5 cleanup (4) — verifies the post-cleanup invariants:
|
|
|
|
|
+// (1) volume_outline.md mirror is NOT produced by the architect
|
|
|
|
|
+// — readVolumeMap() still resolves through outline/volume_map.md
|
|
|
|
|
+// (2) particle_ledger.md / subplot_board.md are NOT seeded by the architect
|
|
|
|
|
+// (3) book_rules.md's YAML frontmatter now lives on story_frame.md; the
|
|
|
|
|
+// legacy file becomes a compat shim, and readBookRules() prefers the
|
|
|
|
|
+// new location
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+const ZERO_USAGE = {
|
|
|
|
|
+ promptTokens: 0,
|
|
|
|
|
+ completionTokens: 0,
|
|
|
|
|
+ totalTokens: 0,
|
|
|
|
|
+} as const;
|
|
|
|
|
+
|
|
|
|
|
+const SAMPLE_RESPONSE = [
|
|
|
|
|
+ "=== SECTION: story_frame ===",
|
|
|
|
|
+ "## 主题与基调",
|
|
|
|
|
+ "一段测试用的主题散文。",
|
|
|
|
|
+ "## 主角弧线",
|
|
|
|
|
+ "主角从 A 走向 B。",
|
|
|
|
|
+ "## 核心冲突与对手",
|
|
|
|
|
+ "对手是 X。",
|
|
|
|
|
+ "## 世界观底色",
|
|
|
|
|
+ "湿冷的小镇。",
|
|
|
|
|
+ "## 终局方向",
|
|
|
|
|
+ "最后一个镜头。",
|
|
|
|
|
+ "",
|
|
|
|
|
+ "=== SECTION: volume_map ===",
|
|
|
|
|
+ "## 各卷主题",
|
|
|
|
|
+ "卷一压卷二放。",
|
|
|
|
|
+ "## 关键节点章",
|
|
|
|
|
+ "第 10 章转折。",
|
|
|
|
|
+ "## 卷间钩子与回收",
|
|
|
|
|
+ "钩子 H01。",
|
|
|
|
|
+ "## 角色阶段性目标",
|
|
|
|
|
+ "卷一:定调。",
|
|
|
|
|
+ "## 卷尾必须发生的改变",
|
|
|
|
|
+ "身份暴露。",
|
|
|
|
|
+ "## 节奏意图",
|
|
|
|
|
+ "前 10 章压。",
|
|
|
|
|
+ "",
|
|
|
|
|
+ "=== SECTION: rhythm_principles ===",
|
|
|
|
|
+ "## 原则 1",
|
|
|
|
|
+ "每 8 章一个高潮。",
|
|
|
|
|
+ "",
|
|
|
|
|
+ "=== SECTION: roles ===",
|
|
|
|
|
+ "---ROLE---",
|
|
|
|
|
+ "tier: major",
|
|
|
|
|
+ "name: 主角甲",
|
|
|
|
|
+ "---CONTENT---",
|
|
|
|
|
+ "## 核心标签",
|
|
|
|
|
+ "沉默、执拗",
|
|
|
|
|
+ "## 反差细节",
|
|
|
|
|
+ "会给流浪狗留罐头",
|
|
|
|
|
+ "## 人物小传",
|
|
|
|
|
+ "测试用小传。",
|
|
|
|
|
+ "## 当前现状",
|
|
|
|
|
+ "旧书店账房。",
|
|
|
|
|
+ "## 关系网络",
|
|
|
|
|
+ "无。",
|
|
|
|
|
+ "## 内在驱动",
|
|
|
|
|
+ "查清真相。",
|
|
|
|
|
+ "## 成长弧光",
|
|
|
|
|
+ "从单打独斗到托付他人。",
|
|
|
|
|
+ "",
|
|
|
|
|
+ "=== SECTION: book_rules ===",
|
|
|
|
|
+ "---",
|
|
|
|
|
+ "version: \"1.0\"",
|
|
|
|
|
+ "protagonist:",
|
|
|
|
|
+ " name: 主角甲",
|
|
|
|
|
+ " personalityLock: [沉默, 执拗]",
|
|
|
|
|
+ " behavioralConstraints: [不对长辈失礼]",
|
|
|
|
|
+ "prohibitions:",
|
|
|
|
|
+ " - 不得美化体制暴力",
|
|
|
|
|
+ "chapterTypesOverride: []",
|
|
|
|
|
+ "fatigueWordsOverride: []",
|
|
|
|
|
+ "additionalAuditDimensions: []",
|
|
|
|
|
+ "enableFullCastTracking: false",
|
|
|
|
|
+ "---",
|
|
|
|
|
+ "## 叙事视角",
|
|
|
|
|
+ "第三人称单一视角。",
|
|
|
|
|
+ "",
|
|
|
|
|
+ "=== SECTION: current_state ===",
|
|
|
|
|
+ "| 字段 | 值 |",
|
|
|
|
|
+ "| --- | --- |",
|
|
|
|
|
+ "| 当前章节 | 0 |",
|
|
|
|
|
+ "",
|
|
|
|
|
+ "=== SECTION: pending_hooks ===",
|
|
|
|
|
+ "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
|
|
|
|
|
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
|
|
|
+ "| H01 | 1 | 主线 | 未开启 | 0 | 32章 | 中程 | 测试钩子 |",
|
|
|
|
|
+].join("\n");
|
|
|
|
|
+
|
|
|
|
|
+function buildAgent(): ArchitectAgent {
|
|
|
|
|
+ return 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(),
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function baseBook(): BookConfig {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: "cleanup-book",
|
|
|
|
|
+ title: "清理测试书",
|
|
|
|
|
+ platform: "other",
|
|
|
|
|
+ genre: "urban",
|
|
|
|
|
+ status: "active",
|
|
|
|
|
+ targetChapters: 40,
|
|
|
|
|
+ chapterWordCount: 2000,
|
|
|
|
|
+ language: "zh",
|
|
|
|
|
+ createdAt: "2026-04-15T00:00:00.000Z",
|
|
|
|
|
+ updatedAt: "2026-04-15T00:00:00.000Z",
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+describe("Phase 5 cleanup (1) — volume_outline.md mirror removed", () => {
|
|
|
|
|
+ let bookDir: string;
|
|
|
|
|
+
|
|
|
|
|
+ beforeEach(async () => {
|
|
|
|
|
+ bookDir = await mkdtemp(join(tmpdir(), "inkos-cleanup-1-"));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ afterEach(async () => {
|
|
|
|
|
+ await rm(bookDir, { recursive: true, force: true });
|
|
|
|
|
+ vi.restoreAllMocks();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("does not write volume_outline.md when running a fresh architect foundation", async () => {
|
|
|
|
|
+ const agent = buildAgent();
|
|
|
|
|
+ vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
|
|
|
|
|
+ .mockResolvedValue({ content: SAMPLE_RESPONSE, usage: ZERO_USAGE });
|
|
|
|
|
+
|
|
|
|
|
+ const output = await agent.generateFoundation(baseBook());
|
|
|
|
|
+ await agent.writeFoundationFiles(bookDir, output, false, "zh");
|
|
|
|
|
+
|
|
|
|
|
+ await expect(
|
|
|
|
|
+ readFile(join(bookDir, "story/volume_outline.md"), "utf-8"),
|
|
|
|
|
+ ).rejects.toThrow();
|
|
|
|
|
+
|
|
|
|
|
+ const newOutline = await readFile(join(bookDir, "story/outline/volume_map.md"), "utf-8");
|
|
|
|
|
+ expect(newOutline).toContain("卷一压卷二放");
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("readVolumeMap resolves the new path without needing the legacy mirror", async () => {
|
|
|
|
|
+ await mkdir(join(bookDir, "story/outline"), { recursive: true });
|
|
|
|
|
+ await writeFile(
|
|
|
|
|
+ join(bookDir, "story/outline/volume_map.md"),
|
|
|
|
|
+ "NEW map content",
|
|
|
|
|
+ "utf-8",
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const content = await readVolumeMap(bookDir, "(missing)");
|
|
|
|
|
+ expect(content).toBe("NEW map content");
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("readVolumeMap still falls back to legacy volume_outline.md for pre-cleanup books", async () => {
|
|
|
|
|
+ await mkdir(join(bookDir, "story"), { recursive: true });
|
|
|
|
|
+ await writeFile(
|
|
|
|
|
+ join(bookDir, "story/volume_outline.md"),
|
|
|
|
|
+ "LEGACY outline content",
|
|
|
|
|
+ "utf-8",
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const content = await readVolumeMap(bookDir, "(missing)");
|
|
|
|
|
+ expect(content).toBe("LEGACY outline content");
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("isCompleteBookDirectory accepts the new outline/ layout (no legacy mirror needed)", async () => {
|
|
|
|
|
+ const projectRoot = await mkdtemp(join(tmpdir(), "inkos-cleanup-1-proj-"));
|
|
|
|
|
+ try {
|
|
|
|
|
+ const targetBookDir = join(projectRoot, "books", "cleanup-book");
|
|
|
|
|
+ const storyDir = join(targetBookDir, "story");
|
|
|
|
|
+ await mkdir(join(storyDir, "outline"), { recursive: true });
|
|
|
|
|
+ await mkdir(join(targetBookDir, "chapters"), { recursive: true });
|
|
|
|
|
+
|
|
|
|
|
+ await Promise.all([
|
|
|
|
|
+ writeFile(join(targetBookDir, "book.json"), "{}", "utf-8"),
|
|
|
|
|
+ writeFile(join(storyDir, "outline/story_frame.md"), "# frame", "utf-8"),
|
|
|
|
|
+ writeFile(join(storyDir, "outline/volume_map.md"), "# map", "utf-8"),
|
|
|
|
|
+ writeFile(join(storyDir, "book_rules.md"), "# rules", "utf-8"),
|
|
|
|
|
+ writeFile(join(storyDir, "current_state.md"), "# state", "utf-8"),
|
|
|
|
|
+ writeFile(join(storyDir, "pending_hooks.md"), "# hooks", "utf-8"),
|
|
|
|
|
+ writeFile(join(targetBookDir, "chapters/index.json"), "[]", "utf-8"),
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ const state = new StateManager(projectRoot);
|
|
|
|
|
+ await expect(state.isCompleteBookDirectory(targetBookDir)).resolves.toBe(true);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ await rm(projectRoot, { recursive: true, force: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("isCompleteBookDirectory still accepts the pre-cleanup layout (legacy flat files)", async () => {
|
|
|
|
|
+ const projectRoot = await mkdtemp(join(tmpdir(), "inkos-cleanup-1-legacy-"));
|
|
|
|
|
+ try {
|
|
|
|
|
+ const targetBookDir = join(projectRoot, "books", "legacy-book");
|
|
|
|
|
+ const storyDir = join(targetBookDir, "story");
|
|
|
|
|
+ await mkdir(storyDir, { recursive: true });
|
|
|
|
|
+ await mkdir(join(targetBookDir, "chapters"), { recursive: true });
|
|
|
|
|
+
|
|
|
|
|
+ await Promise.all([
|
|
|
|
|
+ writeFile(join(targetBookDir, "book.json"), "{}", "utf-8"),
|
|
|
|
|
+ writeFile(join(storyDir, "story_bible.md"), "# bible", "utf-8"),
|
|
|
|
|
+ writeFile(join(storyDir, "volume_outline.md"), "# outline", "utf-8"),
|
|
|
|
|
+ writeFile(join(storyDir, "book_rules.md"), "# rules", "utf-8"),
|
|
|
|
|
+ writeFile(join(storyDir, "current_state.md"), "# state", "utf-8"),
|
|
|
|
|
+ writeFile(join(storyDir, "pending_hooks.md"), "# hooks", "utf-8"),
|
|
|
|
|
+ writeFile(join(targetBookDir, "chapters/index.json"), "[]", "utf-8"),
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ const state = new StateManager(projectRoot);
|
|
|
|
|
+ await expect(state.isCompleteBookDirectory(targetBookDir)).resolves.toBe(true);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ await rm(projectRoot, { recursive: true, force: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+describe("Phase 5 cleanup (2) — architect no longer seeds runtime log files", () => {
|
|
|
|
|
+ let bookDir: string;
|
|
|
|
|
+
|
|
|
|
|
+ beforeEach(async () => {
|
|
|
|
|
+ bookDir = await mkdtemp(join(tmpdir(), "inkos-cleanup-2-"));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ afterEach(async () => {
|
|
|
|
|
+ await rm(bookDir, { recursive: true, force: true });
|
|
|
|
|
+ vi.restoreAllMocks();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("does not write particle_ledger.md or subplot_board.md even when the genre wants a numerical system", async () => {
|
|
|
|
|
+ const agent = buildAgent();
|
|
|
|
|
+ vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
|
|
|
|
|
+ .mockResolvedValue({ content: SAMPLE_RESPONSE, usage: ZERO_USAGE });
|
|
|
|
|
+
|
|
|
|
|
+ const output = await agent.generateFoundation(baseBook());
|
|
|
|
|
+ // numericalSystem=true would previously seed particle_ledger.md
|
|
|
|
|
+ await agent.writeFoundationFiles(bookDir, output, true, "zh");
|
|
|
|
|
+
|
|
|
|
|
+ await expect(
|
|
|
|
|
+ readFile(join(bookDir, "story/particle_ledger.md"), "utf-8"),
|
|
|
|
|
+ ).rejects.toThrow();
|
|
|
|
|
+ await expect(
|
|
|
|
|
+ readFile(join(bookDir, "story/subplot_board.md"), "utf-8"),
|
|
|
|
|
+ ).rejects.toThrow();
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+describe("Phase 5 cleanup (3) — book_rules YAML moved to story_frame.md frontmatter", () => {
|
|
|
|
|
+ let bookDir: string;
|
|
|
|
|
+
|
|
|
|
|
+ beforeEach(async () => {
|
|
|
|
|
+ bookDir = await mkdtemp(join(tmpdir(), "inkos-cleanup-3-"));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ afterEach(async () => {
|
|
|
|
|
+ await rm(bookDir, { recursive: true, force: true });
|
|
|
|
|
+ vi.restoreAllMocks();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("writes the YAML frontmatter onto story_frame.md (not book_rules.md)", async () => {
|
|
|
|
|
+ const agent = buildAgent();
|
|
|
|
|
+ vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
|
|
|
|
|
+ .mockResolvedValue({ content: SAMPLE_RESPONSE, usage: ZERO_USAGE });
|
|
|
|
|
+
|
|
|
|
|
+ const output = await agent.generateFoundation(baseBook());
|
|
|
|
|
+ await agent.writeFoundationFiles(bookDir, output, false, "zh");
|
|
|
|
|
+
|
|
|
|
|
+ const storyFrame = await readFile(join(bookDir, "story/outline/story_frame.md"), "utf-8");
|
|
|
|
|
+ // story_frame.md now starts with YAML frontmatter
|
|
|
|
|
+ expect(storyFrame.trimStart().startsWith("---")).toBe(true);
|
|
|
|
|
+ expect(storyFrame).toContain("protagonist:");
|
|
|
|
|
+ expect(storyFrame).toContain("主角甲");
|
|
|
|
|
+ expect(storyFrame).toContain("prohibitions:");
|
|
|
|
|
+ // prose body still present
|
|
|
|
|
+ expect(storyFrame).toContain("主题与基调");
|
|
|
|
|
+
|
|
|
|
|
+ // book_rules.md is now a compat shim, not the full YAML file
|
|
|
|
|
+ const bookRulesShim = await readFile(join(bookDir, "story/book_rules.md"), "utf-8");
|
|
|
|
|
+ expect(bookRulesShim).toContain("兼容指针");
|
|
|
|
|
+ expect(bookRulesShim).toContain("story_frame.md");
|
|
|
|
|
+ // Shim carries the narrative body excerpt but not the YAML frontmatter
|
|
|
|
|
+ expect(bookRulesShim).toContain("叙事视角");
|
|
|
|
|
+ expect(bookRulesShim).not.toMatch(/^---\s*\nversion:/m);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("readBookRules() prefers story_frame.md frontmatter and parses the rules", async () => {
|
|
|
|
|
+ const agent = buildAgent();
|
|
|
|
|
+ vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
|
|
|
|
|
+ .mockResolvedValue({ content: SAMPLE_RESPONSE, usage: ZERO_USAGE });
|
|
|
|
|
+
|
|
|
|
|
+ const output = await agent.generateFoundation(baseBook());
|
|
|
|
|
+ await agent.writeFoundationFiles(bookDir, output, false, "zh");
|
|
|
|
|
+
|
|
|
|
|
+ const parsed = await readStructuredBookRules(bookDir);
|
|
|
|
|
+ expect(parsed).not.toBeNull();
|
|
|
|
|
+ expect(parsed?.rules.protagonist?.name).toBe("主角甲");
|
|
|
|
|
+ expect(parsed?.rules.protagonist?.personalityLock).toEqual(["沉默", "执拗"]);
|
|
|
|
|
+ expect(parsed?.rules.prohibitions).toEqual(["不得美化体制暴力"]);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("readBookRules() falls back to legacy book_rules.md when story_frame.md has no frontmatter", async () => {
|
|
|
|
|
+ const storyDir = join(bookDir, "story");
|
|
|
|
|
+ await mkdir(join(storyDir, "outline"), { recursive: true });
|
|
|
|
|
+ // story_frame.md exists but has NO frontmatter (pre-cleanup book)
|
|
|
|
|
+ await writeFile(
|
|
|
|
|
+ join(storyDir, "outline/story_frame.md"),
|
|
|
|
|
+ "# Story Frame\n\nPure prose with no YAML.\n",
|
|
|
|
|
+ "utf-8",
|
|
|
|
|
+ );
|
|
|
|
|
+ // Legacy book_rules.md carries the real frontmatter
|
|
|
|
|
+ await writeFile(
|
|
|
|
|
+ join(storyDir, "book_rules.md"),
|
|
|
|
|
+ "---\nversion: \"1.0\"\nprotagonist:\n name: LegacyHero\n personalityLock: [stoic]\n behavioralConstraints: []\nprohibitions:\n - No lazy tropes\n---\n",
|
|
|
|
|
+ "utf-8",
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const parsed = await readStructuredBookRules(bookDir);
|
|
|
|
|
+ expect(parsed?.rules.protagonist?.name).toBe("LegacyHero");
|
|
|
|
|
+ expect(parsed?.rules.prohibitions).toEqual(["No lazy tropes"]);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("readBookRules() returns null when neither source exists", async () => {
|
|
|
|
|
+ const parsed = await readStructuredBookRules(bookDir);
|
|
|
|
|
+ expect(parsed).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("planner-context readBookRules renders structured fields as a markdown block", async () => {
|
|
|
|
|
+ const storyDir = join(bookDir, "story");
|
|
|
|
|
+ await mkdir(join(storyDir, "outline"), { recursive: true });
|
|
|
|
|
+ await writeFile(
|
|
|
|
|
+ join(storyDir, "outline/story_frame.md"),
|
|
|
|
|
+ [
|
|
|
|
|
+ "---",
|
|
|
|
|
+ "version: \"1.0\"",
|
|
|
|
|
+ "protagonist:",
|
|
|
|
|
+ " name: 林辞",
|
|
|
|
|
+ " personalityLock: [沉默, 执拗]",
|
|
|
|
|
+ " behavioralConstraints: [不对长辈失礼]",
|
|
|
|
|
+ "prohibitions:",
|
|
|
|
|
+ " - 不得美化体制暴力",
|
|
|
|
|
+ " - 不得神化主角",
|
|
|
|
|
+ "---",
|
|
|
|
|
+ "",
|
|
|
|
|
+ "# Story Frame",
|
|
|
|
|
+ "",
|
|
|
|
|
+ "主题:测试",
|
|
|
|
|
+ "",
|
|
|
|
|
+ ].join("\n"),
|
|
|
|
|
+ "utf-8",
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const rendered = await readPlannerBookRules(storyDir);
|
|
|
|
|
+ expect(rendered).toContain("林辞");
|
|
|
|
|
+ expect(rendered).toContain("沉默");
|
|
|
|
|
+ expect(rendered).toContain("执拗");
|
|
|
|
|
+ expect(rendered).toContain("不得美化体制暴力");
|
|
|
|
|
+ expect(rendered).toContain("不得神化主角");
|
|
|
|
|
+ expect(rendered).toContain("不对长辈失礼");
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("planner-context readBookRules returns empty string when no rules source exists", async () => {
|
|
|
|
|
+ const storyDir = join(bookDir, "story");
|
|
|
|
|
+ await mkdir(storyDir, { recursive: true });
|
|
|
|
|
+ const rendered = await readPlannerBookRules(storyDir);
|
|
|
|
|
+ expect(rendered).toBe("");
|
|
|
|
|
+ });
|
|
|
|
|
+});
|