Explorar o código

feat(v7): M2 P0——6 Writer 端口真实现 + 表格序列化

定稿写入地基,全部 TDD(先红后绿):
- ChapterWriter.writeChapter:定稿/正文/NNNN-标题.md(防呆 front matter + 正文)
- ThreadLedgerWriter:updateThread(合并 fm 保留正文)+ appendHistory(## 履历 段尾追加)
- EntityWriter:updateCharacter(合并 fm)+ upsertRosterRow(名册表 upsert)
- TimelineWriter.appendRow:卷时间线追加行(文件不存在建表头)
- SecretWriter.write / SummaryWriter.writeChapterSummary:写信息差 / 章摘要
- 新增 serializers/markdown-table.js(与 parser 配对)+ util appendUnderSection
- 删占位 Writer.test.js,6 端口各有镜像测试(临时仓库验证)
lingfengQAQ hai 1 día
pai
achega
b97d73402e

+ 22 - 11
v7/src/storage/adapters/ChapterWriter.js

@@ -1,5 +1,9 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { serializeFrontMatter } from '../serializers/front-matter.js'
+
 /**
- * ChapterWriter:写新章到定稿(M2 落地,本任务只定接口占位)。
+ * ChapterWriter:写新章到定稿(M2 定稿流程调用)。
  */
 export class ChapterWriter {
   constructor(repoPath, cache = null) {
@@ -8,23 +12,30 @@ export class ChapterWriter {
   }
 
   /**
-   * 写新章到定稿目录
+   * 写新章到 定稿/正文/NNNN-标题.md(front matter 走防呆序列化)
    * @param {number} chapterNum
-   * @param {object} frontMatter
-   * @param {string} body
-   * @returns {Promise<{ok: boolean, error: string}>}
+   * @param {object} frontMatter - 章档案(章号/标题/卷/视角/字数/章定位/钩子/情绪定位/伏笔[]/...)
+   * @param {string} body - 正文(不含 front matter)
+   * @returns {Promise<{ok: boolean, filePath: string, error: string}>}
    */
   async writeChapter(chapterNum, frontMatter, body) {
-    throw new Error('ChapterWriter.writeChapter() 将在 M2 定稿流程中实现')
+    try {
+      const title = frontMatter.标题 || '未命名'
+      const fileName = `${String(chapterNum).padStart(4, '0')}-${title}.md`
+      const dir = path.join(this.repoPath, '定稿', '正文')
+      await fs.mkdir(dir, { recursive: true })
+      const filePath = path.join(dir, fileName)
+      await fs.writeFile(filePath, serializeFrontMatter(frontMatter, body), 'utf8')
+      return { ok: true, filePath, error: '' }
+    } catch (err) {
+      return { ok: false, filePath: '', error: `写章节 ${chapterNum} 失败:${err.message}` }
+    }
   }
 
   /**
-   * 更新章节 front matter。
-   * @param {number} chapterNum
-   * @param {object} updates
-   * @returns {Promise<{ok: boolean, error: string}>}
+   * 更新已有章节 front matter(M2 写新章流程不需要,按需补)。
    */
   async updateFrontMatter(chapterNum, updates) {
-    throw new Error('ChapterWriter.updateFrontMatter() 将在 M2 定稿流程中实现')
+    throw new Error('ChapterWriter.updateFrontMatter() 暂未实现(M2 写新章不依赖;按需补)')
   }
 }

+ 69 - 0
v7/src/storage/adapters/EntityWriter.js

@@ -0,0 +1,69 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseFrontMatter } from '../parsers/front-matter.js'
+import { serializeFrontMatter } from '../serializers/front-matter.js'
+import { parseMarkdownTable } from '../parsers/markdown-table.js'
+import { serializeMarkdownTable } from '../serializers/markdown-table.js'
+
+const ROSTER_HEADERS = ['正名', '别名', '类型', '首现章']
+
+/**
+ * EntityWriter:更新角色卡 front matter、upsert 名册行(M2 定稿流程调用)。
+ */
+export class EntityWriter {
+  constructor(repoPath, cache = null) {
+    this.repoPath = repoPath
+    this.cache = cache
+  }
+
+  /**
+   * 更新角色卡 front matter(合并 updates,保留正文)。
+   * @param {string} name 正名
+   * @param {object} updates 位置/状态/境界/持有/最后变更章 等
+   * @returns {Promise<{ok: boolean, error: string}>}
+   */
+  async updateCharacter(name, updates) {
+    const filePath = path.join(this.repoPath, '定稿', '设定', '角色', `${name}.md`)
+    try {
+      const parsed = parseFrontMatter(await fs.readFile(filePath, 'utf8'))
+      if (!parsed.ok) return { ok: false, error: `角色 ${name} 解析失败:${parsed.error}` }
+      const merged = { ...parsed.data, ...updates }
+      await fs.writeFile(filePath, serializeFrontMatter(merged, parsed.body), 'utf8')
+      return { ok: true, error: '' }
+    } catch (err) {
+      return { ok: false, error: `角色 ${name} 不存在或写入失败:${err.message}` }
+    }
+  }
+
+  /**
+   * upsert 名册行(按正名查重;不存在则追加,存在则替换)。
+   * @param {object} row { 正名, 别名, 类型, 首现章 }
+   * @returns {Promise<{ok: boolean, error: string}>}
+   */
+  async upsertRosterRow(row) {
+    try {
+      const filePath = path.join(this.repoPath, '定稿', '设定', '名册.md')
+      let headers = ROSTER_HEADERS
+      let rows = []
+      try {
+        const parsed = parseMarkdownTable(await fs.readFile(filePath, 'utf8'))
+        if (parsed.ok && parsed.headers.length) {
+          headers = parsed.headers
+          rows = parsed.rows
+        }
+      } catch {
+        // 文件不存在,用默认表头
+      }
+
+      const idx = rows.findIndex((r) => r.正名 === row.正名)
+      if (idx >= 0) rows[idx] = row
+      else rows.push(row)
+
+      await fs.mkdir(path.dirname(filePath), { recursive: true })
+      await fs.writeFile(filePath, serializeMarkdownTable(headers, rows), 'utf8')
+      return { ok: true, error: '' }
+    } catch (err) {
+      return { ok: false, error: `upsert 名册行失败:${err.message}` }
+    }
+  }
+}

+ 32 - 0
v7/src/storage/adapters/SecretWriter.js

@@ -0,0 +1,32 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { serializeFrontMatter } from '../serializers/front-matter.js'
+
+/**
+ * SecretWriter:写/更新信息差文件(M2 定稿流程调用)。
+ */
+export class SecretWriter {
+  constructor(repoPath, cache = null) {
+    this.repoPath = repoPath
+    this.cache = cache
+  }
+
+  /**
+   * 写信息差到 定稿/设定/信息差/<id>.md(front matter 走防呆序列化)。
+   * @param {string} id - 文件名干(如 "信息差-021-血书真相")
+   * @param {object} frontMatter - 强度/谁知道[]/读者知道/登记章/关键词[]
+   * @param {string} content - 正文(## 描述 / ## 内容 段)
+   * @returns {Promise<{ok: boolean, filePath: string, error: string}>}
+   */
+  async write(id, frontMatter, content) {
+    try {
+      const dir = path.join(this.repoPath, '定稿', '设定', '信息差')
+      await fs.mkdir(dir, { recursive: true })
+      const filePath = path.join(dir, `${id}.md`)
+      await fs.writeFile(filePath, serializeFrontMatter(frontMatter, content), 'utf8')
+      return { ok: true, filePath, error: '' }
+    } catch (err) {
+      return { ok: false, filePath: '', error: `写信息差 ${id} 失败:${err.message}` }
+    }
+  }
+}

+ 30 - 0
v7/src/storage/adapters/SummaryWriter.js

@@ -0,0 +1,30 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+
+/**
+ * SummaryWriter:写章摘要(M2 定稿流程调用,审稿单定稿版落盘)。
+ */
+export class SummaryWriter {
+  constructor(repoPath, cache = null) {
+    this.repoPath = repoPath
+    this.cache = cache
+  }
+
+  /**
+   * 写章摘要到 定稿/摘要/章摘要/NNNN.md。
+   * @param {number} chapterNum
+   * @param {string} text - 摘要文本(≤200 字,纯文本无 front matter)
+   * @returns {Promise<{ok: boolean, filePath: string, error: string}>}
+   */
+  async writeChapterSummary(chapterNum, text) {
+    try {
+      const dir = path.join(this.repoPath, '定稿', '摘要', '章摘要')
+      await fs.mkdir(dir, { recursive: true })
+      const filePath = path.join(dir, `${String(chapterNum).padStart(4, '0')}.md`)
+      await fs.writeFile(filePath, `${String(text).trim()}\n`, 'utf8')
+      return { ok: true, filePath, error: '' }
+    } catch (err) {
+      return { ok: false, filePath: '', error: `写章摘要 ${chapterNum} 失败:${err.message}` }
+    }
+  }
+}

+ 47 - 9
v7/src/storage/adapters/ThreadLedgerWriter.js

@@ -1,5 +1,11 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseFrontMatter } from '../parsers/front-matter.js'
+import { serializeFrontMatter } from '../serializers/front-matter.js'
+import { appendUnderSection } from '../../util/markdown.js'
+
 /**
- * ThreadLedgerWriter:更新条目、追加履历(M2 落地,本任务只定接口占位)。
+ * ThreadLedgerWriter:更新三类条目 front matter、追加履历(M2 定稿流程调用)。
  */
 export class ThreadLedgerWriter {
   constructor(repoPath, cache = null) {
@@ -8,22 +14,54 @@ export class ThreadLedgerWriter {
   }
 
   /**
-   * 更新条目状态
-   * @param {string} threadId
-   * @param {object} updates
+   * 更新条目 front matter(合并 updates,保留正文与未在 updates 中的字段)
+   * @param {string} threadId 如 "伏笔-001"
+   * @param {object} updates 要改的 front matter 字段
    * @returns {Promise<{ok: boolean, error: string}>}
    */
   async updateThread(threadId, updates) {
-    throw new Error('ThreadLedgerWriter.updateThread() 将在 M2 定稿流程中实现')
+    const filePath = await this._findThreadFile(threadId)
+    if (!filePath) return { ok: false, error: `条目 ${threadId} 不存在` }
+    try {
+      const parsed = parseFrontMatter(await fs.readFile(filePath, 'utf8'))
+      if (!parsed.ok) return { ok: false, error: `条目 ${threadId} 解析失败:${parsed.error}` }
+      const merged = { ...parsed.data, ...updates }
+      await fs.writeFile(filePath, serializeFrontMatter(merged, parsed.body), 'utf8')
+      return { ok: true, error: '' }
+    } catch (err) {
+      return { ok: false, error: `更新条目 ${threadId} 失败:${err.message}` }
+    }
   }
 
   /**
-   * 追加履历条目。
+   * 在条目 `## 履历` 段尾追加一行
    * @param {string} threadId
-   * @param {object} historyEntry
+   * @param {string} entryLine 履历正文(不含前导 "- ")
    * @returns {Promise<{ok: boolean, error: string}>}
    */
-  async appendHistory(threadId, historyEntry) {
-    throw new Error('ThreadLedgerWriter.appendHistory() 将在 M2 定稿流程中实现')
+  async appendHistory(threadId, entryLine) {
+    const filePath = await this._findThreadFile(threadId)
+    if (!filePath) return { ok: false, error: `条目 ${threadId} 不存在` }
+    try {
+      const parsed = parseFrontMatter(await fs.readFile(filePath, 'utf8'))
+      if (!parsed.ok) return { ok: false, error: `条目 ${threadId} 解析失败:${parsed.error}` }
+      const newBody = appendUnderSection(parsed.body, '履历', `- ${entryLine}`)
+      await fs.writeFile(filePath, serializeFrontMatter(parsed.data, newBody), 'utf8')
+      return { ok: true, error: '' }
+    } catch (err) {
+      return { ok: false, error: `追加履历 ${threadId} 失败:${err.message}` }
+    }
+  }
+
+  async _findThreadFile(threadId) {
+    const type = threadId.split('-')[0]
+    const dir = path.join(this.repoPath, '大纲', type)
+    try {
+      const files = await fs.readdir(dir)
+      const found = files.find((f) => f.startsWith(threadId))
+      return found ? path.join(dir, found) : null
+    } catch {
+      return null
+    }
   }
 }

+ 48 - 0
v7/src/storage/adapters/TimelineWriter.js

@@ -0,0 +1,48 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseMarkdownTable } from '../parsers/markdown-table.js'
+import { serializeMarkdownTable } from '../serializers/markdown-table.js'
+
+const TIMELINE_HEADERS = ['章', '书内时间', '一句话事件', '在场']
+
+/**
+ * TimelineWriter:向卷时间线表格追加行(M2 定稿流程调用)。
+ */
+export class TimelineWriter {
+  constructor(repoPath, cache = null) {
+    this.repoPath = repoPath
+    this.cache = cache
+  }
+
+  /**
+   * 向 定稿/设定/时间线/第NN卷.md 追加一行(文件不存在则建表头)。
+   * @param {number} volumeNum
+   * @param {object} row { 章, 书内时间, 一句话事件, 在场 }
+   * @returns {Promise<{ok: boolean, filePath: string, error: string}>}
+   */
+  async appendRow(volumeNum, row) {
+    try {
+      const dir = path.join(this.repoPath, '定稿', '设定', '时间线')
+      await fs.mkdir(dir, { recursive: true })
+      const filePath = path.join(dir, `第${String(volumeNum).padStart(2, '0')}卷.md`)
+
+      let headers = TIMELINE_HEADERS
+      let rows = []
+      try {
+        const parsed = parseMarkdownTable(await fs.readFile(filePath, 'utf8'))
+        if (parsed.ok && parsed.headers.length) {
+          headers = parsed.headers
+          rows = parsed.rows
+        }
+      } catch {
+        // 文件不存在,用默认表头
+      }
+
+      rows.push(row)
+      await fs.writeFile(filePath, serializeMarkdownTable(headers, rows), 'utf8')
+      return { ok: true, filePath, error: '' }
+    } catch (err) {
+      return { ok: false, filePath: '', error: `写时间线第${volumeNum}卷失败:${err.message}` }
+    }
+  }
+}

+ 4 - 0
v7/src/storage/index.js

@@ -5,7 +5,11 @@ export { ChapterWriter } from './adapters/ChapterWriter.js'
 export { ThreadLedgerReader } from './adapters/ThreadLedgerReader.js'
 export { ThreadLedgerWriter } from './adapters/ThreadLedgerWriter.js'
 export { EntityReader } from './adapters/EntityReader.js'
+export { EntityWriter } from './adapters/EntityWriter.js'
 export { TimelineReader } from './adapters/TimelineReader.js'
+export { TimelineWriter } from './adapters/TimelineWriter.js'
 export { SecretReader } from './adapters/SecretReader.js'
+export { SecretWriter } from './adapters/SecretWriter.js'
 export { OutlineReader } from './adapters/OutlineReader.js'
 export { BookConfigReader } from './adapters/BookConfigReader.js'
+export { SummaryWriter } from './adapters/SummaryWriter.js'

+ 12 - 0
v7/src/storage/serializers/markdown-table.js

@@ -0,0 +1,12 @@
+/**
+ * 序列化 Markdown 表格(与 parsers/markdown-table.js 配对)。
+ * @param {string[]} headers 表头
+ * @param {object[]} rows 行对象数组(按 header 取值,缺失补空)
+ * @returns {string} Markdown 表格文本(含尾换行)
+ */
+export function serializeMarkdownTable(headers, rows) {
+  const head = `| ${headers.join(' | ')} |`
+  const sep = `| ${headers.map(() => '---').join(' | ')} |`
+  const body = rows.map((r) => `| ${headers.map((h) => String(r[h] ?? '')).join(' | ')} |`)
+  return [head, sep, ...body].join('\n') + '\n'
+}

+ 35 - 0
v7/src/util/markdown.js

@@ -20,3 +20,38 @@ export function extractSection(content, title) {
   }
   return out.join('\n').trim()
 }
