interaction-tools.test.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join } from "node:path";
  4. import { beforeAll, describe, expect, it, vi } from "vitest";
  5. import { createLogger, type LogSink } from "../index.js";
  6. import {
  7. buildChapterFileLookup,
  8. createInteractionToolsFromDeps,
  9. } from "../interaction/project-tools.js";
  10. let projectRoot: string;
  11. describe("interaction tools", () => {
  12. beforeAll(async () => {
  13. projectRoot = await mkdtemp(join(tmpdir(), "inkos-core-interaction-tools-"));
  14. await mkdir(join(projectRoot, "books", "harbor", "story"), { recursive: true });
  15. });
  16. it("delegates writeNextChapter and reviseDraft to the pipeline", async () => {
  17. const events: string[] = [];
  18. const sink: LogSink = {
  19. write(entry) {
  20. events.push(entry.message);
  21. },
  22. };
  23. const pipeline = {
  24. config: {
  25. logger: createLogger({ tag: "test", sinks: [sink] }),
  26. },
  27. writeNextChapter: vi.fn(async () => ({
  28. config: undefined,
  29. chapterNumber: 1,
  30. title: "Draft",
  31. wordCount: 1000,
  32. revised: false,
  33. status: "ready-for-review" as const,
  34. auditResult: { passed: true, issues: [], summary: "ok" },
  35. })),
  36. reviseDraft: vi.fn(async () => ({
  37. chapterNumber: 3,
  38. wordCount: 1200,
  39. fixedIssues: [],
  40. applied: true,
  41. status: "ready-for-review" as const,
  42. })),
  43. };
  44. const state = {
  45. ensureControlDocuments: vi.fn(async () => {}),
  46. bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
  47. loadBookConfig: vi.fn(async () => ({
  48. id: "harbor",
  49. title: "Harbor",
  50. platform: "other" as const,
  51. genre: "other",
  52. status: "outlining" as const,
  53. targetChapters: 200,
  54. chapterWordCount: 3000,
  55. createdAt: "2026-04-10T00:00:00.000Z",
  56. updatedAt: "2026-04-10T00:00:00.000Z",
  57. })),
  58. loadChapterIndex: vi.fn(async () => []),
  59. saveChapterIndex: vi.fn(async () => undefined),
  60. listBooks: vi.fn(async () => ["harbor"]),
  61. };
  62. const tools = createInteractionToolsFromDeps(pipeline, state);
  63. const writeResult = await tools.writeNextChapter("harbor");
  64. await tools.reviseDraft("harbor", 3, "rewrite");
  65. expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor");
  66. expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
  67. expect((writeResult as { __interaction?: { activeChapterNumber?: number } }).__interaction?.activeChapterNumber).toBe(1);
  68. expect(events).toEqual([]);
  69. });
  70. it("captures pipeline stage logs into interaction events", async () => {
  71. const pipeline = {
  72. config: {
  73. logger: createLogger({
  74. tag: "test",
  75. sinks: [{
  76. write() {},
  77. }],
  78. }),
  79. },
  80. writeNextChapter: vi.fn(async function (this: { config: { logger?: { info: (msg: string) => void } } }) {
  81. this.config.logger?.info("Stage: preparing chapter inputs");
  82. this.config.logger?.info("Stage: writing chapter draft");
  83. return {
  84. chapterNumber: 4,
  85. title: "Draft",
  86. wordCount: 1000,
  87. revised: false,
  88. status: "ready-for-review" as const,
  89. auditResult: { passed: true, issues: [], summary: "ok" },
  90. };
  91. }),
  92. reviseDraft: vi.fn(async () => ({
  93. chapterNumber: 3,
  94. wordCount: 1200,
  95. fixedIssues: [],
  96. applied: true,
  97. status: "ready-for-review" as const,
  98. })),
  99. };
  100. const state = {
  101. ensureControlDocuments: vi.fn(async () => {}),
  102. bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
  103. loadBookConfig: vi.fn(async () => ({
  104. id: "harbor",
  105. title: "Harbor",
  106. platform: "other" as const,
  107. genre: "other",
  108. status: "outlining" as const,
  109. targetChapters: 200,
  110. chapterWordCount: 3000,
  111. createdAt: "2026-04-10T00:00:00.000Z",
  112. updatedAt: "2026-04-10T00:00:00.000Z",
  113. })),
  114. loadChapterIndex: vi.fn(async () => []),
  115. saveChapterIndex: vi.fn(async () => undefined),
  116. listBooks: vi.fn(async () => ["harbor"]),
  117. };
  118. const tools = createInteractionToolsFromDeps(pipeline, state);
  119. const result = await tools.writeNextChapter("harbor");
  120. expect((result as {
  121. __interaction?: {
  122. events?: ReadonlyArray<{ kind: string; status: string; detail?: string }>;
  123. };
  124. }).__interaction?.events).toEqual([
  125. expect.objectContaining({ kind: "stage.changed", status: "planning", detail: "preparing chapter inputs" }),
  126. expect.objectContaining({ kind: "stage.changed", status: "writing", detail: "writing chapter draft" }),
  127. ]);
  128. });
  129. it("writes current_focus and author_intent into canonical story paths", async () => {
  130. const tools = createInteractionToolsFromDeps(
  131. {
  132. writeNextChapter: vi.fn(async () => ({
  133. chapterNumber: 1,
  134. title: "Draft",
  135. wordCount: 1000,
  136. revised: false,
  137. status: "ready-for-review" as const,
  138. auditResult: { passed: true, issues: [], summary: "ok" },
  139. })),
  140. reviseDraft: vi.fn(async () => ({
  141. chapterNumber: 3,
  142. wordCount: 1200,
  143. fixedIssues: [],
  144. applied: true,
  145. status: "ready-for-review" as const,
  146. })),
  147. },
  148. {
  149. ensureControlDocuments: vi.fn(async () => {}),
  150. bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
  151. loadBookConfig: vi.fn(async () => ({
  152. id: "harbor",
  153. title: "Harbor",
  154. platform: "other" as const,
  155. genre: "other",
  156. status: "outlining" as const,
  157. targetChapters: 200,
  158. chapterWordCount: 3000,
  159. createdAt: "2026-04-10T00:00:00.000Z",
  160. updatedAt: "2026-04-10T00:00:00.000Z",
  161. })),
  162. loadChapterIndex: vi.fn(async () => []),
  163. saveChapterIndex: vi.fn(async () => undefined),
  164. listBooks: vi.fn(async () => ["harbor"]),
  165. },
  166. );
  167. await tools.updateCurrentFocus("harbor", "# Current Focus\n\nBring focus back.\n");
  168. await tools.updateAuthorIntent("harbor", "# Author Intent\n\nWrite a harbor mystery.\n");
  169. await expect(readFile(join(projectRoot, "books", "harbor", "story", "current_focus.md"), "utf-8"))
  170. .resolves.toContain("Bring focus back");
  171. await expect(readFile(join(projectRoot, "books", "harbor", "story", "author_intent.md"), "utf-8"))
  172. .resolves.toContain("harbor mystery");
  173. });
  174. it("forwards foundation draft fields into shared book creation", async () => {
  175. const pipeline = {
  176. initBook: vi.fn(async () => undefined),
  177. writeNextChapter: vi.fn(async () => ({
  178. chapterNumber: 1,
  179. title: "Draft",
  180. wordCount: 1000,
  181. revised: false,
  182. status: "ready-for-review" as const,
  183. auditResult: { passed: true, issues: [], summary: "ok" },
  184. })),
  185. reviseDraft: vi.fn(async () => ({
  186. chapterNumber: 3,
  187. wordCount: 1200,
  188. fixedIssues: [],
  189. applied: true,
  190. status: "ready-for-review" as const,
  191. })),
  192. };
  193. const state = {
  194. ensureControlDocuments: vi.fn(async () => {}),
  195. bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
  196. loadBookConfig: vi.fn(async () => ({
  197. id: "night-harbor",
  198. title: "Night Harbor",
  199. platform: "other" as const,
  200. genre: "urban",
  201. status: "outlining" as const,
  202. targetChapters: 120,
  203. chapterWordCount: 2800,
  204. createdAt: "2026-04-10T00:00:00.000Z",
  205. updatedAt: "2026-04-10T00:00:00.000Z",
  206. })),
  207. loadChapterIndex: vi.fn(async () => []),
  208. saveChapterIndex: vi.fn(async () => undefined),
  209. listBooks: vi.fn(async () => []),
  210. };
  211. const tools = createInteractionToolsFromDeps(pipeline, state);
  212. await tools.createBook?.({
  213. title: "Night Harbor",
  214. genre: "urban",
  215. platform: "tomato",
  216. chapterWordCount: 2800,
  217. targetChapters: 120,
  218. blurb: "一个做灰产生意的人,准备在夜港洗白,却先被旧账拖回去。",
  219. worldPremise: "近未来架空香港,港口账本牵出多方势力。",
  220. protagonist: "林砚,水货账房出身,聪明克制,不轻易信人。",
  221. conflictCore: "洗白与旧债回潮的对撞。",
  222. volumeOutline: "卷一先查账,再暴露港口旧案。",
  223. authorIntent: "# 作者意图\n\n写成冷硬、克制、利益驱动的商战悬疑。\n",
  224. currentFocus: "# 当前聚焦\n\n先把旧账线和港口势力网立住。\n",
  225. });
  226. expect(pipeline.initBook).toHaveBeenCalledWith(
  227. expect.objectContaining({
  228. title: "Night Harbor",
  229. genre: "urban",
  230. platform: "tomato",
  231. targetChapters: 120,
  232. chapterWordCount: 2800,
  233. }),
  234. expect.objectContaining({
  235. externalContext: expect.stringContaining("近未来架空香港"),
  236. authorIntent: expect.stringContaining("冷硬、克制"),
  237. currentFocus: expect.stringContaining("旧账线"),
  238. }),
  239. );
  240. });
  241. it("builds a reusable chapter lookup from a single directory listing", () => {
  242. const lookup = buildChapterFileLookup([
  243. "0001_First.md",
  244. "0002_Second.md",
  245. "notes.txt",
  246. "0002_Second.backup",
  247. ]);
  248. expect(lookup.get(1)).toBe("0001_First.md");
  249. expect(lookup.get(2)).toBe("0002_Second.md");
  250. expect(lookup.size).toBe(2);
  251. });
  252. });