novel-writer-cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +1 -1
  2. package/agents/chapter-writer.md +43 -14
  3. package/agents/character-weaver.md +7 -1
  4. package/agents/plot-architect.md +20 -7
  5. package/agents/quality-judge.md +199 -20
  6. package/agents/style-analyzer.md +14 -8
  7. package/agents/style-refiner.md +10 -3
  8. package/agents/world-builder.md +8 -1
  9. package/dist/__tests__/agent-prompts-anti-ai-upgrade.test.js +194 -6
  10. package/dist/__tests__/agent-prompts-platform-expansion.test.js +33 -0
  11. package/dist/__tests__/anti-ai-infrastructure.test.js +548 -0
  12. package/dist/__tests__/anti-ai-templates.test.js +2 -2
  13. package/dist/__tests__/canon-status-lifecycle.test.js +481 -0
  14. package/dist/__tests__/commit-gate-decision.test.js +65 -0
  15. package/dist/__tests__/commit-prototype-pollution.test.js +1 -1
  16. package/dist/__tests__/excitement-type-annotation.test.js +240 -0
  17. package/dist/__tests__/excitement-type.test.js +21 -0
  18. package/dist/__tests__/gate-decision.test.js +62 -15
  19. package/dist/__tests__/genre-excitement-mapping.test.js +355 -0
  20. package/dist/__tests__/golden-chapter-gates.test.js +79 -0
  21. package/dist/__tests__/golden-chapter-mini-planning.test.js +485 -0
  22. package/dist/__tests__/helpers/quickstart-mini-planning.js +61 -0
  23. package/dist/__tests__/init.test.js +57 -5
  24. package/dist/__tests__/instructions-platform-expansion.test.js +125 -0
  25. package/dist/__tests__/next-step-gate-decision-routing.test.js +98 -0
  26. package/dist/__tests__/orchestrator-state-write-path.test.js +1 -1
  27. package/dist/__tests__/platform-profile.test.js +57 -1
  28. package/dist/__tests__/quickstart-pipeline.test.js +73 -6
  29. package/dist/__tests__/scoring-weights.test.js +193 -0
  30. package/dist/__tests__/steps-id.test.js +2 -0
  31. package/dist/__tests__/validate-quickstart-prereqs.test.js +2 -0
  32. package/dist/advance.js +27 -2
  33. package/dist/anti-ai-context.js +535 -0
  34. package/dist/cli.js +3 -1
  35. package/dist/commit.js +22 -0
  36. package/dist/excitement-type.js +12 -0
  37. package/dist/gate-decision.js +98 -2
  38. package/dist/golden-chapter-gates.js +143 -0
  39. package/dist/init.js +76 -7
  40. package/dist/instructions.js +552 -6
  41. package/dist/next-step.js +124 -88
  42. package/dist/platform-profile.js +20 -8
  43. package/dist/quickstart-mini-planning.js +30 -0
  44. package/dist/scoring-weights.js +38 -3
  45. package/dist/steps.js +1 -1
  46. package/dist/validate.js +293 -214
  47. package/dist/volume-commit.js +271 -5
  48. package/dist/volume-planning.js +78 -3
  49. package/docs/user/README.md +1 -0
  50. package/docs/user/migration-guide.md +166 -0
  51. package/docs/user/novel-cli.md +4 -3
  52. package/docs/user/quick-start.md +354 -57
  53. package/package.json +1 -1
  54. package/schemas/platform-profile.schema.json +2 -2
  55. package/scripts/lint-blacklist.sh +221 -76
  56. package/scripts/lint-structural.sh +538 -0
  57. package/skills/continue/SKILL.md +6 -0
  58. package/skills/continue/references/context-contracts.md +71 -6
  59. package/skills/continue/references/periodic-maintenance.md +12 -1
  60. package/skills/novel-writing/references/quality-rubric.md +79 -26
  61. package/skills/novel-writing/references/style-guide.md +129 -19
  62. package/skills/start/SKILL.md +23 -3
  63. package/skills/start/references/vol-planning.md +12 -3
  64. package/templates/ai-blacklist.json +1024 -246
  65. package/templates/ai-sentence-patterns.json +167 -0
  66. package/templates/genre-excitement-map.json +48 -0
  67. package/templates/genre-golden-standards.json +80 -0
  68. package/templates/genre-weight-profiles.json +15 -0
  69. package/templates/golden-chapter-gates.json +230 -0
  70. package/templates/novel-ask/example.question.json +3 -2
  71. package/templates/platform-profile.json +141 -1
  72. package/templates/platforms/fanqie.md +35 -0
  73. package/templates/platforms/jinjiang.md +35 -0
  74. package/templates/platforms/qidian.md +35 -0
  75. package/templates/style-profile-template.json +3 -0