+
+/**
+ * 在首个标题含 sectionTitle 的 ## 小节段尾追加一行(段不存在则在文末新建该段)。
+ * @param {string} body Markdown 正文
+ * @param {string} sectionTitle 小节标题关键词
+ * @param {string} line 要追加的整行(如 "- 第152章:推进——...")
+ * @returns {string} 追加后的正文
+ */
+export function appendUnderSection(body, sectionTitle, line) {
+  const lines = body.split('\n')
+  let secIdx = -1
+  for (let i = 0; i < lines.length; i++) {
+    if (lines[i].startsWith('## ') && lines[i].includes(sectionTitle)) {
+      secIdx = i
+      break
+    }
+  }
+  if (secIdx === -1) {
+    const sep = body.endsWith('\n') ? '' : '\n'
+    return `${body}${sep}\n## ${sectionTitle}\n${line}\n`
+  }
+  // 段落范围 = secIdx+1 .. 下一个 ## 或文末
+  let endIdx = lines.length
+  for (let i = secIdx + 1; i < lines.length; i++) {
+    if (lines[i].startsWith('## ')) {
+      endIdx = i
+      break
+    }
+  }
+  // 插到段内最后一个非空行之后
+  let insertAt = endIdx
+  while (insertAt > secIdx + 1 && lines[insertAt - 1].trim() === '') insertAt--
+  lines.splice(insertAt, 0, line)
+  return lines.join('\n')
+}

