1
0

persist.test.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import os from 'node:os'
  4. import path from 'node:path'
  5. import { promises as fs } from 'node:fs'
  6. import { execFile } from 'node:child_process'
  7. import { promisify } from 'node:util'
  8. import {
  9. persistRepair,
  10. persistCreateBook,
  11. persistVolumeReview,
  12. persistDraftOutline,
  13. } from '../../src/state-machine/persist.js'
  14. import { makeGitBook } from './_helper.js'
  15. async function tmpRepo() {
  16. const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-persist-'))
  17. return { ctx: { repoPath: root }, root, cleanup: () => fs.rm(root, { recursive: true, force: true }) }
  18. }
  19. const read = (root, rel) => fs.readFile(path.join(root, rel), 'utf8')
  20. const exec = promisify(execFile)
  21. test('persistCreateBook(P0-2)→ git init + core.quotepath=false + .gitignore(书仓库工程化)', async () => {
  22. const { ctx, root, cleanup } = await tmpRepo()
  23. try {
  24. const r = await persistCreateBook(ctx, {
  25. book: { spec_version: '7.0', 书名: '测' },
  26. 总纲: '# 总纲',
  27. 卷纲: '# 第1卷',
  28. })
  29. assert.equal(r.ok, true, r.error)
  30. const { stdout: inside } = await exec('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root })
  31. assert.equal(inside.trim(), 'true', '建书应 git init 出一个仓库')
  32. const { stdout: qp } = await exec('git', ['config', 'core.quotepath'], { cwd: root })
  33. assert.equal(qp.trim(), 'false', '应设 core.quotepath=false(spec quality §3.3)')
  34. const gi = await read(root, '.gitignore')
  35. assert.ok(gi.includes('.cache/') && gi.includes('工作区/'), '.gitignore 应 ignore .cache 与 工作区')
  36. } finally { await cleanup() }
  37. })
  38. test('persistCreateBook(P0-2)→ 已有 .gitignore 追加不覆盖', async () => {
  39. const { ctx, root, cleanup } = await tmpRepo()
  40. try {
  41. await fs.writeFile(path.join(root, '.gitignore'), 'node_modules/\n', 'utf8')
  42. const r = await persistCreateBook(ctx, { book: { 书名: '测' }, 总纲: '# 总纲', 卷纲: '# 第1卷' })
  43. assert.equal(r.ok, true, r.error)
  44. const gi = await read(root, '.gitignore')
  45. assert.ok(gi.includes('node_modules/'), '不应覆盖既有条目')
  46. assert.ok(gi.includes('.cache/') && gi.includes('工作区/'))
  47. } finally { await cleanup() }
  48. })
  49. test('persistDraftOutline(序6)→ 写 工作区/细纲.md', async () => {
  50. const { ctx, root, cleanup } = await tmpRepo()
  51. try {
  52. const r = await persistDraftOutline(ctx, { 细纲: '## 本章要写到的事\n林晚突破。\n' })
  53. assert.equal(r.ok, true)
  54. assert.match(await read(root, '工作区/细纲.md'), /林晚突破/)
  55. } finally { await cleanup() }
  56. })
  57. test('persistCreateBook(序1)→ 写 book.yaml + 总纲 + 第一卷卷纲', async () => {
  58. const { ctx, root, cleanup } = await tmpRepo()
  59. try {
  60. const r = await persistCreateBook(ctx, {
  61. book: { spec_version: '7.0', 书名: '剑起青云', 题材: '仙侠' },
  62. 总纲: '# 总纲\n主角逆袭。',
  63. 卷纲: '# 第1卷\n入门。',
  64. })
  65. assert.equal(r.ok, true)
  66. assert.match(await read(root, 'book.yaml'), /剑起青云/)
  67. assert.match(await read(root, '大纲/总纲.md'), /逆袭/)
  68. assert.match(await read(root, '大纲/卷纲/第01卷.md'), /入门/)
  69. } finally { await cleanup() }
  70. })
  71. test('persistVolumeReview(序4)→ 写卷摘要 + 下卷卷纲 + vol commit(P1-1)', async () => {
  72. const { ctx, root, git, cleanup } = await makeGitBook({ 'book.yaml': 'spec_version: "7.0"\n书名: 测\n' })
  73. try {
  74. const r = await persistVolumeReview(ctx, { 卷号: 1, 卷摘要: '第一卷收束。', 下卷卷纲: '# 第2卷\n新地图。' })
  75. assert.equal(r.ok, true, r.error)
  76. assert.match(await read(root, '定稿/摘要/卷摘要/第01卷.md'), /收束/)
  77. assert.match(await read(root, '大纲/卷纲/第02卷.md'), /新地图/)
  78. const { stdout } = await git(['log', '-1', '--format=%s'])
  79. assert.match(stdout.trim(), /^vol\(01\): /, '复盘产物应随手 commit(否则 next 误触序2)')
  80. } finally { await cleanup() }
  81. })
  82. test('persistRepair(序0)→ 仅写失败清单内的文件,内容须能解析', async () => {
  83. const { ctx, root, cleanup } = await tmpRepo()
  84. try {
  85. const target = '定稿/正文/0001-起.md'
  86. await fs.mkdir(path.join(root, '定稿/正文'), { recursive: true })
  87. await fs.writeFile(path.join(root, target), '---\n坏: yaml: :\n---\n正文', 'utf8')
  88. const good = '---\n章号: 1\n标题: 起\n---\n正文'
  89. const r = await persistRepair(ctx, { repairs: [{ file: target, content: good }] }, { allowedFiles: [target] })
  90. assert.equal(r.ok, true)
  91. assert.deepEqual(r.written, [target])
  92. assert.match(await read(root, target), /章号: 1/)
  93. } finally { await cleanup() }
  94. })
  95. test('persistRepair:拒绝写不在失败清单内的文件(安全网,防 AI 任意写)', async () => {
  96. const { ctx, root, cleanup } = await tmpRepo()
  97. try {
  98. const r = await persistRepair(
  99. ctx,
  100. { repairs: [{ file: '定稿/正文/9999-注入.md', content: '---\n章号: 9\n---\nx' }] },
  101. { allowedFiles: ['定稿/正文/0001-起.md'] }
  102. )
  103. assert.equal(r.ok, false)
  104. await assert.rejects(() => read(root, '定稿/正文/9999-注入.md'))
  105. } finally { await cleanup() }
  106. })
  107. test('persistRepair:修复内容仍解析失败 → ok=false 不写', async () => {
  108. const { ctx, root, cleanup } = await tmpRepo()
  109. try {
  110. const target = '定稿/正文/0001-起.md'
  111. const r = await persistRepair(
  112. ctx,
  113. { repairs: [{ file: target, content: '---\n仍坏: : :\n---\nx' }] },
  114. { allowedFiles: [target] }
  115. )
  116. assert.equal(r.ok, false)
  117. } finally { await cleanup() }
  118. })