e2e.test.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  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 { promises as fs } from 'node:fs'
  6. import { execFile } from 'node:child_process'
  7. import { promisify } from 'node:util'
  8. import { migrateV6 } from '../../src/migrate/index.js'
  9. import { run as migrateCmd } from '../../src/commands/migrate.js'
  10. import { CacheManager } from '../../src/cache/index.js'
  11. import { EntityReader } from '../../src/storage/adapters/EntityReader.js'
  12. import { determineNextState } from '../../src/state-machine/index.js'
  13. import { loadBooks } from '../../src/session/index.js'
  14. import { tempV6, tempV6Sqlite, inlineFixture } from './_v6.js'
  15. const execFileAsync = promisify(execFile)
  16. async function tempWorkdir() {
  17. const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-workdir-'))
  18. await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true })
  19. return { ctx: { workdir, packageRoot: null }, cleanup: () => fs.rm(workdir, { recursive: true, force: true }) }
  20. }
  21. /** 目录树指纹:相对路径 → 内容长度(源只读断言用)。 */
  22. async function treeFingerprint(root) {
  23. const map = new Map()
  24. async function walk(dir) {
  25. for (const ent of await fs.readdir(dir, { withFileTypes: true })) {
  26. const full = path.join(dir, ent.name)
  27. if (ent.isDirectory()) await walk(full)
  28. else map.set(path.relative(root, full), (await fs.readFile(full)).length)
  29. }
  30. }
  31. await walk(root)
  32. return map
  33. }
  34. async function readTree(root) {
  35. const parts = []
  36. async function walk(dir) {
  37. for (const ent of await fs.readdir(dir, { withFileTypes: true })) {
  38. if (ent.name === '.git' || ent.name === '.cache') continue
  39. const full = path.join(dir, ent.name)
  40. if (ent.isDirectory()) await walk(full)
  41. else parts.push(await fs.readFile(full, 'utf8'))
  42. }
  43. }
  44. await walk(root)
  45. return parts.join('\n')
  46. }
  47. test('AC2 端到端(inline):迁移落位、git 单 commit、缓存可删重建、next 进正常写章流程', async () => {
  48. const { ctx, cleanup } = await tempWorkdir()
  49. const v6 = await tempV6(inlineFixture)
  50. try {
  51. const r = await migrateV6(ctx, v6.v6Path)
  52. assert.equal(r.ok, true, r.error)
  53. const repo = path.join(ctx.workdir, '剑碎虚空')
  54. // 结构合规:正文/摘要/条目/名册/时间线/卷纲/工程件
  55. for (const p of [
  56. '定稿/正文/0001-残剑出鞘.md', '定稿/正文/0002-第2章.md', '定稿/正文/0003-剑灵初醒.md',
  57. '定稿/摘要/章摘要/0001.md', '大纲/伏笔/伏笔-001-残剑剑柄内藏半张古.md',
  58. '定稿/设定/名册.md', '定稿/设定/角色/陆沉.md', '定稿/设定/时间线/第01卷.md',
  59. '大纲/总纲.md', '大纲/卷纲/第01卷.md', 'book.yaml', 'AGENTS.md', '.gitignore',
  60. '工作区/迁移报告.md',
  61. ]) {
  62. await fs.access(path.join(repo, p))
  63. }
  64. // git:提交链压成单个 init commit,工作树净(工作区被 ignore)
  65. const { stdout: cnt } = await execFileAsync('git', ['rev-list', '--count', 'HEAD'], { cwd: repo })
  66. assert.equal(cnt.trim(), '1')
  67. const { stdout: st } = await execFileAsync('git', ['status', '--porcelain'], { cwd: repo })
  68. assert.equal(st.trim(), '')
  69. // 删缓存全量重建一致(不变量 2)
  70. await fs.rm(path.join(repo, '.cache'), { recursive: true, force: true })
  71. const cache = new CacheManager(path.join(repo, '.cache', 'index.db'))
  72. try {
  73. await cache.ensureReady(repo)
  74. const rows = await cache.query('SELECT COUNT(*) AS n FROM chapters', [])
  75. assert.equal(rows[0].n, 3)
  76. // AC7 口径顺检:迁移不产生指纹(体检才产)
  77. assert.equal((await cache.query('SELECT COUNT(*) AS n FROM fingerprints', []))[0].n, 0)
  78. // R5:名册写中文类型(作者域),入缓存归一成英文 machine 值——迁移书的角色不再隐身
  79. const types = (await cache.query('SELECT DISTINCT type FROM entities', [])).map((r) => r.type).sort()
  80. assert.deepEqual(types, ['character', 'organization'], `entities.type 应全为英文机器值:${types}`)
  81. const chars = await new EntityReader(repo, cache).listCharacters()
  82. assert.ok(chars.some((c) => c.id === '陆沉'), `list-characters 应含陆沉:${JSON.stringify(chars)}`)
  83. // next 直接进正常流程:序 6 起草第 4 章
  84. const next = await determineNextState({ repoPath: repo, cache, workdir: ctx.workdir })
  85. assert.equal(next.序, 6, JSON.stringify(next))
  86. assert.equal(next.dto.nextChapter, 4)
  87. } finally {
  88. await cache.close()
  89. }
  90. // books.jsonl 登记 + 当前书
  91. const books = await loadBooks(ctx.workdir)
  92. assert.ok(books.books.some((b) => b.书名 === '剑碎虚空'))
  93. } finally {
  94. await v6.cleanup()
  95. await cleanup()
  96. }
  97. })
  98. test('AC2 端到端(sqlite 形态)+ AC5 报告三节', async () => {
  99. const { ctx, cleanup } = await tempWorkdir()
  100. const v6 = await tempV6Sqlite()
  101. try {
  102. const r = await migrateV6(ctx, v6.v6Path)
  103. assert.equal(r.ok, true, r.error)
  104. const report = await fs.readFile(path.join(ctx.workdir, '潮汐之下', '工作区', '迁移报告.md'), 'utf8')
  105. assert.match(report, /## 迁了什么/)
  106. assert.match(report, /- 章数:2/)
  107. assert.match(report, /## 待校对/)
  108. assert.match(report, /## 如实丢弃/)
  109. assert.match(report, /index\.db 实体已转正文件/)
  110. assert.match(report, /数据形态:state\.json 精简 \+ index\.db/)
  111. } finally {
  112. await v6.cleanup()
  113. await cleanup()
  114. }
  115. })
  116. test('AC3 不丢字:v6 每类文本在 v7 产物或待校对区可寻回', async () => {
  117. const { ctx, cleanup } = await tempWorkdir()
  118. const v6 = await tempV6(inlineFixture)
  119. try {
  120. const r = await migrateV6(ctx, v6.v6Path)
  121. assert.equal(r.ok, true, r.error)
  122. const tree = await readTree(path.join(ctx.workdir, '剑碎虚空'))
  123. for (const text of [
  124. '晨雾未散,陆沉背着那柄锈迹斑斑的残剑走进演武场', // 正文(平坦带标题)
  125. '藏经阁的木梯吱呀作响', // 正文(遗留无标题)
  126. '淤塞多年的气海竟被撕开一道细缝', // 正文(卷内 3 位)
  127. '陆沉携残剑入演武场受三长老盘问', // 章摘要
  128. '残剑剑柄内藏半张古图', // 伏笔 content
  129. '外门大比', // active_threads → 卷纲
  130. '三长老着人盯梢陆沉,尚未收线', // scratchpad open_loops → 待校对
  131. '陆家灭门夜唯一遗物', // scratchpad story_facts → 待校对
  132. '战斗段落短句连用,收在动作定格', // patterns → 文风候选
  133. '剑灵反哺', // state_changes → 实体变更史
  134. '藏经阁初见,苏素予其残卷', // structured_relationships → 角色卡关系
  135. '外门三千弟子,藏经阁七层', // 设定集
  136. '陆沉集齐古图,于虚空裂隙斩落伪天道', // 总纲
  137. '剑灵初醒,破至练气五层', // 时间线事件
  138. ]) {
  139. assert.ok(tree.includes(text), `丢字:找不到「${text}」`)
  140. }
  141. } finally {
  142. await v6.cleanup()
  143. await cleanup()
  144. }
  145. })
  146. test('AC4 回退演练:中途失败工作目录零残留、源 v6 未被改动;残留临时目录会被清扫', async () => {
  147. const { ctx, cleanup } = await tempWorkdir()
  148. const v6 = await tempV6(inlineFixture)
  149. try {
  150. const before = await treeFingerprint(v6.v6Path)
  151. // 预埋一个上次中断的残留临时目录
  152. await fs.mkdir(path.join(ctx.workdir, '.migrate-tmp-99999', '定稿'), { recursive: true })
  153. const r = await migrateV6(ctx, v6.v6Path, { _faultBeforeRename: true })
  154. assert.equal(r.ok, false)
  155. assert.match(r.error, /工作目录已恢复原样/)
  156. const left = await fs.readdir(ctx.workdir)
  157. assert.ok(!left.some((n) => n.startsWith('.migrate-tmp-')), `残留:${left}`)
  158. assert.ok(!left.includes('剑碎虚空'))
  159. assert.deepEqual(await treeFingerprint(v6.v6Path), before) // 源逐文件未动
  160. } finally {
  161. await v6.cleanup()
  162. await cleanup()
  163. }
  164. })
  165. test('目标目录已存在拒绝(--dir 另起名可走);命令壳用法提示', async () => {
  166. const { ctx, cleanup } = await tempWorkdir()
  167. const v6 = await tempV6(inlineFixture)
  168. try {
  169. await fs.mkdir(path.join(ctx.workdir, '剑碎虚空'))
  170. const clash = await migrateV6(ctx, v6.v6Path)
  171. assert.equal(clash.ok, false)
  172. assert.match(clash.error, /已有「剑碎虚空」/)
  173. const alt = await migrateCmd([v6.v6Path], { dir: '剑碎虚空-旧稿' }, ctx)
  174. assert.equal(alt.ok, true, alt.error)
  175. await fs.access(path.join(ctx.workdir, '剑碎虚空-旧稿', 'book.yaml'))
  176. const noArg = await migrateCmd([], {}, ctx)
  177. assert.equal(noArg.ok, false)
  178. assert.match(noArg.error, /用法/)
  179. } finally {
  180. await v6.cleanup()
  181. await cleanup()
  182. }
  183. })