transform.test.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { readV6Project } from '../../src/migrate/read-v6.js'
  4. import { transformV6 } from '../../src/migrate/transform.js'
  5. import { parseFrontMatter } from '../../src/storage/parsers/front-matter.js'
  6. import { tempV6, tempV6Sqlite, inlineFixture } from './_v6.js'
  7. function fileOf(plan, p) {
  8. const f = plan.files.find((x) => x.path === p)
  9. assert.ok(f, `缺文件 ${p};实有:\n${plan.files.map((x) => x.path).join('\n')}`)
  10. return f.content
  11. }
  12. async function inlinePlan() {
  13. const { v6Path, cleanup } = await tempV6(inlineFixture)
  14. try {
  15. const r = await readV6Project(v6Path)
  16. assert.equal(r.ok, true, r.error)
  17. return transformV6(r.facts)
  18. } finally {
  19. await cleanup()
  20. }
  21. }
  22. test('inline → 正文补 front matter:迁移标记、钩子映射、卷号推断、标题兜底', async () => {
  23. const plan = await inlinePlan()
  24. const ch1 = parseFrontMatter(fileOf(plan, '定稿/正文/0001-残剑出鞘.md'))
  25. assert.equal(ch1.ok, true, ch1.error)
  26. assert.equal(ch1.data.章号, 1)
  27. assert.equal(ch1.data.卷, 1)
  28. assert.equal(ch1.data.章定位, '迁移')
  29. assert.equal(ch1.data.钩子, '危机钩-强') // chapter_meta strong→强
  30. assert.deepEqual(ch1.data.本章要写到的事, ['迁移'])
  31. assert.ok(ch1.data.字数 > 0)
  32. assert.equal(ch1.data.书内时间, undefined) // 无源省略,不编造
  33. assert.match(ch1.body, /晨雾未散/)
  34. const ch2 = parseFrontMatter(fileOf(plan, '定稿/正文/0002-第2章.md'))
  35. assert.equal(ch2.data.标题, '第2章') // 遗留无标题兜底
  36. assert.equal(ch2.data.钩子, undefined) // 无 hook 数据省略
  37. const ch3 = parseFrontMatter(fileOf(plan, '定稿/正文/0003-剑灵初醒.md'))
  38. assert.equal(ch3.data.钩子, '悬念钩-中')
  39. })
  40. test('inline → 伏笔条目/名册/角色卡/时间线/摘要/卷纲', async () => {
  41. const plan = await inlinePlan()
  42. const fb1 = parseFrontMatter(fileOf(plan, '大纲/伏笔/伏笔-001-残剑剑柄内藏半张古.md'))
  43. assert.equal(fb1.data.状态, '进行')
  44. assert.equal(fb1.data.强度, '高') // core→核心→高
  45. assert.equal(fb1.data.开启章, 1)
  46. assert.equal(fb1.data.预计收尾, '第30章')
  47. assert.match(fb1.body, /## 描述\n残剑剑柄内藏半张古图/)
  48. assert.match(fb1.body, /## 履历\n- 第1章:埋下(迁移)/)
  49. const fb2 = parseFrontMatter(fileOf(plan, '大纲/伏笔/伏笔-002-苏素识破陆沉伪装的.md'))
  50. assert.equal(fb2.data.状态, '已收尾')
  51. assert.equal(fb2.data.最后推进章, 3)
  52. const roster = fileOf(plan, '定稿/设定/名册.md')
  53. assert.match(roster, /\| 陆沉 \| 小师弟 \| 角色 \| 1 \|/)
  54. assert.match(roster, /\| 青云宗 \| 宗门 \| 势力 \| 1 \|/)
  55. const card = fileOf(plan, '定稿/设定/角色/陆沉.md')
  56. const cardFm = parseFrontMatter(card)
  57. assert.equal(cardFm.data.姓名, '陆沉')
  58. assert.match(card, /## 设定\n/)
  59. assert.match(card, /背负残剑的外门弟子/)
  60. assert.match(card, /## 关系\n[\s\S]*师姐弟/)
  61. const tl = fileOf(plan, '定稿/设定/时间线/第01卷.md')
  62. assert.match(tl, /\| 章 \| 书内时间 \| 一句话事件 \| 在场 \|/)
  63. assert.match(tl, /\| 1 \| 春末清晨 \| 演武场盘问,残剑现世 \|/)
  64. assert.match(fileOf(plan, '定稿/摘要/章摘要/0001.md'), /^陆沉携残剑入演武场/)
  65. const vol = fileOf(plan, '大纲/卷纲/第01卷.md')
  66. assert.match(vol, /第3章:剑灵初醒/)
  67. assert.match(vol, /## 迁移的剧情线\n[\s\S]*外门大比/)
  68. assert.match(fileOf(plan, '大纲/总纲.md'), /剑碎虚空 总纲/)
  69. })
  70. test('inline → book.yaml、待校对三件、报告', async () => {
  71. const plan = await inlinePlan()
  72. assert.equal(plan.bookName, '剑碎虚空')
  73. const yaml = fileOf(plan, 'book.yaml')
  74. assert.match(yaml, /书名: 剑碎虚空/)
  75. assert.match(yaml, /类型: 仙侠/) // xianxia 码表映射
  76. const mem = fileOf(plan, '定稿/设定/迁移待校对-记忆清单.md')
  77. assert.match(mem, /## open_loops[\s\S]*三长老着人盯梢/)
  78. assert.match(mem, /## story_facts[\s\S]*陆家灭门夜唯一遗物/)
  79. assert.match(fileOf(plan, '文风/迁移待校对-文风候选.md'), /战斗段落短句连用/)
  80. assert.match(fileOf(plan, '定稿/设定/迁移待校对-实体变更史.md'), /剑灵反哺/)
  81. assert.equal(plan.report.counts.章数, 3)
  82. assert.equal(plan.report.counts.伏笔, 2)
  83. assert.equal(plan.report.counts.角色卡, 2)
  84. assert.ok(plan.report.待校对.length >= 3)
  85. })
  86. test('sqlite → db 实体/摘要/追读力进产物;genre 未知码原样并提示', async () => {
  87. const { v6Path, cleanup } = await tempV6Sqlite()
  88. try {
  89. const r = await readV6Project(v6Path)
  90. assert.equal(r.ok, true, r.error)
  91. const plan = transformV6(r.facts)
  92. assert.match(fileOf(plan, 'book.yaml'), /类型: 都市/) // urban 码表
  93. const ch1 = parseFrontMatter(fileOf(plan, '定稿/正文/0001-退潮.md'))
  94. assert.equal(ch1.data.钩子, '悬念钩-强') // readingPower 兜底
  95. assert.match(fileOf(plan, '定稿/摘要/章摘要/0001.md'), /退潮滩涂拾得停摆怀表/) // db summary 兜底
  96. assert.match(fileOf(plan, '定稿/设定/角色/江遥.md'), /滨海市海事记者/)
  97. const roster = fileOf(plan, '定稿/设定/名册.md')
  98. assert.match(roster, /\| 江遥 \| 小江 \| 角色 \| 1 \|/)
  99. assert.match(roster, /\| 滨海市 \| \| 地点 \| 1 \|/)
  100. } finally {
  101. await cleanup()
  102. }
  103. })
  104. // —— 批 2 回归:R9 / R10 / F-4(transformV6 纯函数,用最小 facts 直测)——
  105. import { parseBookConfig } from '../../src/storage/parsers/book-config.js'
  106. function minimalFacts(over = {}) {
  107. return {
  108. project: { title: '测', genre: '玄幻' },
  109. chapters: [], summaries: new Map(), dbSummaries: new Map(),
  110. foreshadowing: [], entities: [], relationships: [],
  111. protagonistState: {}, outlines: { master: '', volumes: [] },
  112. settingFiles: [], scratchpad: {}, patterns: [], stateChanges: [],
  113. chaseDebtCount: 0, warnings: [], activeThreads: [],
  114. readingPower: new Map(), chapterMeta: new Map(), form: 'inline',
  115. ...over,
  116. }
  117. }
  118. test('R9:危险书名走防呆序列化器——[ * 起首与纯数字书名都能读回', () => {
  119. for (const title of ['[快穿]反派', '*追读', '"引号"书', '- 开局', '12345', 'true']) {
  120. const plan = transformV6(minimalFacts({ project: { title, genre: '玄幻' } }))
  121. const cfg = parseBookConfig(fileOf(plan, 'book.yaml'))
  122. assert.equal(cfg.ok, true, `书名「${title}」的 book.yaml 解析失败:${cfg.error}`)
  123. assert.equal(cfg.data.书名, title, `书名「${title}」往返漂移成 ${JSON.stringify(cfg.data.书名)}`)
  124. }
  125. })
  126. test('R10:一对多别名降级——首实体保留、其余剥离进待校对,迁移不再硬失败', () => {
  127. const plan = transformV6(minimalFacts({
  128. entities: [
  129. { id: 'a', name: '张三', type: '角色', aliases: ['小明', '老张'], current: {}, firstAppearance: 1 },
  130. { id: 'b', name: '李四', type: '角色', aliases: ['小明'], current: {}, firstAppearance: 2 },
  131. ],
  132. }))
  133. const roster = fileOf(plan, '定稿/设定/名册.md')
  134. assert.match(roster, /\| 张三 \| 小明, 老张 \| 角色 \| 1 \|/)
  135. assert.match(roster, /\| 李四 \| \| 角色 \| 2 \|/)
  136. const 歧义 = fileOf(plan, '定稿/设定/迁移待校对-别名歧义.md')
  137. assert.match(歧义, /「小明」保留给【张三】,已从【李四】剥离/)
  138. assert.ok(plan.report.待校对.some((s) => s.includes('别名歧义')), JSON.stringify(plan.report.待校对))
  139. })
  140. test('R10:同实体重复别名静默去重,不进歧义清单', () => {
  141. const plan = transformV6(minimalFacts({
  142. entities: [{ id: 'a', name: '张三', type: '角色', aliases: ['小明', '小明'], current: {}, firstAppearance: 1 }],
  143. }))
  144. const roster = fileOf(plan, '定稿/设定/名册.md')
  145. assert.match(roster, /\| 张三 \| 小明 \| 角色 \| 1 \|/)
  146. assert.ok(!plan.files.some((f) => f.path.includes('别名歧义')))
  147. })
  148. test('F-4:伏笔短题按码点截断,文件名不含半个代理对', () => {
  149. const plan = transformV6(minimalFacts({
  150. foreshadowing: [{ content: '💠🀄𝌆一二三四五六七八九十', tier: '核心', status: '未回收', plantedChapter: 1 }],
  151. }))
  152. const fb = plan.files.find((f) => f.path.startsWith('大纲/伏笔/伏笔-001'))
  153. assert.ok(fb, '伏笔文件应存在')
  154. const lone = fb.path.match(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/)
  155. assert.equal(lone, null, `文件名含孤代理:${JSON.stringify(fb.path)}`)
  156. })