+ 22 - 0
v7/test/storage/_tmprepo.js

@@ -0,0 +1,22 @@
+import path from 'node:path'
+import os from 'node:os'
+import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises'
+
+// 在临时目录造一个最小书仓库(Writer/定稿 测试用),files = {相对路径: 内容}
+export async function makeRepo(files = {}) {
+  const root = await mkdtemp(path.join(os.tmpdir(), 'wnw-w-'))
+  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
+}
+
+export async function cleanup(root) {
+  await rm(root, { recursive: true, force: true })
+}
+
+export function read(root, rel) {
+  return readFile(path.join(root, rel), 'utf8')
+}

+ 41 - 0
v7/test/storage/adapters/ChapterWriter.test.js

@@ -0,0 +1,41 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { ChapterWriter } from '../../../src/storage/adapters/ChapterWriter.js'
+import { makeRepo, cleanup, read } from '../_tmprepo.js'
+
+test('ChapterWriter.writeChapter 写出定稿正文(防呆 front matter + 正文)', async () => {
+  const root = await makeRepo()
+  try {
+    const w = new ChapterWriter(root)
+    const fm = {
+      章号: 152,
+      标题: '北境的雪',
+      卷: 5,
+      字数: 3120,
+      章定位: '推进',
+      伏笔: ['埋下 伏笔-058'],
+    }
+    const r = await w.writeChapter(152, fm, '正文第一段。\n')
+    assert.equal(r.ok, true)
+    const content = await read(root, '定稿/正文/0152-北境的雪.md')
+    assert.match(content, /^---\n/)
+    assert.match(content, /标题: 北境的雪/)
+    assert.match(content, /伏笔:\n {2}- 埋下 伏笔-058/) // 块列表防呆,两空格缩进
+    assert.match(content, /正文第一段。/)
+  } finally {
+    await cleanup(root)
+  }
+})
+
+test('ChapterWriter.writeChapter 危险值加引号(防呆)', async () => {
+  const root = await makeRepo()
+  try {
+    const w = new ChapterWriter(root)
+    const r = await w.writeChapter(1, { 章号: 1, 标题: '123', 卷: 1 }, '正文')
+    assert.equal(r.ok, true)
+    const content = await read(root, '定稿/正文/0001-123.md')
+    assert.match(content, /标题: "123"/) // 纯数字串加引号
+  } finally {
+    await cleanup(root)
+  }
+})

