Ver Fonte

fix(v7): M1 重建 R0——缓存卫生 + scanCharacters 丢数据 bug + rebuilder 测试

- scanCharacters 由 UPDATE-only 改 upsert(INSERT ON CONFLICT):角色卡不在
  名册里时不再丢数据;在名册里则补全字段并把 file_path 指向角色卡
- rebuildFromSource 自建 .cache 父目录:可被直接调用而不必先过 ensureReady
- 新增 test/cache/rebuilder.test.js(计划 C4.3 一直缺):upsert、AC10 履历
  warning、AC10 别名冲突 error,TDD 先红后绿
- v7/.gitignore 忽略 .cache/(AC1:派生缓存任何时刻可重建,不入库)
- 删冗余 test/chinese-path.test.js(只测 Node 的 fs,没碰我们代码);
  集成 chinese-path 去掉 win32-only skip → 双平台都过我们的全栈
lingfengQAQ há 2 dias atrás
pai
commit
461b596fbc

+ 5 - 0
v7/.gitignore

@@ -0,0 +1,5 @@
+# 派生缓存:AC1 保证任何时刻可删可全量重建,绝不入库
+.cache/
+
+# 依赖
+node_modules/

+ 3 - 0
v7/src/cache/index.js

@@ -67,6 +67,9 @@ export class CacheManager {
       // 文件不存在,忽略
     }
 
+    // 确保 .cache 目录存在:rebuildFromSource 可被直接调用,不保证先过 ensureReady
+    await fs.mkdir(path.dirname(this.dbPath), { recursive: true })
+
     // 创建新数据库
     this.db = new DatabaseSync(this.dbPath)
 

+ 15 - 5
v7/src/cache/rebuilder.js

