chapter-persistence.test.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { describe, expect, it, vi } from "vitest";
  2. import type { AuditIssue, AuditResult } from "../agents/continuity.js";
  3. import type { ChapterMeta } from "../models/chapter.js";
  4. import { persistChapterArtifacts } from "../pipeline/chapter-persistence.js";
  5. const ZERO_USAGE = {
  6. promptTokens: 0,
  7. completionTokens: 0,
  8. totalTokens: 0,
  9. } as const;
  10. function createIssue(overrides?: Partial<AuditIssue>): AuditIssue {
  11. return {
  12. severity: "warning",
  13. category: "continuity",
  14. description: "issue",
  15. suggestion: "fix",
  16. ...overrides,
  17. };
  18. }
  19. function createAuditResult(overrides?: Partial<AuditResult>): AuditResult {
  20. return {
  21. passed: true,
  22. issues: [],
  23. summary: "clean",
  24. ...overrides,
  25. };
  26. }
  27. describe("persistChapterArtifacts", () => {
  28. it("persists truth files, index, drift guidance, and snapshots for reviewable chapters", async () => {
  29. const saveChapter = vi.fn().mockResolvedValue(undefined);
  30. const saveTruthFiles = vi.fn().mockResolvedValue(undefined);
  31. const saveChapterIndex = vi.fn().mockResolvedValue(undefined);
  32. const markBookActiveIfNeeded = vi.fn().mockResolvedValue(undefined);
  33. const persistAuditDriftGuidance = vi.fn().mockResolvedValue(undefined);
  34. const snapshotState = vi.fn().mockResolvedValue(undefined);
  35. const syncCurrentStateFactHistory = vi.fn().mockResolvedValue(undefined);
  36. const logSnapshotStage = vi.fn();
  37. await persistChapterArtifacts({
  38. chapterNumber: 3,
  39. chapterTitle: "Chapter Title",
  40. status: "ready-for-review",
  41. auditResult: createAuditResult({
  42. issues: [
  43. createIssue({ severity: "info", description: "ignore me" }),
  44. createIssue({ severity: "warning", description: "keep me" }),
  45. createIssue({ severity: "critical", description: "keep me too" }),
  46. ],
  47. }),
  48. finalWordCount: 888,
  49. lengthWarnings: ["warn"],
  50. degradedIssues: [],
  51. tokenUsage: ZERO_USAGE,
  52. loadChapterIndex: async () => [] satisfies ReadonlyArray<ChapterMeta>,
  53. saveChapter,
  54. saveTruthFiles,
  55. saveChapterIndex,
  56. markBookActiveIfNeeded,
  57. persistAuditDriftGuidance,
  58. snapshotState,
  59. syncCurrentStateFactHistory,
  60. logSnapshotStage,
  61. now: () => "2026-04-01T00:00:00.000Z",
  62. });
  63. expect(saveChapter).toHaveBeenCalledTimes(1);
  64. expect(saveTruthFiles).toHaveBeenCalledTimes(1);
  65. expect(saveChapterIndex).toHaveBeenCalledWith([
  66. expect.objectContaining({
  67. number: 3,
  68. title: "Chapter Title",
  69. status: "ready-for-review",
  70. wordCount: 888,
  71. auditIssues: [
  72. "[info] ignore me",
  73. "[warning] keep me",
  74. "[critical] keep me too",
  75. ],
  76. reviewNote: undefined,
  77. tokenUsage: ZERO_USAGE,
  78. }),
  79. ]);
  80. expect(markBookActiveIfNeeded).toHaveBeenCalledTimes(1);
  81. expect(persistAuditDriftGuidance).toHaveBeenCalledWith([
  82. expect.objectContaining({ severity: "warning", description: "keep me" }),
  83. expect.objectContaining({ severity: "critical", description: "keep me too" }),
  84. ]);
  85. expect(logSnapshotStage).toHaveBeenCalledTimes(1);
  86. expect(snapshotState).toHaveBeenCalledTimes(1);
  87. expect(syncCurrentStateFactHistory).toHaveBeenCalledTimes(1);
  88. });
  89. it("skips truth persistence and snapshots for state-degraded chapters while preserving review note", async () => {
  90. const saveChapter = vi.fn().mockResolvedValue(undefined);
  91. const saveTruthFiles = vi.fn().mockResolvedValue(undefined);
  92. const saveChapterIndex = vi.fn().mockResolvedValue(undefined);
  93. const markBookActiveIfNeeded = vi.fn().mockResolvedValue(undefined);
  94. const persistAuditDriftGuidance = vi.fn().mockResolvedValue(undefined);
  95. const snapshotState = vi.fn().mockResolvedValue(undefined);
  96. const syncCurrentStateFactHistory = vi.fn().mockResolvedValue(undefined);
  97. const logSnapshotStage = vi.fn();
  98. await persistChapterArtifacts({
  99. chapterNumber: 4,
  100. chapterTitle: "Degraded Chapter",
  101. status: "state-degraded",
  102. auditResult: createAuditResult({
  103. passed: false,
  104. issues: [createIssue({ description: "audit issue" })],
  105. summary: "needs review",
  106. }),
  107. finalWordCount: 512,
  108. lengthWarnings: [],
  109. degradedIssues: [createIssue({ description: "state mismatch" })],
  110. tokenUsage: ZERO_USAGE,
  111. loadChapterIndex: async () => [] satisfies ReadonlyArray<ChapterMeta>,
  112. saveChapter,
  113. saveTruthFiles,
  114. saveChapterIndex,
  115. markBookActiveIfNeeded,
  116. persistAuditDriftGuidance,
  117. snapshotState,
  118. syncCurrentStateFactHistory,
  119. logSnapshotStage,
  120. now: () => "2026-04-01T00:00:00.000Z",
  121. });
  122. expect(saveChapter).toHaveBeenCalledTimes(1);
  123. expect(saveTruthFiles).not.toHaveBeenCalled();
  124. expect(saveChapterIndex).toHaveBeenCalledWith([
  125. expect.objectContaining({
  126. number: 4,
  127. title: "Degraded Chapter",
  128. status: "state-degraded",
  129. reviewNote: expect.any(String),
  130. }),
  131. ]);
  132. const reviewNote = saveChapterIndex.mock.calls[0]?.[0]?.[0]?.reviewNote as string;
  133. expect(JSON.parse(reviewNote)).toMatchObject({
  134. kind: "state-degraded",
  135. baseStatus: "audit-failed",
  136. injectedIssues: ["[warning] state mismatch"],
  137. });
  138. expect(persistAuditDriftGuidance).toHaveBeenCalledWith([]);
  139. expect(logSnapshotStage).not.toHaveBeenCalled();
  140. expect(snapshotState).not.toHaveBeenCalled();
  141. expect(syncCurrentStateFactHistory).not.toHaveBeenCalled();
  142. });
  143. it("replaces existing entry for the same chapter number instead of appending", async () => {
  144. const saveChapterIndex = vi.fn().mockResolvedValue(undefined);
  145. const existingEntry: ChapterMeta = {
  146. number: 1,
  147. title: "Old Title",
  148. status: "drafted",
  149. wordCount: 500,
  150. createdAt: "2026-01-01T00:00:00.000Z",
  151. updatedAt: "2026-01-01T00:00:00.000Z",
  152. auditIssues: [],
  153. lengthWarnings: [],
  154. };
  155. await persistChapterArtifacts({
  156. chapterNumber: 1,
  157. chapterTitle: "New Title",
  158. status: "ready-for-review",
  159. auditResult: createAuditResult(),
  160. finalWordCount: 2000,
  161. lengthWarnings: [],
  162. degradedIssues: [],
  163. tokenUsage: ZERO_USAGE,
  164. loadChapterIndex: async () => [existingEntry],
  165. saveChapter: vi.fn().mockResolvedValue(undefined),
  166. saveTruthFiles: vi.fn().mockResolvedValue(undefined),
  167. saveChapterIndex,
  168. markBookActiveIfNeeded: vi.fn().mockResolvedValue(undefined),
  169. persistAuditDriftGuidance: vi.fn().mockResolvedValue(undefined),
  170. snapshotState: vi.fn().mockResolvedValue(undefined),
  171. syncCurrentStateFactHistory: vi.fn().mockResolvedValue(undefined),
  172. logSnapshotStage: vi.fn(),
  173. now: () => "2026-04-01T00:00:00.000Z",
  174. });
  175. const savedIndex = saveChapterIndex.mock.calls[0][0] as ChapterMeta[];
  176. // Must have exactly 1 entry, not 2
  177. expect(savedIndex).toHaveLength(1);
  178. expect(savedIndex[0].number).toBe(1);
  179. expect(savedIndex[0].title).toBe("New Title");
  180. expect(savedIndex[0].wordCount).toBe(2000);
  181. expect(savedIndex[0].status).toBe("ready-for-review");
  182. // Must preserve original createdAt
  183. expect(savedIndex[0].createdAt).toBe("2026-01-01T00:00:00.000Z");
  184. expect(savedIndex[0].updatedAt).toBe("2026-04-01T00:00:00.000Z");
  185. });
  186. });