memory-book.spec.ts 4.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. import { afterAll, describe, expect, it } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as os from 'node:os'
  4. import * as path from 'node:path'
  5. import {
  6. paths,
  7. queryMemory,
  8. rebuildBookMemoryIndex,
  9. writeBookMemory,
  10. writeFileAtomic,
  11. parseDocument,
  12. } from '../src/index'
  13. const roots: string[] = []
  14. function mkDir(prefix: string): string {
  15. const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
  16. roots.push(dir)
  17. return dir
  18. }
  19. afterAll(() => { for (const root of roots) { try { fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放,%TEMP% 残留无害 */ } } })
  20. describe('本书记忆一事一文件(A10)', () => {
  21. it('描述进入条目与索引,后续未提供描述时保留已有值', () => {
  22. const root = mkDir('webnovel-memory-description-')
  23. const input = { 类: '决策' as const, 名称: '拒绝内卫', 来源: '对谈', 裁决记录: '批准', 正文: '不加入内卫。' }
  24. expect(writeBookMemory(root, { ...input, 描述: '主角阵营边界' }).ok).toBe(true)
  25. expect(writeBookMemory(root, { ...input, 正文: '不加入内卫,也不接受招揽。' }).ok).toBe(true)
  26. const doc = parseDocument(fs.readFileSync(path.join(root, '本书记忆/拒绝内卫.md'), 'utf8'))
  27. expect(doc.ok && doc.data.fields['描述']).toBe('主角阵营边界')
  28. expect(fs.readFileSync(path.join(root, '本书记忆/索引.md'), 'utf8')).toContain('主角阵营边界|类:决策')
  29. expect(writeBookMemory(root, { ...input, 描述: '多行\n描述' }).ok).toBe(false)
  30. })
  31. it('写入后条目文件存在、索引含该行;queryMemory 可读', () => {
  32. const root = mkDir('webnovel-membook-')
  33. const r = writeBookMemory(root, {
  34. 类: '文风',
  35. 名称: '克制叙述',
  36. 正文: '短句,少解释性旁白。例:他没有回头。',
  37. 来源: '定稿/卷01/0001-开篇任务.md',
  38. 裁决记录: '作者批准首章沉淀',
  39. })
  40. expect(r.ok).toBe(true)
  41. const entryText = fs.readFileSync(path.join(root, '本书记忆/克制叙述.md'), 'utf-8')
  42. expect(entryText).toContain('短句,少解释性旁白')
  43. expect(entryText).toContain('状态: 已确认')
  44. const indexText = fs.readFileSync(path.join(root, '本书记忆/索引.md'), 'utf-8')
  45. expect(indexText).toContain('[克制叙述]')
  46. expect(indexText).toContain('类:文风')
  47. const q = queryMemory(root, { 类: '文风' })
  48. expect(q.ok).toBe(true)
  49. if (q.ok) {
  50. expect(q.entries.find((entry) => entry.名称 === '克制叙述')?.正文).toContain('他没有回头')
  51. expect(q.entries.find((entry) => entry.名称 === '克制叙述')?.格式).toBeUndefined()
  52. }
  53. })
  54. it('rebuildBookMemoryIndex 幂等:重建两次索引内容不变', () => {
  55. const root = mkDir('webnovel-membook-rebuild-')
  56. writeBookMemory(root, { 类: '决策', 名称: '开篇视角', 正文: '主角限知。', 来源: 'x', 裁决记录: 'y' })
  57. const first = fs.readFileSync(path.join(root, paths.本书记忆索引()), 'utf-8')
  58. rebuildBookMemoryIndex(root)
  59. const second = fs.readFileSync(path.join(root, paths.本书记忆索引()), 'utf-8')
  60. expect(second).toBe(first)
  61. rebuildBookMemoryIndex(root)
  62. expect(fs.readFileSync(path.join(root, paths.本书记忆索引()), 'utf-8')).toBe(first)
  63. })
  64. it('旧仓只有 本书记忆/文风.md 时 queryMemory 仍返回条目并标旧格式', () => {
  65. const root = mkDir('webnovel-membook-old-')
  66. writeFileAtomic(root, paths.记忆('文风'), '# 文风\n\n## 旧条目\n状态:已确认\n### 正文\n旧文风记忆。\n')
  67. const q = queryMemory(root, { 类: '文风' })
  68. expect(q.ok).toBe(true)
  69. if (q.ok) {
  70. expect(q.entries.find((entry) => entry.名称 === '旧条目')?.正文).toContain('旧文风记忆')
  71. expect(q.entries.find((entry) => entry.名称 === '旧条目')?.格式).toBe('旧')
  72. }
  73. })
  74. it('条目名为「索引」或为空被拒', () => {
  75. const root = mkDir('webnovel-membook-guard-')
  76. expect(writeBookMemory(root, { 类: '文风', 名称: '索引', 正文: 'x', 来源: 'x', 裁决记录: 'x' }).ok).toBe(false)
  77. expect(writeBookMemory(root, { 类: '文风', 名称: ' ', 正文: 'x', 来源: 'x', 裁决记录: 'x' }).ok).toBe(false)
  78. })
  79. })