router.test.js 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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 { CacheManager } from '../../src/cache/index.js'
  9. import { determineNextState } from '../../src/state-machine/index.js'
  10. import { runHealthCheck } from '../../src/health-check/index.js'
  11. const execFileAsync = promisify(execFile)
  12. // 造 git 书仓库 + 缓存。files = {相对路径: 内容};committed=true 时初始全部提交
  13. async function makeGitBook(files, { commit = true } = {}) {
  14. const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-sm-'))
  15. const git = (a) => execFileAsync('git', a, { cwd: root })
  16. await git(['init', '-q'])
  17. await git(['config', 'user.email', 't@example.com'])
  18. await git(['config', 'user.name', 'test'])
  19. await fs.writeFile(path.join(root, '.gitignore'), '.cache/\n工作区/\n', 'utf8')
  20. for (const [rel, content] of Object.entries(files)) {
  21. const full = path.join(root, rel)
  22. await fs.mkdir(path.dirname(full), { recursive: true })
  23. await fs.writeFile(full, content, 'utf8')
  24. }
  25. if (commit) {
  26. await git(['add', '-A'])
  27. await git(['commit', '-q', '-m', 'init'])
  28. }
  29. const dbDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-sm-db-'))
  30. const cache = new CacheManager(path.join(dbDir, 'index.db'))
  31. await cache.ensureReady(root)
  32. return {
  33. root,
  34. git,
  35. ctx: { repoPath: root, cache },
  36. cleanup: async () => {
  37. await cache.close()
  38. await fs.rm(root, { recursive: true, force: true })
  39. await fs.rm(dbDir, { recursive: true, force: true })
  40. },
  41. }
  42. }
  43. const ch = (n, vol = 1, pos = '推进', 收卷 = false) =>
  44. `---\n章号: ${n}\n标题: 第${n}章\n卷: ${vol}\n字数: 100\n章定位: ${pos}\n钩子: 危机钩-强\n情绪定位: 铺垫${收卷 ? '\n收卷: 是' : ''}\n---\n正文。`
  45. const healthyBook = (extra = {}) => ({
  46. 'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n体检周期: 50\n',
  47. '大纲/总纲.md': '# 总纲\n## 结局\nx',
  48. '定稿/正文/0001-第1章.md': ch(1),
  49. ...extra,
  50. })
  51. test('序1:无 book.yaml → 建书引导', async () => {
  52. const { ctx, cleanup } = await makeGitBook({ '大纲/占位.md': 'x' })
  53. try {
  54. const r = await determineNextState(ctx)
  55. assert.equal(r.序, 1)
  56. assert.equal(r.state, 'create-book')
  57. } finally {
  58. await cleanup()
  59. }
  60. })
  61. test('序6:健康书、无异常 → 起草新章细纲', async () => {
  62. const { ctx, cleanup } = await makeGitBook(healthyBook())
  63. try {
  64. const r = await determineNextState(ctx)
  65. assert.equal(r.序, 6, `实际:${JSON.stringify(r)}`)
  66. assert.equal(r.state, 'draft-outline')
  67. } finally {
  68. await cleanup()
  69. }
  70. })
  71. test('序0:源文件解析失败 → 修复确认', async () => {
  72. const { ctx, cleanup } = await makeGitBook(
  73. healthyBook({ '定稿/正文/0002-坏章.md': '---\n章号: 2\n标题: [未闭合\n卷: : :\n---\n正文' })
  74. )
  75. try {
  76. const r = await determineNextState(ctx)
  77. assert.equal(r.序, 0)
  78. assert.equal(r.state, 'repair-confirm')
  79. } finally {
  80. await cleanup()
  81. }
  82. })
  83. test('序2:定稿有未登记手改 → 提议补登', async () => {
  84. const { ctx, root, cleanup } = await makeGitBook(healthyBook())
  85. try {
  86. // 提交后手改一个已跟踪文件(不提交)
  87. await fs.writeFile(path.join(root, '定稿/正文/0001-第1章.md'), ch(1) + '\n手改了一句。', 'utf8')
  88. const r = await determineNextState(ctx)
  89. assert.equal(r.序, 2)
  90. assert.equal(r.state, 'relink-manual-edits')
  91. } finally {
  92. await cleanup()
  93. }
  94. })
  95. test('序3:工作区有未完成草稿 → 断点续跑', async () => {
  96. const { ctx, root, cleanup } = await makeGitBook(healthyBook())
  97. try {
  98. await fs.mkdir(path.join(root, '工作区'), { recursive: true })
  99. await fs.writeFile(path.join(root, '工作区/草稿-A.md'), '半成品草稿', 'utf8')
  100. const r = await determineNextState(ctx)
  101. assert.equal(r.序, 3)
  102. assert.equal(r.state, 'resume')
  103. } finally {
  104. await cleanup()
  105. }
  106. })
  107. test('序4:最新定稿章声明收卷 → 卷复盘(声明制)', async () => {
  108. const { ctx, cleanup } = await makeGitBook({
  109. 'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n体检周期: 50\n',
  110. '大纲/总纲.md': '# 总纲',
  111. '定稿/正文/0001-第1章.md': ch(1),
  112. '定稿/正文/0002-第2章.md': ch(2, 1, '推进', true),
  113. })
  114. try {
  115. const r = await determineNextState(ctx)
  116. assert.equal(r.序, 4, `实际:${JSON.stringify(r)}`)
  117. assert.equal(r.state, 'volume-review')
  118. assert.equal(r.dto.卷, 1)
  119. } finally {
  120. await cleanup()
  121. }
  122. })
  123. test('序4 反例:章号为卷规模整数倍但未声明收卷 → 不触发卷复盘(旧整除行为消失)', async () => {
  124. const { ctx, cleanup } = await makeGitBook({
  125. 'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 2\n体检周期: 50\n',
  126. '大纲/总纲.md': '# 总纲',
  127. '定稿/正文/0001-第1章.md': ch(1),
  128. '定稿/正文/0002-第2章.md': ch(2),
  129. })
  130. try {
  131. const r = await determineNextState(ctx)
  132. assert.equal(r.序, 6, `实际:${JSON.stringify(r)}`)
  133. } finally {
  134. await cleanup()
  135. }
  136. })
  137. test('序4:收卷但该卷卷摘要已存在(复盘已做)→ 不再触发', async () => {
  138. const { ctx, cleanup } = await makeGitBook({
  139. 'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n体检周期: 50\n',
  140. '大纲/总纲.md': '# 总纲',
  141. '定稿/正文/0001-第1章.md': ch(1, 1, '推进', true),
  142. '定稿/摘要/卷摘要/第01卷.md': '# 第一卷摘要\n收束。',
  143. })
  144. try {
  145. const r = await determineNextState(ctx)
  146. assert.equal(r.序, 6, `实际:${JSON.stringify(r)}`)
  147. } finally {
  148. await cleanup()
  149. }
  150. })
  151. test('序5:距上次体检满周期 → 体检', async () => {
  152. const { ctx, cleanup } = await makeGitBook({
  153. 'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 3\n体检周期: 2\n',
  154. '大纲/总纲.md': '# 总纲',
  155. '定稿/正文/0001-第1章.md': ch(1),
  156. '定稿/正文/0002-第2章.md': ch(2),
  157. })
  158. try {
  159. const r = await determineNextState(ctx)
  160. assert.equal(r.序, 5, `实际:${JSON.stringify(r)}`)
  161. assert.equal(r.state, 'health-check')
  162. } finally {
  163. await cleanup()
  164. }
  165. })
  166. test('序5:体检执行后记录章号,未到下个周期不再触发(体检不卡主循环)', async () => {
  167. const { ctx, cleanup } = await makeGitBook({
  168. 'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n体检周期: 2\n',
  169. '大纲/总纲.md': '# 总纲',
  170. '定稿/正文/0001-第1章.md': ch(1),
  171. '定稿/正文/0002-第2章.md': ch(2),
  172. })
  173. try {
  174. const first = await determineNextState(ctx)
  175. assert.equal(first.序, 5)
  176. const hc = await runHealthCheck(ctx)
  177. assert.equal(hc.ok, true, hc.error)
  178. const second = await determineNextState(ctx)
  179. assert.equal(second.序, 6, `实际:${JSON.stringify(second)}`)
  180. } finally {
  181. await cleanup()
  182. }
  183. })
  184. test('序0:book.yaml 解析失败 → 修复确认(不得静默用默认值)', async () => {
  185. const { ctx, cleanup } = await makeGitBook(
  186. healthyBook({ 'book.yaml': '书名: [未闭合\n卷规模: : :\n' })
  187. )
  188. try {
  189. const r = await determineNextState(ctx)
  190. assert.equal(r.序, 0, `实际:${JSON.stringify(r)}`)
  191. assert.ok(r.dto.failures.some((f) => f.file === 'book.yaml'))
  192. } finally {
  193. await cleanup()
  194. }
  195. })
  196. test('序0:文风铁律 front matter 解析失败 → 修复确认', async () => {
  197. const { ctx, cleanup } = await makeGitBook(
  198. healthyBook({ '文风/文风铁律.md': '---\n禁词: [未闭合\n---\n## 铁律\nx' })
  199. )
  200. try {
  201. const r = await determineNextState(ctx)
  202. assert.equal(r.序, 0)
  203. assert.ok(r.dto.failures.some((f) => f.file === '文风/文风铁律.md'))
  204. } finally {
  205. await cleanup()
  206. }
  207. })
  208. test('序0:名册/时间线表解析失败 → 修复确认', async () => {
  209. const { ctx, cleanup } = await makeGitBook(
  210. healthyBook({
  211. '定稿/设定/名册.md': '## 名册\n没有表格',
  212. '定稿/设定/时间线/第01卷.md': '不是表格的内容',
  213. })
  214. )
  215. try {
  216. const r = await determineNextState(ctx)
  217. assert.equal(r.序, 0)
  218. const files = r.dto.failures.map((f) => f.file)
  219. assert.ok(files.includes('定稿/设定/名册.md'), `实际:${files}`)
  220. assert.ok(files.includes('定稿/设定/时间线/第01卷.md'), `实际:${files}`)
  221. } finally {
  222. await cleanup()
  223. }
  224. })
  225. test('序3:工作区仅存细纲 → 断点续跑,细纲不被改写(AC5)', async () => {
  226. const { ctx, root, cleanup } = await makeGitBook(healthyBook())
  227. try {
  228. const 细纲内容 = '# 第 2 章细纲\n## 本章要写到的事\n- [ ] 已确认的事'
  229. await fs.mkdir(path.join(root, '工作区'), { recursive: true })
  230. await fs.writeFile(path.join(root, '工作区/细纲.md'), 细纲内容, 'utf8')
  231. const r = await determineNextState(ctx)
  232. assert.equal(r.序, 3, `实际:${JSON.stringify(r)}`)
  233. assert.equal(r.state, 'resume')
  234. assert.equal(await fs.readFile(path.join(root, '工作区/细纲.md'), 'utf8'), 细纲内容)
  235. } finally {
  236. await cleanup()
  237. }
  238. })
  239. test('命中即停:手改(序2) + 工作区草稿(序3) 同时存在 → 先报序2', async () => {
  240. const { ctx, root, cleanup } = await makeGitBook(healthyBook())
  241. try {
  242. await fs.writeFile(path.join(root, '定稿/正文/0001-第1章.md'), ch(1) + '\n手改。', 'utf8')
  243. await fs.mkdir(path.join(root, '工作区'), { recursive: true })
  244. await fs.writeFile(path.join(root, '工作区/草稿-A.md'), '草稿', 'utf8')
  245. const r = await determineNextState(ctx)
  246. assert.equal(r.序, 2)
  247. } finally {
  248. await cleanup()
  249. }
  250. })