@@ -0,0 +1,355 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import test from "node:test";
6
+ import { fileURLToPath } from "node:url";
7
+ import { buildInstructionPacket } from "../instructions.js";
8
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
9
+ async function readRepoText(relPath) {
10
+ return readFile(join(repoRoot, relPath), "utf8");
11
+ }
12
+ async function readRepoJson(relPath) {
13
+ return JSON.parse(await readRepoText(relPath));
14
+ }
15
+ async function writeText(absPath, contents) {
16
+ await mkdir(dirname(absPath), { recursive: true });
17
+ await writeFile(absPath, contents, "utf8");
18
+ }
19
+ async function writeJson(absPath, payload) {
20
+ await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
21
+ }
22
+ function makeVolumeCheckpoint(lastCompletedChapter = 0) {
23
+ return {
24
+ last_completed_chapter: lastCompletedChapter,
25
+ current_volume: 1,
26
+ orchestrator_state: "VOL_PLANNING",
27
+ pipeline_stage: null,
28
+ volume_pipeline_stage: null,
29
+ inflight_chapter: null,
30
+ revision_count: 0,
31
+ hook_fix_count: 0,
32
+ title_fix_count: 0
33
+ };
34
+ }
35
+ function makeJudgeCheckpoint(chapter) {
36
+ return {
37
+ last_completed_chapter: chapter - 1,
38
+ current_volume: 1,
39
+ orchestrator_state: "WRITING",
40
+ pipeline_stage: "refined",
41
+ inflight_chapter: chapter,
42
+ revision_count: 0,
43
+ hook_fix_count: 0,
44
+ title_fix_count: 0
45
+ };
46
+ }
47
+ function makeQuickstartCheckpoint(lastCompletedChapter = 0) {
48
+ return {
49
+ last_completed_chapter: lastCompletedChapter,
50
+ current_volume: 1,
51
+ orchestrator_state: "QUICK_START",
52
+ pipeline_stage: null,
53
+ volume_pipeline_stage: null,
54
+ inflight_chapter: null,
55
+ revision_count: 0,
56
+ hook_fix_count: 0,
57
+ title_fix_count: 0
58
+ };
59
+ }
60
+ async function seedGenreAwareProject(rootDir, genreLine = "- **题材**:言情") {
61
+ await writeText(join(rootDir, "brief.md"), [
62
+ "# 创作纲领",
63
+ "",
64
+ "## 基本信息",
65
+ "",
66
+ genreLine,
67
+ "- **目标平台**:晋江",
68
+ ""
69
+ ].join("\n"));
70
+ await writeJson(join(rootDir, "genre-excitement-map.json"), await readRepoJson("templates/genre-excitement-map.json"));
71
+ await writeJson(join(rootDir, "genre-golden-standards.json"), await readRepoJson("templates/genre-golden-standards.json"));
72
+ await writeJson(join(rootDir, "volumes/vol-01/chapter-contracts/chapter-002.json"), {
73
+ chapter: 2,
74
+ storyline_id: "main-arc",
75
+ objectives: [{ id: "OBJ-2-1", required: true, description: "x" }]
76
+ });
77
+ await writeText(join(rootDir, "staging/chapters/chapter-002.md"), "# 第2章\n\n正文\n");
78
+ await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), "# 试写章\n\n正文\n");
79
+ }
80
+ test("issue 131 prompts, skills, templates, and openspec docs describe genre-aware opening guidance", async () => {
81
+ const plotArchitect = await readRepoText("agents/plot-architect.md");
82
+ const qualityJudge = await readRepoText("agents/quality-judge.md");
83
+ const continueSkill = await readRepoText("skills/continue/SKILL.md");
84
+ const startSkill = await readRepoText("skills/start/SKILL.md");
85
+ const contextContracts = await readRepoText("skills/continue/references/context-contracts.md");
86
+ const proposal = await readRepoText("openspec/changes/m8-genre-excitement-mapping/proposal.md");
87
+ const tasks = await readRepoText("openspec/changes/m8-genre-excitement-mapping/tasks.md");
88
+ const genreMapSpec = await readRepoText("openspec/changes/m8-genre-excitement-mapping/specs/genre-excitement-map/spec.md");
89
+ const genreStandardsSpec = await readRepoText("openspec/changes/m8-genre-excitement-mapping/specs/genre-golden-standards/spec.md");
90
+ assert.match(plotArchitect, /genre_excitement_map/);
91
+ assert.match(plotArchitect, /禁止新增第 10 个/);
92
+ assert.match(plotArchitect, /不得新增 `ExcitementTypeOverrideReason`/);
93
+ assert.match(plotArchitect, /自由分配/);
94
+ assert.match(qualityJudge, /genre_golden_standards/);
95
+ assert.match(qualityJudge, /minimum_thresholds/);
96
+ assert.match(qualityJudge, /平台门控和题材门槛都会独立生效/);
97
+ assert.match(qualityJudge, /recommendation.*"revise"/s);
98
+ assert.match(continueSkill, /packet\.manifest\.inline\.genre_golden_standards/);
99
+ assert.match(startSkill, /言情 \(romance\)/);
100
+ assert.match(startSkill, /invalid_combinations/);
101
+ assert.match(startSkill, /packet\.manifest\.inline\.genre_excitement_map/);
102
+ assert.match(startSkill, /若两处都不可读/);
103
+ assert.match(contextContracts, /genre_golden_standards\?:/);
104
+ assert.match(proposal, /src\/instructions\.ts/);
105
+ assert.match(proposal, /quickstart:results/);
106
+ assert.match(tasks, /src\/instructions\.ts/);
107
+ assert.match(tasks, /quickstart:results/);
108
+ assert.match(genreMapSpec, /src\/instructions\.ts/);
109
+ assert.match(genreStandardsSpec, /quickstart:results/);
110
+ const excitementMap = await readRepoJson("templates/genre-excitement-map.json");
111
+ assert.equal(excitementMap.schema_version, 1);
112
+ assert.deepEqual(Object.keys(excitementMap.genres).sort(), ["dushi", "history", "romance", "scifi", "suspense", "xuanhuan"]);
113
+ assert.equal(excitementMap.genres.xuanhuan.chapters["1"], "setup");
114
+ assert.equal(excitementMap.genres.xuanhuan.chapters["2"], "power_up");
115
+ assert.equal(excitementMap.genres.xuanhuan.chapters["3"], "face_slap");
116
+ assert.equal(excitementMap.genres.romance.chapters["2"], "reveal");
117
+ const goldenStandards = await readRepoJson("templates/genre-golden-standards.json");
118
+ assert.equal(goldenStandards.schema_version, 1);
119
+ assert.deepEqual(Object.keys(goldenStandards.genres).sort(), ["dushi", "history", "romance", "scifi", "suspense", "xuanhuan"]);
120
+ assert.equal(goldenStandards.genres.xuanhuan.minimum_thresholds.immersion, 3.5);
121
+ assert.equal(goldenStandards.genres.suspense.minimum_thresholds.plot_logic, 4.0);
122
+ assert.equal(goldenStandards.genres.romance.minimum_thresholds.character, 4.0);
123
+ assert.equal(goldenStandards.genres.romance.minimum_thresholds.style_naturalness, 3.5);
124
+ assert.ok(goldenStandards.invalid_combinations.some((item) => item.genre === "romance" && item.platform === "qidian"));
125
+ assert.ok(goldenStandards.invalid_combinations.some((item) => item.genre === "xuanhuan" && item.platform === "jinjiang"));
126
+ });
127
+ test("buildInstructionPacket injects selected genre excitement map and genre standards", async () => {
128
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-genre-map-"));
129
+ try {
130
+ await seedGenreAwareProject(rootDir);
131
+ const volumePacket = (await buildInstructionPacket({
132
+ rootDir,
133
+ checkpoint: makeVolumeCheckpoint(),
134
+ step: { kind: "volume", phase: "outline" },
135
+ embedMode: null,
136
+ writeManifest: false
137
+ }));
138
+ assert.deepEqual(volumePacket.packet.manifest.inline.genre_excitement_map, {
139
+ genre: "romance",
140
+ chapters: { "1": "setup", "2": "reveal", "3": "reversal" },
141
+ source: "genre-excitement-map.json"
142
+ });
143
+ const judgePacket = (await buildInstructionPacket({
144
+ rootDir,
145
+ checkpoint: makeJudgeCheckpoint(2),
146
+ step: { kind: "chapter", chapter: 2, stage: "judge" },
147
+ embedMode: null,
148
+ writeManifest: false
149
+ }));
150
+ assert.deepEqual(judgePacket.packet.manifest.inline.genre_golden_standards, {
151
+ genre: "romance",
152
+ focus_dimensions: ["character", "style_naturalness", "emotional_impact"],
153
+ criteria: [
154
+ "人物性格与关系张力要通过动作、对白和情绪反应落地,不能只靠旁白概括。",
155
+ "CP 化学反应、情绪钩子或关系预期必须在前三章站住。"
156
+ ],
157
+ minimum_thresholds: { character: 4, style_naturalness: 3.5 },
158
+ source: "genre-golden-standards.json"
159
+ });
160
+ const quickstartResultsPacket = (await buildInstructionPacket({
161
+ rootDir,
162
+ checkpoint: makeQuickstartCheckpoint(),
163
+ step: { kind: "quickstart", phase: "results" },
164
+ embedMode: null,
165
+ writeManifest: false
166
+ }));
167
+ assert.equal(quickstartResultsPacket.packet.manifest.inline.genre_golden_standards.genre, "romance");
168
+ }
169
+ finally {
170
+ await rm(rootDir, { recursive: true, force: true });
171
+ }
172
+ });
173
+ test("buildInstructionPacket accepts supported brief genre formats and aliases", async () => {
174
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-genre-map-brief-"));
175
+ try {
176
+ await seedGenreAwareProject(rootDir);
177
+ const cases = [
178
+ { genreLine: "- **题材**:言情", expectedGenre: "romance" },
179
+ { genreLine: "- **题材**: 言情(女频)", expectedGenre: "romance" },
180
+ { genreLine: "- **Genre**: romance", expectedGenre: "romance" },
181
+ { genreLine: "- **Genre**: SUSPENSE ", expectedGenre: "suspense" },
182
+ { genreLine: "- **Genre**: Sci-Fi", expectedGenre: "scifi" }
183
+ ];
184
+ for (const testCase of cases) {
185
+ await writeText(join(rootDir, "brief.md"), [
186
+ "# 创作纲领",
187
+ "",
188
+ "## 基本信息",
189
+ "",
190
+ testCase.genreLine,
191
+ "- **目标平台**:晋江",
192
+ ""
193
+ ].join("\n"));
194
+ const volumePacket = (await buildInstructionPacket({
195
+ rootDir,
196
+ checkpoint: makeVolumeCheckpoint(),
197
+ step: { kind: "volume", phase: "outline" },
198
+ embedMode: null,
199
+ writeManifest: false
200
+ }));
201
+ assert.equal(volumePacket.packet.manifest.inline.genre_excitement_map.genre, testCase.expectedGenre, testCase.genreLine);
202
+ const judgePacket = (await buildInstructionPacket({
203
+ rootDir,
204
+ checkpoint: makeJudgeCheckpoint(2),
205
+ step: { kind: "chapter", chapter: 2, stage: "judge" },
206
+ embedMode: null,
207
+ writeManifest: false
208
+ }));
209
+ assert.equal(judgePacket.packet.manifest.inline.genre_golden_standards.genre, testCase.expectedGenre, testCase.genreLine);
210
+ const quickstartPacket = (await buildInstructionPacket({
211
+ rootDir,
212
+ checkpoint: makeQuickstartCheckpoint(),
213
+ step: { kind: "quickstart", phase: "results" },
214
+ embedMode: null,
215
+ writeManifest: false
216
+ }));
217
+ assert.equal(quickstartPacket.packet.manifest.inline.genre_golden_standards.genre, testCase.expectedGenre, testCase.genreLine);
218
+ }
219
+ }
220
+ finally {
221
+ await rm(rootDir, { recursive: true, force: true });
222
+ }
223
+ });
224
+ test("buildInstructionPacket skips genre-specific injections when templates are missing, malformed, or genre is unknown", async () => {
225
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-genre-map-skip-"));
226
+ try {
227
+ await seedGenreAwareProject(rootDir, "- **题材**:仙侠");
228
+ const unknownGenreVolume = (await buildInstructionPacket({
229
+ rootDir,
230
+ checkpoint: makeVolumeCheckpoint(),
231
+ step: { kind: "volume", phase: "outline" },
232
+ embedMode: null,
233
+ writeManifest: false
234
+ }));
235
+ assert.equal(Object.prototype.hasOwnProperty.call(unknownGenreVolume.packet.manifest.inline, "genre_excitement_map"), false);
236
+ const unknownGenreJudge = (await buildInstructionPacket({
237
+ rootDir,
238
+ checkpoint: makeJudgeCheckpoint(2),
239
+ step: { kind: "chapter", chapter: 2, stage: "judge" },
240
+ embedMode: null,
241
+ writeManifest: false
242
+ }));
243
+ assert.equal(Object.prototype.hasOwnProperty.call(unknownGenreJudge.packet.manifest.inline, "genre_golden_standards"), false);
244
+ await seedGenreAwareProject(rootDir);
245
+ await rm(join(rootDir, "genre-excitement-map.json"), { force: true });
246
+ await rm(join(rootDir, "genre-golden-standards.json"), { force: true });
247
+ const missingTemplateVolume = (await buildInstructionPacket({
248
+ rootDir,
249
+ checkpoint: makeVolumeCheckpoint(),
250
+ step: { kind: "volume", phase: "outline" },
251
+ embedMode: null,
252
+ writeManifest: false
253
+ }));
254
+ assert.equal(Object.prototype.hasOwnProperty.call(missingTemplateVolume.packet.manifest.inline, "genre_excitement_map"), false);
255
+ const chapterFourJudge = (await buildInstructionPacket({
256
+ rootDir,
257
+ checkpoint: makeJudgeCheckpoint(4),
258
+ step: { kind: "chapter", chapter: 4, stage: "judge" },
259
+ embedMode: null,
260
+ writeManifest: false
261
+ }));
262
+ assert.equal(Object.prototype.hasOwnProperty.call(chapterFourJudge.packet.manifest.inline, "genre_golden_standards"), false);
263
+ const lateVolumePacket = (await buildInstructionPacket({
264
+ rootDir,
265
+ checkpoint: makeVolumeCheckpoint(3),
266
+ step: { kind: "volume", phase: "outline" },
267
+ embedMode: null,
268
+ writeManifest: false
269
+ }));
270
+ assert.equal(Object.prototype.hasOwnProperty.call(lateVolumePacket.packet.manifest.inline, "genre_excitement_map"), false);
271
+ const lateQuickstartPacket = (await buildInstructionPacket({
272
+ rootDir,
273
+ checkpoint: makeQuickstartCheckpoint(3),
274
+ step: { kind: "quickstart", phase: "results" },
275
+ embedMode: null,
276
+ writeManifest: false
277
+ }));
278
+ assert.equal(Object.prototype.hasOwnProperty.call(lateQuickstartPacket.packet.manifest.inline, "genre_golden_standards"), false);
279
+ }
280
+ finally {
281
+ await rm(rootDir, { recursive: true, force: true });
282
+ }
283
+ });
284
+ test("buildInstructionPacket quietly skips malformed optional genre templates", async () => {
285
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-genre-map-malformed-"));
286
+ try {
287
+ await seedGenreAwareProject(rootDir);
288
+ const malformedCases = [
289
+ {
290
+ name: "invalid JSON files",
291
+ mutate: async () => {
292
+ await writeText(join(rootDir, "genre-excitement-map.json"), "{\n");
293
+ await writeText(join(rootDir, "genre-golden-standards.json"), "{\n");
294
+ }
295
+ },
296
+ {
297
+ name: "wrong schema_version",
298
+ mutate: async () => {
299
+ const excitementMap = await readRepoJson("templates/genre-excitement-map.json");
300
+ const goldenStandards = await readRepoJson("templates/genre-golden-standards.json");
301
+ excitementMap.schema_version = 2;
302
+ goldenStandards.schema_version = 2;
303
+ await writeJson(join(rootDir, "genre-excitement-map.json"), excitementMap);
304
+ await writeJson(join(rootDir, "genre-golden-standards.json"), goldenStandards);
305
+ }
306
+ },
307
+ {
308
+ name: "malformed entry shapes and unknown dimensions",
309
+ mutate: async () => {
310
+ const excitementMap = await readRepoJson("templates/genre-excitement-map.json");
311
+ const goldenStandards = await readRepoJson("templates/genre-golden-standards.json");
312
+ excitementMap.genres.romance = { chapters: { "1": "setup", "2": "boom", "3": "reversal" } };
313
+ goldenStandards.genres.romance = {
314
+ focus_dimensions: ["styleNaturalness"],
315
+ criteria: ["x"],
316
+ minimum_thresholds: { styleNaturalness: 3.5 }
317
+ };
318
+ await writeJson(join(rootDir, "genre-excitement-map.json"), excitementMap);
319
+ await writeJson(join(rootDir, "genre-golden-standards.json"), goldenStandards);
320
+ }
321
+ }
322
+ ];
323
+ for (const malformedCase of malformedCases) {
324
+ await malformedCase.mutate();
325
+ const volumePacket = (await buildInstructionPacket({
326
+ rootDir,
327
+ checkpoint: makeVolumeCheckpoint(),
328
+ step: { kind: "volume", phase: "outline" },
329
+ embedMode: null,
330
+ writeManifest: false
331
+ }));
332
+ assert.equal(Object.prototype.hasOwnProperty.call(volumePacket.packet.manifest.inline, "genre_excitement_map"), false, malformedCase.name);
333
+ const judgePacket = (await buildInstructionPacket({
334
+ rootDir,
335
+ checkpoint: makeJudgeCheckpoint(2),
336
+ step: { kind: "chapter", chapter: 2, stage: "judge" },
337
+ embedMode: null,
338
+ writeManifest: false
339
+ }));
340
+ assert.equal(Object.prototype.hasOwnProperty.call(judgePacket.packet.manifest.inline, "genre_golden_standards"), false, malformedCase.name);
341
+ const quickstartPacket = (await buildInstructionPacket({
342
+ rootDir,
343
+ checkpoint: makeQuickstartCheckpoint(),
344
+ step: { kind: "quickstart", phase: "results" },
345
+ embedMode: null,
346
+ writeManifest: false
347
+ }));
348
+ assert.equal(Object.prototype.hasOwnProperty.call(quickstartPacket.packet.manifest.inline, "genre_golden_standards"), false, malformedCase.name);
349
+ await seedGenreAwareProject(rootDir);
350
+ }
351
+ }
352
+ finally {
353
+ await rm(rootDir, { recursive: true, force: true });
354
+ }
355
+ });
@@ -0,0 +1,79 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import test from "node:test";
4
+ import { parseGoldenChapterGates, selectGoldenChapterGatesForPlatform } from "../golden-chapter-gates.js";
5
+ function makeBaseConfig() {
6
+ const baseRule = {
7
+ id: "hook_present",
8
+ requirement: "章末必须留下钩子",
9
+ threshold: {
10
+ metric: "hook_strength",
11
+ operator: ">=",
12
+ value: 3
13
+ }
14
+ };
15
+ const chapterConfig = { gates: [baseRule] };
16
+ return {
17
+ schema_version: 1,
18
+ invalid_combinations: [],
19
+ platforms: {
20
+ fanqie: { chapters: { "1": chapterConfig, "2": chapterConfig, "3": chapterConfig } },
21
+ qidian: { chapters: { "1": chapterConfig, "2": chapterConfig, "3": chapterConfig } },
22
+ jinjiang: { chapters: { "1": chapterConfig, "2": chapterConfig, "3": chapterConfig } }
23
+ }
24
+ };
25
+ }
26
+ test("parseGoldenChapterGates rejects unsupported threshold operators", () => {
27
+ const raw = makeBaseConfig();
28
+ raw.platforms.fanqie.chapters["1"].gates[0].threshold.operator = "approx";
29
+ assert.throws(() => parseGoldenChapterGates(raw, "golden-chapter-gates.json"), /threshold\.operator.*<, <=, >, >=, ==, !=/i);
30
+ });
31
+ test("parseGoldenChapterGates parses template happy path", async () => {
32
+ const raw = JSON.parse(await readFile("templates/golden-chapter-gates.json", "utf8"));
33
+ const parsed = parseGoldenChapterGates(raw, "templates/golden-chapter-gates.json");
34
+ assert.equal(parsed.schema_version, 1);
35
+ assert.ok((parsed.platforms.fanqie.chapters["1"]?.gates.length ?? 0) > 0);
36
+ assert.ok((parsed.platforms.qidian.chapters["2"]?.gates.length ?? 0) > 0);
37
+ assert.ok((parsed.platforms.jinjiang.chapters["3"]?.gates.length ?? 0) > 0);
38
+ });
39
+ test("parseGoldenChapterGates rejects non-object input", () => {
40
+ assert.throws(() => parseGoldenChapterGates(null, "golden-chapter-gates.json"), /expected a JSON object/i);
41
+ });
42
+ test("parseGoldenChapterGates rejects invalid schema version", () => {
43
+ const raw = makeBaseConfig();
44
+ raw.schema_version = 2;
45
+ assert.throws(() => parseGoldenChapterGates(raw, "golden-chapter-gates.json"), /schema_version.*must be 1/i);
46
+ });
47
+ test("parseGoldenChapterGates rejects missing platform objects", () => {
48
+ const raw = makeBaseConfig();
49
+ delete raw.platforms.jinjiang;
50
+ assert.throws(() => parseGoldenChapterGates(raw, "golden-chapter-gates.json"), /missing 'platforms\.jinjiang'/i);
51
+ });
52
+ test("parseGoldenChapterGates rejects empty chapter gates", () => {
53
+ const raw = makeBaseConfig();
54
+ raw.platforms.fanqie.chapters["1"].gates = [];
55
+ assert.throws(() => parseGoldenChapterGates(raw, "golden-chapter-gates.json"), /chapters\.1\.gates.*non-empty array/i);
56
+ });
57
+ test("selectGoldenChapterGatesForPlatform canonicalizes tomato and returns invalid-combination warnings", () => {
58
+ const raw = makeBaseConfig();
59
+ raw.invalid_combinations = [
60
+ { genre: "litRPG", platform: "fanqie", warning: "fanqie warning" },
61
+ { genre: "xianxia", platform: "qidian", warning: "qidian warning" }
62
+ ];
63
+ const parsed = parseGoldenChapterGates(raw, "golden-chapter-gates.json");
64
+ const selected = selectGoldenChapterGatesForPlatform({
65
+ config: parsed,
66
+ platformId: "tomato",
67
+ chapter: 2
68
+ });
69
+ assert.ok(selected);
70
+ assert.equal(selected?.platform, "fanqie");
71
+ assert.equal(selected?.chapter, 2);
72
+ assert.equal(selected?.current_chapter.gates.length, 1);
73
+ assert.deepEqual(selected?.invalid_combination_warnings, [{ genre: "litRPG", warning: "fanqie warning" }]);
74
+ });
75
+ test("selectGoldenChapterGatesForPlatform returns null for chapters outside 1-3", () => {
76
+ const parsed = parseGoldenChapterGates(makeBaseConfig(), "golden-chapter-gates.json");
77
+ assert.equal(selectGoldenChapterGatesForPlatform({ config: parsed, platformId: "qidian", chapter: 0 }), null);
78
+ assert.equal(selectGoldenChapterGatesForPlatform({ config: parsed, platformId: "qidian", chapter: 4 }), null);
79
+ });