+ 83 - 0
v7/test/storage/adapters/EntityWriter.test.js

@@ -0,0 +1,83 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { EntityWriter } from '../../../src/storage/adapters/EntityWriter.js'
+import { makeRepo, cleanup, read } from '../_tmprepo.js'
+
+const 角色卡 = `---
+姓名: 林晚
+状态: 在世
+位置: 青云宗
+境界: 练气三层
+最后变更章: 1
+---
+## 设定
+外门弟子。
+`
+
+test('updateCharacter 改 front matter,保留正文', async () => {
+  const root = await makeRepo({ '定稿/设定/角色/林晚.md': 角色卡 })
+  try {
+    const r = await new EntityWriter(root).updateCharacter('林晚', {
+      位置: '北境雪原',
+      境界: '练气五层',
+      最后变更章: 152,
+    })
+    assert.equal(r.ok, true)
+    const c = await read(root, '定稿/设定/角色/林晚.md')
+    assert.match(c, /位置: 北境雪原/)
+    assert.match(c, /境界: 练气五层/)
+    assert.match(c, /最后变更章: 152/)
+    assert.match(c, /外门弟子/)
+  } finally {
+    await cleanup(root)
+  }
+})
+
+test('updateCharacter 不存在的角色 → ok=false(不崩)', async () => {
+  const root = await makeRepo({})
+  try {
+    const r = await new EntityWriter(root).updateCharacter('查无此人', { 状态: '死亡' })
+    assert.equal(r.ok, false)
+  } finally {
+    await cleanup(root)
+  }
+})
+
+const 名册 = '| 正名 | 别名 | 类型 | 首现章 |\n|---|---|---|---|\n| 林晚 | 晚晚 | character | 1 |\n'
+
+test('upsertRosterRow 新增名册行', async () => {
+  const root = await makeRepo({ '定稿/设定/名册.md': 名册 })
+  try {
+    const r = await new EntityWriter(root).upsertRosterRow({
+      正名: '神秘老者',
+      别名: '黑衣人',
+      类型: 'character',
+      首现章: 1,
+    })
+    assert.equal(r.ok, true)
+    const c = await read(root, '定稿/设定/名册.md')
+    assert.match(c, /\| 林晚 \|/)
+    assert.match(c, /\| 神秘老者 \| 黑衣人 \| character \| 1 \|/)
+  } finally {
+    await cleanup(root)
+  }
+})
+
+test('upsertRosterRow 已存在则更新该行(不重复)', async () => {
+  const root = await makeRepo({ '定稿/设定/名册.md': 名册 })
+  try {
+    const r = await new EntityWriter(root).upsertRosterRow({
+      正名: '林晚',
+      别名: '晚晚, 林师妹',
+      类型: 'character',
+      首现章: 1,
+    })
+    assert.equal(r.ok, true)
+    const c = await read(root, '定稿/设定/名册.md')
+    assert.match(c, /\| 林晚 \| 晚晚, 林师妹 \| character \| 1 \|/)
+    assert.equal((c.match(/\| 林晚 \|/g) || []).length, 1)
+  } finally {
+    await cleanup(root)
+  }
+})
+