@@ -260,9 +260,18 @@ async function scanEntities(repoPath, db) {
 async function scanCharacters(repoPath, db) {
   const charDir = path.join(repoPath, '定稿', '设定', '角色')
 
-  const updateStmt = db.prepare(`
-    UPDATE entities SET status = ?, location = ?, realm = ?, possessions = ?, last_changed_chapter = ?
-    WHERE id = ?
+  // upsert:角色卡可能不在名册里(名册非强制全覆盖),只 UPDATE 会丢这些角色。
+  // 在名册里则补全字段并把 file_path 指向更详细的角色卡。
+  const upsertStmt = db.prepare(`
+    INSERT INTO entities (id, type, status, location, realm, possessions, last_changed_chapter, file_path)
+    VALUES (?, 'character', ?, ?, ?, ?, ?, ?)
+    ON CONFLICT(id) DO UPDATE SET
+      status = excluded.status,
+      location = excluded.location,
+      realm = excluded.realm,
+      possessions = excluded.possessions,
+      last_changed_chapter = excluded.last_changed_chapter,
+      file_path = excluded.file_path
   `)
 
   try {
@@ -278,13 +287,14 @@ async function scanCharacters(repoPath, db) {
 
       if (parsed.ok) {
         const fm = parsed.data
-        updateStmt.run(
+        upsertStmt.run(
+          name,
           fm.状态 || null,
           fm.位置 || null,
           fm.境界 || null,
           JSON.stringify(fm.持有 || []),
           fm.最后变更章 || 1,
-          name
+          filePath
         )
       }
     }

+ 103 - 0
v7/test/cache/rebuilder.test.js

@@ -0,0 +1,103 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import os from 'node:os'
+import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
+import { CacheManager } from '../../src/cache/index.js'
+
+// 在临时目录构造一个最小书仓库,files 是 { 相对路径: 内容 }
+async function makeRepo(files) {
+  const root = await mkdtemp(path.join(os.tmpdir(), 'wnw-rebuild-'))
+  for (const [rel, content] of Object.entries(files)) {
+    const full = path.join(root, rel)
+    await mkdir(path.dirname(full), { recursive: true })
+    await writeFile(full, content, 'utf8')
+  }
+  return root
+}
+
+const 名册仅林晚 =
+  '| 正名 | 别名 | 类型 | 首现章 |\n|------|------|------|--------|\n| 林晚 | 晚晚 | character | 1 |\n'
+
+test('角色卡不在名册里也必须入 entities(upsert,不丢数据)', async () => {
+  const root = await makeRepo({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
+    '定稿/设定/名册.md': 名册仅林晚,
+    '定稿/设定/角色/独行客.md':
+      '---\n姓名: 独行客\n状态: 在世\n位置: 荒原\n境界: 元婴\n最后变更章: 5\n---\n## 设定\n来历不明。\n',
+  })
+  const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
+  try {
+    await cache.ensureReady(root)
+    const rows = await cache.query('SELECT * FROM entities WHERE id = ?', ['独行客'])
+    assert.equal(rows.length, 1, '独行客有角色卡但不在名册,必须 upsert 入 entities,不能丢')
+    assert.equal(rows[0].status, '在世')
+    assert.equal(rows[0].realm, '元婴')
+  } finally {
+    await cache.close()
+    await rm(root, { recursive: true, force: true })
+  }
+})
+
+test('名册中的角色被角色卡补全字段(upsert 走 UPDATE 分支)', async () => {
+  const root = await makeRepo({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
+    '定稿/设定/名册.md': 名册仅林晚,
+    '定稿/设定/角色/林晚.md':
+      '---\n姓名: 林晚\n状态: 在世\n位置: 青云宗\n境界: 练气三层\n最后变更章: 1\n---\n## 设定\n外门弟子。\n',
+  })
+  const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
+  try {
+    await cache.ensureReady(root)
+    const rows = await cache.query('SELECT * FROM entities WHERE id = ?', ['林晚'])
+    assert.equal(rows.length, 1, '林晚不应因 upsert 而重复')
+    assert.equal(rows[0].realm, '练气三层', '角色卡的境界应补进名册建立的行')
+  } finally {
+    await cache.close()
+    await rm(root, { recursive: true, force: true })
+  }
+})
+
+test('履历引用不存在的章节 → 记 warning,不阻断重建(AC10)', async () => {
+  const root = await makeRepo({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
+    '定稿/设定/名册.md': 名册仅林晚,
+    '定稿/正文/0001-开局.md': '---\n章号: 1\n标题: 开局\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。',
+    '大纲/伏笔/伏笔-001-test.md':
+      '---\n强度: 高\n状态: 进行\n开启章: 1\n---\n## 履历\n- 第999章:推进——引用不存在的章\n',
+  })
+  const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
+  try {
+    const result = await cache.rebuildFromSource(root)
+    assert.equal(result.ok, true, '履历章节不存在只警告,不阻断')
+    assert.ok(
+      result.warnings.some((w) => w.includes('999')),
+      `应有引用 999 章的 warning,实际:${JSON.stringify(result.warnings)}`
+    )
+  } finally {
+    await cache.close()
+    await rm(root, { recursive: true, force: true })
+  }
+})
+
+test('别名冲突 → 报 error 并拒绝重建(AC10)', async () => {
+  const root = await makeRepo({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
+    // 「影」同时是林晚和神秘老者的别名 → 冲突
+    '定稿/设定/名册.md':
+      '| 正名 | 别名 | 类型 | 首现章 |\n|------|------|------|--------|\n' +
+      '| 林晚 | 影 | character | 1 |\n| 神秘老者 | 影 | character | 1 |\n',
+  })
+  const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
+  try {
+    const result = await cache.rebuildFromSource(root)
+    assert.equal(result.ok, false, '别名冲突必须拒绝重建')
+    assert.ok(
+      result.errors.some((e) => e.includes('影')),
+      `应有别名冲突 error,实际:${JSON.stringify(result.errors)}`
+    )
+  } finally {
+    await cache.close()
+    await rm(root, { recursive: true, force: true })
+  }
+})

+ 0 - 22
v7/test/chinese-path.test.js

@@ -1,22 +0,0 @@
-import { test } from 'node:test'
-import assert from 'node:assert/strict'
-import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-
-// 守护不变量:中文目录名、中文文件名、中文内容在任何平台都必须 UTF-8 正确往返,
-// 不依赖系统 locale(Windows 中文环境是一等公民,story-repo-spec §2.2)。
-test('中文路径与中文内容 UTF-8 往返一致', async () => {
-  const base = await mkdtemp(join(tmpdir(), 'wnw-'))
-  try {
-    const dir = join(base, '测试书-第05卷')
-    await mkdir(dir, { recursive: true })
-    const file = join(dir, '伏笔-031-灭门真凶.md')
-    const content = '# 北境的雪\n林晚于北境得血书,玄阶令牌现世。\n'
-    await writeFile(file, content, { encoding: 'utf8' })
-    const readBack = await readFile(file, { encoding: 'utf8' })
-    assert.equal(readBack, content)
-  } finally {
-    await rm(base, { recursive: true, force: true })
-  }
-})

+ 4 - 6
v7/test/integration/chinese-path.test.js

@@ -6,12 +6,10 @@ import os from 'node:os'
 import { CacheManager } from '../../src/cache/index.js'
 import { ChapterReader } from '../../src/storage/adapters/ChapterReader.js'
 
-test('Windows 中文路径全链路', async (t) => {
-  if (process.platform !== 'win32') {
-    t.skip('Windows 专用测试')
-    return
-  }
-
+// 守护不变量:中文目录名/文件名/中文内容在任何平台都必须 UTF-8 正确往返过我们的全栈
+// (重建缓存 + 读取)。Windows 是一等公民(story-repo-spec §2.2),但 Linux CI 也跑,
+// 双平台都覆盖,不依赖系统 locale。
+test('中文路径全链路(重建缓存 + 读取,跨平台)', async (t) => {
   const tmpDir = path.join(os.tmpdir(), '测试书仓库', `test-${Date.now()}`)
 
   try {