1
0

rebuilder.test.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import path from 'node:path'
  4. import os from 'node:os'
  5. import { access, mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
  6. import { CacheManager } from '../../src/cache/index.js'
  7. // 在临时目录构造一个最小书仓库,files 是 { 相对路径: 内容 }
  8. async function makeRepo(files) {
  9. const root = await mkdtemp(path.join(os.tmpdir(), 'wnw-rebuild-'))
  10. for (const [rel, content] of Object.entries(files)) {
  11. const full = path.join(root, rel)
  12. await mkdir(path.dirname(full), { recursive: true })
  13. await writeFile(full, content, 'utf8')
  14. }
  15. return root
  16. }
  17. const 名册仅林晚 =
  18. '| 正名 | 别名 | 类型 | 首现章 |\n|------|------|------|--------|\n| 林晚 | 晚晚 | character | 1 |\n'
  19. test('角色卡不在名册里也必须入 entities(upsert,不丢数据)', async () => {
  20. const root = await makeRepo({
  21. 'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
  22. '定稿/设定/名册.md': 名册仅林晚,
  23. '定稿/设定/角色/独行客.md':
  24. '---\n姓名: 独行客\n状态: 在世\n位置: 荒原\n境界: 元婴\n最后变更章: 5\n---\n## 设定\n来历不明。\n',
  25. })
  26. const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
  27. try {
  28. await cache.ensureReady(root)
  29. const rows = await cache.query('SELECT * FROM entities WHERE id = ?', ['独行客'])
  30. assert.equal(rows.length, 1, '独行客有角色卡但不在名册,必须 upsert 入 entities,不能丢')
  31. assert.equal(rows[0].status, '在世')
  32. assert.equal(rows[0].realm, '元婴')
  33. } finally {
  34. await cache.close()
  35. await rm(root, { recursive: true, force: true })
  36. }
  37. })
  38. test('名册中的角色被角色卡补全字段(upsert 走 UPDATE 分支)', async () => {
  39. const root = await makeRepo({
  40. 'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
  41. '定稿/设定/名册.md': 名册仅林晚,
  42. '定稿/设定/角色/林晚.md':
  43. '---\n姓名: 林晚\n状态: 在世\n位置: 青云宗\n境界: 练气三层\n最后变更章: 1\n---\n## 设定\n外门弟子。\n',
  44. })
  45. const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
  46. try {
  47. await cache.ensureReady(root)
  48. const rows = await cache.query('SELECT * FROM entities WHERE id = ?', ['林晚'])
  49. assert.equal(rows.length, 1, '林晚不应因 upsert 而重复')
  50. assert.equal(rows[0].realm, '练气三层', '角色卡的境界应补进名册建立的行')
  51. } finally {
  52. await cache.close()
  53. await rm(root, { recursive: true, force: true })
  54. }
  55. })
  56. test('履历引用不存在的章节 → 记 warning,不阻断重建(AC10)', async () => {
  57. const root = await makeRepo({
  58. 'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
  59. '定稿/设定/名册.md': 名册仅林晚,
  60. '定稿/正文/0001-开局.md': '---\n章号: 1\n标题: 开局\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。',
  61. '大纲/伏笔/伏笔-001-test.md':
  62. '---\n强度: 高\n状态: 进行\n开启章: 1\n---\n## 履历\n- 第999章:推进——引用不存在的章\n',
  63. })
  64. const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
  65. try {
  66. const result = await cache.rebuildFromSource(root)
  67. assert.equal(result.ok, true, '履历章节不存在只警告,不阻断')
  68. assert.ok(
  69. result.warnings.some((w) => w.includes('999')),
  70. `应有引用 999 章的 warning,实际:${JSON.stringify(result.warnings)}`
  71. )
  72. } finally {
  73. await cache.close()
  74. await rm(root, { recursive: true, force: true })
  75. }
  76. })
  77. test('别名冲突 → 报 error 并拒绝重建(AC10)', async () => {
  78. const root = await makeRepo({
  79. 'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
  80. // 「影」同时是林晚和神秘老者的别名 → 冲突
  81. '定稿/设定/名册.md':
  82. '| 正名 | 别名 | 类型 | 首现章 |\n|------|------|------|--------|\n' +
  83. '| 林晚 | 影 | character | 1 |\n| 神秘老者 | 影 | character | 1 |\n',
  84. })
  85. const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
  86. try {
  87. const result = await cache.rebuildFromSource(root)
  88. assert.equal(result.ok, false, '别名冲突必须拒绝重建')
  89. assert.ok(
  90. result.errors.some((e) => e.includes('影')),
  91. `应有别名冲突 error,实际:${JSON.stringify(result.errors)}`
  92. )
  93. } finally {
  94. await cache.close()
  95. await rm(root, { recursive: true, force: true })
  96. }
  97. })
  98. // —— 批 2 回归:R5 / R6 / A13 ——
  99. test('R5:名册中文类型入库归一成英文机器值(角色→character、地点→location)', async () => {
  100. const root = await makeRepo({
  101. 'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
  102. '定稿/设定/名册.md':
  103. '| 正名 | 别名 | 类型 | 首现章 |\n|---|---|---|---|\n| 江遥 | 小江 | 角色 | 1 |\n| 滨海市 | | 地点 | 1 |\n| 旧英文 | | character | 1 |\n',
  104. })
  105. const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
  106. try {
  107. await cache.ensureReady(root)
  108. const typeOf = async (id) =>
  109. (await cache.query('SELECT type FROM entities WHERE id = ?', [id]))[0]?.type
  110. assert.equal(await typeOf('江遥'), 'character')
  111. assert.equal(await typeOf('滨海市'), 'location')
  112. assert.equal(await typeOf('旧英文'), 'character', '英文旧值恒等收纳,存量书无损')
  113. } finally {
  114. await cache.close()
  115. await rm(root, { recursive: true, force: true })
  116. }
  117. })
  118. test('R6:全角逗号/顿号分隔的别名全部入 entity_aliases 可解析', async () => {
  119. const root = await makeRepo({
  120. 'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
  121. '定稿/设定/名册.md':
  122. '| 正名 | 别名 | 类型 | 首现章 |\n|---|---|---|---|\n| 林晚 | 阿晚,晚儿、小晚 | 角色 | 1 |\n',
  123. })
  124. const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
  125. try {
  126. await cache.ensureReady(root)
  127. const aliases = (await cache.query('SELECT alias FROM entity_aliases ORDER BY alias', []))
  128. .map((r) => r.alias)
  129. assert.deepEqual(aliases, ['小晚', '晚儿', '阿晚'].sort(), `全角分隔的三个别名都应入库:${aliases}`)
  130. } finally {
  131. await cache.close()
  132. await rm(root, { recursive: true, force: true })
  133. }
  134. })
  135. test('A13:名册缺「别名」列 → 实体照常入库,不炸整次重建', async () => {
  136. const root = await makeRepo({
  137. 'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
  138. '定稿/正文/0001-开局.md': '---\n章号: 1\n标题: 开局\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。',
  139. '定稿/设定/名册.md': '| 正名 | 类型 | 首现章 |\n|---|---|---|\n| 林晚 | 角色 | 1 |\n',
  140. })
  141. const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
  142. try {
  143. const result = await cache.rebuildFromSource(root)
  144. assert.equal(result.ok, true, `缺别名列不应回滚重建:${JSON.stringify(result.errors)}`)
  145. assert.equal((await cache.query('SELECT COUNT(*) AS n FROM entities', []))[0].n, 1)
  146. assert.equal((await cache.query('SELECT COUNT(*) AS n FROM chapters', []))[0].n, 1, '章不应被连坐')
  147. } finally {
  148. await cache.close()
  149. await rm(root, { recursive: true, force: true })
  150. }
  151. })
  152. test('别名冲突 → ROLLBACK 不留半库(P1-1:已写 chapters 也回滚)', async () => {
  153. const root = await makeRepo({
  154. 'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
  155. '定稿/正文/0001-开局.md': '---\n章号: 1\n标题: 开局\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。',
  156. '定稿/正文/0002-承接.md': '---\n章号: 2\n标题: 承接\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。',
  157. // 别名冲突:scanEntities 在 chapters/threads/secrets 已 INSERT 后才返回失败
  158. '定稿/设定/名册.md':
  159. '| 正名 | 别名 | 类型 | 首现章 |\n|------|------|------|--------|\n' +
  160. '| 林晚 | 影 | character | 1 |\n| 老者 | 影 | character | 1 |\n',
  161. })
  162. const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
  163. try {
  164. const result = await cache.rebuildFromSource(root)
  165. assert.equal(result.ok, false, '别名冲突应拒绝重建')
  166. // 事务回滚且失败临时库不应被提升为可用缓存
  167. await assert.rejects(() => cache.query('SELECT COUNT(*) AS c FROM chapters'))
  168. } finally {
  169. await cache.close()
  170. await rm(root, { recursive: true, force: true })
  171. }
  172. })
  173. test('重建失败保留上一份可用缓存,不替换成空库', async () => {
  174. const root = await makeRepo({
  175. 'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
  176. '定稿/正文/0001-开局.md': '---\n章号: 1\n标题: 开局\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。',
  177. '定稿/设定/名册.md': 名册仅林晚,
  178. })
  179. const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
  180. try {
  181. await cache.ensureReady(root)
  182. const before = await cache.query('SELECT COUNT(*) AS c FROM chapters')
  183. assert.equal(before[0].c, 1)
  184. await writeFile(
  185. path.join(root, '定稿/设定/名册.md'),
  186. '| 正名 | 别名 | 类型 | 首现章 |\n|------|------|------|--------|\n' +
  187. '| 林晚 | 影 | character | 1 |\n| 老者 | 影 | character | 1 |\n',
  188. 'utf8'
  189. )
  190. const result = await cache.rebuildFromSource(root)
  191. assert.equal(result.ok, false, '坏源文件应让本次重建失败')
  192. const after = await cache.query('SELECT COUNT(*) AS c FROM chapters')
  193. assert.equal(after[0].c, 1, '失败重建不应把旧缓存替换成空库')
  194. } finally {
  195. await cache.close()
  196. await rm(root, { recursive: true, force: true })
  197. }
  198. })
  199. test('重建失败可选择移除旧缓存,避免继续读取陈旧数据', async () => {
  200. const root = await makeRepo({
  201. 'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
  202. '定稿/正文/0001-开局.md': '---\n章号: 1\n标题: 开局\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。',
  203. '定稿/设定/名册.md': 名册仅林晚,
  204. })
  205. const dbPath = path.join(root, '.cache', 'index.db')
  206. const cache = new CacheManager(dbPath)
  207. try {
  208. await cache.ensureReady(root)
  209. await writeFile(
  210. path.join(root, '定稿/设定/名册.md'),
  211. '| 正名 | 别名 | 类型 | 首现章 |\n|------|------|------|--------|\n' +
  212. '| 林晚 | 影 | character | 1 |\n| 老者 | 影 | character | 1 |\n',
  213. 'utf8'
  214. )
  215. const result = await cache.rebuildFromSource(root, { keepExistingOnFailure: false })
  216. assert.equal(result.ok, false)
  217. await assert.rejects(() => cache.query('SELECT COUNT(*) AS c FROM chapters'))
  218. await assert.rejects(() => access(dbPath))
  219. } finally {
  220. await cache.close()
  221. await rm(root, { recursive: true, force: true })
  222. }
  223. })