+ 21 - 0
v7/test/storage/adapters/SecretWriter.test.js

@@ -0,0 +1,21 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { SecretWriter } from '../../../src/storage/adapters/SecretWriter.js'
+import { makeRepo, cleanup, read } from '../_tmprepo.js'
+
+test('SecretWriter.write 写信息差(防呆 front matter + 内容)', async () => {
+  const root = await makeRepo()
+  try {
+    const w = new SecretWriter(root)
+    const fm = { 强度: '高', 谁知道: ['林晚'], 读者知道: false, 登记章: 152, 关键词: ['血书'] }
+    const r = await w.write('信息差-021-血书真相', fm, '## 内容\n血书写着真凶名讳。')
+    assert.equal(r.ok, true)
+    const content = await read(root, '定稿/设定/信息差/信息差-021-血书真相.md')
+    assert.match(content, /读者知道: false/)
+    assert.match(content, /关键词:\n {2}- 血书/)
+    assert.match(content, /## 内容/)
+    assert.match(content, /血书写着真凶名讳/)
+  } finally {
+    await cleanup(root)
+  }
+})

+ 17 - 0
v7/test/storage/adapters/SummaryWriter.test.js

@@ -0,0 +1,17 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { SummaryWriter } from '../../../src/storage/adapters/SummaryWriter.js'
+import { makeRepo, cleanup, read } from '../_tmprepo.js'
+
+test('SummaryWriter.writeChapterSummary 写章摘要到 定稿/摘要/章摘要/NNNN.md', async () => {
+  const root = await makeRepo()
+  try {
+    const w = new SummaryWriter(root)
+    const r = await w.writeChapterSummary(152, '林晚于北境得血书,玄阶令牌现世。')
+    assert.equal(r.ok, true)
+    const content = await read(root, '定稿/摘要/章摘要/0152.md')
+    assert.match(content, /林晚于北境得血书/)
+  } finally {
+    await cleanup(root)
+  }
+})

+ 61 - 0
v7/test/storage/adapters/ThreadLedgerWriter.test.js

@@ -0,0 +1,61 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { ThreadLedgerWriter } from '../../../src/storage/adapters/ThreadLedgerWriter.js'
+import { makeRepo, cleanup, read } from '../_tmprepo.js'
+
+const 伏笔文件 = `---
+强度: 高
+状态: 进行
+开启章: 1
+最后推进章: 1
+---
+## 描述
+神秘老者身份。
+
+## 履历
+- 第1章:埋下——留玉佩
+`
+
+test('updateThread 改 front matter,保留正文与履历', async () => {
+  const root = await makeRepo({ '大纲/伏笔/伏笔-001-老者.md': 伏笔文件 })
+  try {
+    const r = await new ThreadLedgerWriter(root).updateThread('伏笔-001', {
+      状态: '已收尾',
+      最后推进章: 152,
+    })
+    assert.equal(r.ok, true)
+    const c = await read(root, '大纲/伏笔/伏笔-001-老者.md')
+    assert.match(c, /状态: 已收尾/)
+    assert.match(c, /最后推进章: 152/)
+    assert.match(c, /## 履历/)
+    assert.match(c, /神秘老者身份/)
+  } finally {
+    await cleanup(root)
+  }
+})
+
+test('appendHistory 在 ## 履历 段尾追加一行', async () => {
+  const root = await makeRepo({ '大纲/伏笔/伏笔-001-老者.md': 伏笔文件 })
+  try {
+    const r = await new ThreadLedgerWriter(root).appendHistory(
+      '伏笔-001',
+      '第152章:推进——林晚取得实证'
+    )
+    assert.equal(r.ok, true)
+    const c = await read(root, '大纲/伏笔/伏笔-001-老者.md')
+    assert.match(c, /- 第1章:埋下——留玉佩/)
+    assert.match(c, /- 第152章:推进——林晚取得实证/)
+  } finally {
+    await cleanup(root)
+  }
+})
+
+test('updateThread 不存在的条目 → ok=false(不崩)', async () => {
+  const root = await makeRepo({})
+  try {
+    const r = await new ThreadLedgerWriter(root).updateThread('伏笔-999', {})
+    assert.equal(r.ok, false)
+  } finally {
+    await cleanup(root)
+  }
+})

+ 43 - 0
v7/test/storage/adapters/TimelineWriter.test.js

@@ -0,0 +1,43 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { TimelineWriter } from '../../../src/storage/adapters/TimelineWriter.js'
+import { makeRepo, cleanup, read } from '../_tmprepo.js'
+
+test('appendRow 向已有卷时间线追加一行', async () => {
+  const root = await makeRepo({
+    '定稿/设定/时间线/第05卷.md':
+      '| 章 | 书内时间 | 一句话事件 | 在场 |\n|---|---|---|---|\n| 151 | 冬月初二 | 旧事 | 林晚 |\n',
+  })
+  try {
+    const r = await new TimelineWriter(root).appendRow(5, {
+      章: 152,
+      书内时间: '冬月初三',
+      一句话事件: '北境得血书',
+      在场: '林晚',
+    })
+    assert.equal(r.ok, true)
+    const c = await read(root, '定稿/设定/时间线/第05卷.md')
+    assert.match(c, /\| 151 \|/)
+    assert.match(c, /\| 152 \| 冬月初三 \| 北境得血书 \| 林晚 \|/)
+  } finally {
+    await cleanup(root)
+  }
+})
+
+test('appendRow 文件不存在时新建表头', async () => {
+  const root = await makeRepo({})
+  try {
+    const r = await new TimelineWriter(root).appendRow(1, {
+      章: 1,
+      书内时间: '春月初一',
+      一句话事件: '入宗门',
+      在场: '林晚',
+    })
+    assert.equal(r.ok, true)
+    const c = await read(root, '定稿/设定/时间线/第01卷.md')
+    assert.match(c, /\| 章 \| 书内时间 \| 一句话事件 \| 在场 \|/)
+    assert.match(c, /\| 1 \| 春月初一 \| 入宗门 \| 林晚 \|/)
+  } finally {
+    await cleanup(root)
+  }
+})