소스 검색

feat(v7): M1 重建 R4a——P2 读取接口真实现 + 修 EntityReader 双引号 bug

P2 读取类接口全部真实现 + test/commands 逐条测试:
- list-threads(--悬了太久|--type[--status]|--strength)
- list-characters([--status])
- read-secret(--基本信息|--内容)
- read-worldview(--section,读 定稿/设定/世界观.md)
- read-outline(--总纲[--section|--结局]|--卷=N[--section])
- list-volumes(卷号 + 查询时算章数范围)
- read-chapters(--range 范围摘要 | --recent --tail 近章结尾)
- read-timeline 补 --current-volume/--卷=N/--在场=名
- grep-story 补 --regex(非法正则不崩,返回中文错误)
- 抽出 src/util/markdown.js extractSection 共享

修 bug:EntityReader.listCharacters 用 `type = "character"`(SQLite 双引号是
标识符不是字符串)→ 改单引号字面量,list-characters 之前恒空。新测试揪出。
lingfengQAQ 2 일 전
부모
커밋
322fe8ea4f

+ 15 - 5
v7/src/commands/grep-story.js

@@ -2,13 +2,23 @@ import { promises as fs } from 'node:fs'
 import path from 'node:path'
 
 /**
- * grep-story <关键词>(R4 补 --regex=<pattern>)→ JSON 数组(章号、匹配行、上下文)
+ * grep-story <关键词> | --regex=<pattern> → JSON 数组(章号、匹配行、上下文)
  * 全文检索 定稿/正文/*.md。契约:纯返回 {ok, output?, error?}(见 design §6.2)。
  */
 export async function run(args, options, ctx) {
-  const keyword = args[0]
-  if (!keyword) {
-    return { ok: false, error: '请指定检索关键词' }
+  let matcher
+  if (options.regex && options.regex !== true) {
+    try {
+      const re = new RegExp(options.regex)
+      matcher = (line) => re.test(line)
+    } catch (err) {
+      return { ok: false, error: `正则表达式非法:${err.message}` }
+    }
+  } else if (args[0]) {
+    const keyword = args[0]
+    matcher = (line) => line.includes(keyword)
+  } else {
+    return { ok: false, error: '请指定检索关键词或 --regex=<pattern>' }
   }
 
   const dir = path.join(ctx.repoPath, '定稿', '正文')
@@ -25,7 +35,7 @@ export async function run(args, options, ctx) {
     const content = await fs.readFile(path.join(dir, file), 'utf8')
     const lines = content.split('\n')
     for (let i = 0; i < lines.length; i++) {
-      if (lines[i].includes(keyword)) {
+      if (matcher(lines[i])) {
         hits.push({
           章号: num,
           匹配行: lines[i].trim(),

+ 13 - 0
v7/src/commands/list-characters.js

@@ -0,0 +1,13 @@
+import { EntityReader } from '../storage/adapters/EntityReader.js'
+
+/**
+ * list-characters [--status=<状态>] → JSON 数组(正名、状态、位置)
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const reader = new EntityReader(ctx.repoPath, ctx.cache)
+  const filter = {}
+  if (options.status && options.status !== true) filter.status = options.status
+  const rows = await reader.listCharacters(filter)
+  return { ok: true, output: JSON.stringify(rows, null, 2) }
+}

+ 35 - 0
v7/src/commands/list-threads.js

@@ -0,0 +1,35 @@
+import { ThreadLedgerReader } from '../storage/adapters/ThreadLedgerReader.js'
+import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+
+/**
+ * list-threads [--悬了太久 | --type=<t> [--status=<s>] | --strength=<强>]
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const reader = new ThreadLedgerReader(ctx.repoPath, ctx.cache)
+
+  if (options['悬了太久']) {
+    const config = await new BookConfigReader(ctx.repoPath).read()
+    const overdue = await reader.listOverdue(config.ok ? config.data : {})
+    return { ok: true, output: JSON.stringify(overdue, null, 2) }
+  }
+
+  if (options.type && options.type !== true) {
+    const status = options.status && options.status !== true ? options.status : null
+    const rows = await reader.listByType(options.type, status)
+    return { ok: true, output: JSON.stringify(rows, null, 2) }
+  }
+
+  if (options.strength && options.strength !== true) {
+    const rows = await ctx.cache.query(
+      'SELECT id, type, short_title, strength, status FROM threads WHERE strength = ? ORDER BY id',
+      [options.strength]
+    )
+    return { ok: true, output: JSON.stringify(rows, null, 2) }
+  }
+
+  return {
+    ok: false,
+    error: '请指定筛选(--悬了太久 | --type=<t> [--status=<s>] | --strength=<强>)',
+  }
+}

+ 21 - 0
v7/src/commands/list-volumes.js

@@ -0,0 +1,21 @@
+import { OutlineReader } from '../storage/adapters/OutlineReader.js'
+
+/**
+ * list-volumes → JSON 数组(卷号、章数范围)
+ * 卷号来自 大纲/第NN卷.md,章数范围查询时从 chapters 表算(派生值不物化)。
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const reader = new OutlineReader(ctx.repoPath, ctx.cache)
+  const volumes = await reader.listVolumes()
+
+  const out = []
+  for (const v of volumes) {
+    const range = await ctx.cache.query(
+      'SELECT MIN(chapter_num) AS s, MAX(chapter_num) AS e FROM chapters WHERE volume_num = ?',
+      [v]
+    )
+    out.push({ 卷号: v, 起始章: range[0]?.s ?? null, 结束章: range[0]?.e ?? null })
+  }
+  return { ok: true, output: JSON.stringify(out, null, 2) }
+}

+ 49 - 0
v7/src/commands/read-chapters.js

@@ -0,0 +1,49 @@
+import { ChapterReader } from '../storage/adapters/ChapterReader.js'
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+
+/**
+ * read-chapters [--range=a-b --摘要 | --recent=N --tail=M] → 批量读取
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  // --range=a-b --摘要:范围内各章摘要(摘要在 定稿/摘要/章摘要/NNNN.md,不在 front matter)
+  if (options.range && options.range !== true) {
+    const m = String(options.range).match(/^(\d+)-(\d+)$/)
+    if (!m) return { ok: false, error: '--range 格式应为 起-止,如 --range=1-20' }
+    const start = parseInt(m[1], 10)
+    const end = parseInt(m[2], 10)
+    const out = []
+    for (let n = start; n <= end; n++) {
+      const sp = path.join(ctx.repoPath, '定稿', '摘要', '章摘要', `${String(n).padStart(4, '0')}.md`)
+      let summary = ''
+      try {
+        summary = (await fs.readFile(sp, 'utf8')).trim()
+      } catch {
+        summary = ''
+      }
+      out.push({ 章号: n, 摘要: summary })
+    }
+    return { ok: true, output: JSON.stringify(out, null, 2) }
+  }
+
+  // --recent=N --tail=M:近 N 章的结尾 M 字
+  if (options.recent && options.recent !== true) {
+    const n = parseInt(options.recent, 10)
+    const tailLen = options.tail && options.tail !== true ? parseInt(options.tail, 10) : 200
+    const rows = await ctx.cache.query(
+      'SELECT chapter_num FROM chapters ORDER BY chapter_num DESC LIMIT ?',
+      [n]
+    )
+    const reader = new ChapterReader(ctx.repoPath, ctx.cache)
+    const out = []
+    for (const row of rows) {
+      const r = await reader.readTail(row.chapter_num, tailLen)
+      out.push({ 章号: row.chapter_num, 结尾: r.ok ? r.text : '' })
+    }
+    out.sort((a, b) => a.章号 - b.章号)
+    return { ok: true, output: JSON.stringify(out, null, 2) }
+  }
+
+  return { ok: false, error: '请指定 --range=起-止 --摘要 或 --recent=N --tail=M' }
+}

+ 42 - 0
v7/src/commands/read-outline.js

@@ -0,0 +1,42 @@
+import { OutlineReader } from '../storage/adapters/OutlineReader.js'
+import { extractSection } from '../util/markdown.js'
+
+/**
+ * read-outline [--总纲 [--section=<标题>|--结局] | --卷=N [--section=<标题>]]
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const reader = new OutlineReader(ctx.repoPath, ctx.cache)
+
+  if (options['总纲']) {
+    const title = options['结局']
+      ? '结局'
+      : options.section && options.section !== true
+        ? options.section
+        : null
+    if (!title) {
+      return { ok: false, error: '读总纲请指定 --结局 或 --section=<标题>' }
+    }
+    const r = await reader.readOutlineSection(title)
+    if (!r.ok) return { ok: false, error: r.error }
+    return r.content
+      ? { ok: true, output: r.content }
+      : { ok: false, error: `总纲无「${title}」小节` }
+  }
+
+  if (options['卷'] !== undefined && options['卷'] !== true) {
+    const volNum = parseInt(options['卷'], 10)
+    const r = await reader.readVolumeOutline(volNum)
+    if (!r.ok) return { ok: false, error: r.error }
+
+    if (options.section && options.section !== true) {
+      const section = extractSection(r.content, options.section)
+      return section
+        ? { ok: true, output: section }
+        : { ok: false, error: `第${volNum}卷无「${options.section}」小节` }
+    }
+    return { ok: true, output: r.content }
+  }
+
+  return { ok: false, error: '请指定 --总纲 或 --卷=N' }
+}

+ 23 - 0
v7/src/commands/read-secret.js

@@ -0,0 +1,23 @@
+import { SecretReader } from '../storage/adapters/SecretReader.js'
+
+/**
+ * read-secret <ID> [--基本信息|--内容](默认基本信息)
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const id = args[0]
+  if (!id) {
+    return { ok: false, error: '请指定信息差 ID(如 信息差-001)' }
+  }
+
+  const reader = new SecretReader(ctx.repoPath, ctx.cache)
+
+  if (options['内容']) {
+    const r = await reader.readContent(id)
+    return r.ok ? { ok: true, output: r.content } : { ok: false, error: r.error }
+  }
+
+  // 默认 / --基本信息
+  const r = await reader.readBasicInfo(id)
+  return r.ok ? { ok: true, output: JSON.stringify(r.data, null, 2) } : { ok: false, error: r.error }
+}

+ 19 - 1
v7/src/commands/read-timeline.js

@@ -11,7 +11,7 @@ async function currentVolume(ctx) {
 }
 
 /**
- * read-timeline [--current-and-prev](R4 再补 --current-volume|--卷=N|--在场=名)
+ * read-timeline [--current-and-prev|--current-volume|--卷=N|--在场=名]
  * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
  */
 export async function run(args, options, ctx) {
@@ -23,6 +23,24 @@ export async function run(args, options, ctx) {
     return r.ok ? { ok: true, output: JSON.stringify(r.timeline, null, 2) } : { ok: false, error: r.error }
   }
 
+  if (options['current-volume']) {
+    const cur = await currentVolume(ctx)
+    const r = await reader.readCurrentVolume(cur)
+    return r.ok ? { ok: true, output: JSON.stringify(r.timeline, null, 2) } : { ok: false, error: r.error }
+  }
+
+  if (options['卷'] !== undefined && options['卷'] !== true) {
+    const vol = parseInt(options['卷'], 10)
+    const r = await reader.readVolumeRange(vol, vol)
+    return r.ok ? { ok: true, output: JSON.stringify(r.timeline, null, 2) } : { ok: false, error: r.error }
+  }
+
+  if (options['在场'] && options['在场'] !== true) {
+    const cur = await currentVolume(ctx)
+    const rows = await reader.readByParticipant(cur, options['在场'])
+    return { ok: true, output: JSON.stringify(rows, null, 2) }
+  }
+
   return {
     ok: false,
     error: '请指定选项(如 --current-and-prev、--current-volume、--卷=N、--在场=名)',

+ 26 - 0
v7/src/commands/read-worldview.js

@@ -0,0 +1,26 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { extractSection } from '../util/markdown.js'
+
+/**
+ * read-worldview --section=<标题> → 世界观指定小节(读 定稿/设定/世界观.md)
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  if (!options.section || options.section === true) {
+    return { ok: false, error: '请用 --section=<标题> 指定世界观小节' }
+  }
+
+  const filePath = path.join(ctx.repoPath, '定稿', '设定', '世界观.md')
+  let content
+  try {
+    content = await fs.readFile(filePath, 'utf8')
+  } catch {
+    return { ok: false, error: '世界观文件不存在' }
+  }
+
+  const section = extractSection(content, options.section)
+  return section
+    ? { ok: true, output: section }
+    : { ok: false, error: `世界观无「${options.section}」小节` }
+}

+ 1 - 1
v7/src/storage/adapters/EntityReader.js

@@ -104,7 +104,7 @@ export class EntityReader {
   async listCharacters(filter = {}) {
     if (this.cache) {
       try {
-        let query = 'SELECT * FROM entities WHERE type = "character"'
+        let query = "SELECT * FROM entities WHERE type = 'character'"
         const params = []
 
         if (filter.status) {

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

@@ -0,0 +1,22 @@
+/**
+ * 从 Markdown 提取首个标题含 title 的 ## 小节正文(到下一个 ## 为止)。
+ * @param {string} content Markdown 全文
+ * @param {string} title 小节标题关键词
+ * @returns {string} 去首尾空白的小节正文;未命中返回空串
+ */
+export function extractSection(content, title) {
+  const lines = content.split('\n')
+  let inSection = false
+  const out = []
+  for (const line of lines) {
+    if (line.startsWith('## ')) {
+      if (inSection) break
+      if (line.includes(title)) {
+        inSection = true
+        continue
+      }
+    }
+    if (inSection) out.push(line)
+  }
+  return out.join('\n').trim()
+}

+ 13 - 0
v7/test/commands/grep-story.test.js

@@ -26,6 +26,19 @@ test('grep-story 无命中 → ok 且空数组', async () => {
   assert.deepEqual(JSON.parse(r.output), [])
 })
 
+test('grep-story --regex 正则检索命中', async () => {
+  const r = await run([], { regex: '玉佩|藏书' }, ctx)
+  assert.equal(r.ok, true)
+  const hits = JSON.parse(r.output)
+  assert.ok(hits.length >= 1)
+  assert.ok(hits.every((h) => /玉佩|藏书/.test(h.匹配行)))
+})
+
+test('grep-story --regex 非法正则 → ok=false(不崩)', async () => {
+  const r = await run([], { regex: '[invalid(' }, ctx)
+  assert.equal(r.ok, false)
+})
+
 test('grep-story 缺关键词 → ok=false', async () => {
   const r = await run([], {}, ctx)
   assert.equal(r.ok, false)

+ 29 - 0
v7/test/commands/list-characters.test.js

@@ -0,0 +1,29 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/list-characters.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('list-characters 列出所有角色', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, true)
+  const rows = JSON.parse(r.output)
+  const names = rows.map((c) => c.正名 ?? c.id)
+  assert.ok(names.includes('林晚'))
+  assert.ok(names.includes('神秘老者'))
+})
+
+test('list-characters --status=在世 按状态筛选', async () => {
+  const r = await run([], { status: '在世' }, ctx)
+  assert.equal(r.ok, true)
+  const rows = JSON.parse(r.output)
+  assert.ok(rows.some((c) => (c.正名 ?? c.id) === '林晚'))
+  assert.ok(rows.every((c) => c.status === '在世' || c.状态 === '在世'))
+})

+ 46 - 0
v7/test/commands/list-threads.test.js

@@ -0,0 +1,46 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/list-threads.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('list-threads --type=foreshadow 只返回伏笔', async () => {
+  const r = await run([], { type: 'foreshadow' }, ctx)
+  assert.equal(r.ok, true)
+  const rows = JSON.parse(r.output)
+  assert.ok(rows.length >= 1)
+  assert.ok(rows.every((t) => t.id.startsWith('伏笔')))
+})
+
+test('list-threads --type=suspense 只返回悬念', async () => {
+  const r = await run([], { type: 'suspense' }, ctx)
+  assert.equal(r.ok, true)
+  const rows = JSON.parse(r.output)
+  assert.ok(rows.some((t) => t.id === '悬念-001'))
+})
+
+test('list-threads --strength=高 按强度筛选', async () => {
+  const r = await run([], { strength: '高' }, ctx)
+  assert.equal(r.ok, true)
+  const rows = JSON.parse(r.output)
+  assert.ok(rows.every((t) => t.strength === '高'))
+  assert.ok(rows.some((t) => t.id === '伏笔-001'))
+})
+
+test('list-threads --悬了太久 返回数组(不崩)', async () => {
+  const r = await run([], { 悬了太久: true }, ctx)
+  assert.equal(r.ok, true)
+  assert.ok(Array.isArray(JSON.parse(r.output)))
+})
+
+test('list-threads 无筛选 → ok=false 提示', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, false)
+})

+ 22 - 0
v7/test/commands/list-volumes.test.js

@@ -0,0 +1,22 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/list-volumes.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('list-volumes 返回卷号与章数范围', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, true)
+  const vols = JSON.parse(r.output)
+  const v1 = vols.find((v) => v.卷号 === 1)
+  assert.ok(v1, `应含第1卷,实际:${r.output}`)
+  assert.equal(v1.起始章, 1)
+  assert.equal(v1.结束章, 2)
+})

+ 35 - 0
v7/test/commands/read-chapters.test.js

@@ -0,0 +1,35 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/read-chapters.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('read-chapters --range=1-2 --摘要 返回范围内各章摘要', async () => {
+  const r = await run([], { range: '1-2', 摘要: true }, ctx)
+  assert.equal(r.ok, true)
+  const rows = JSON.parse(r.output)
+  assert.equal(rows.length, 2)
+  assert.equal(rows[0].章号, 1)
+  assert.ok(rows[0].摘要.length > 0)
+})
+
+test('read-chapters --recent=1 --tail=8 返回近 1 章结尾', async () => {
+  const r = await run([], { recent: '1', tail: '8' }, ctx)
+  assert.equal(r.ok, true)
+  const rows = JSON.parse(r.output)
+  assert.equal(rows.length, 1)
+  assert.equal(rows[0].章号, 2) // 最新章
+  assert.ok([...rows[0].结尾].length <= 8)
+})
+
+test('read-chapters 缺选项 → ok=false', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, false)
+})

+ 6 - 0
v7/test/commands/read-character.test.js

@@ -26,6 +26,12 @@ test('read-character 默认返回完整(frontMatter + body)', async () => {
   assert.ok(data.body.includes('设定'))
 })
 
+test('read-character --section=设定 返回该小节', async () => {
+  const r = await run(['林晚'], { section: '设定' }, ctx)
+  assert.equal(r.ok, true)
+  assert.ok(r.output.includes('外门弟子'))
+})
+
 test('read-character 缺正名 → ok=false', async () => {
   const r = await run([], {}, ctx)
   assert.equal(r.ok, false)

+ 41 - 0
v7/test/commands/read-outline.test.js

@@ -0,0 +1,41 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/read-outline.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('read-outline --总纲 --结局 返回结局段', async () => {
+  const r = await run([], { 总纲: true, 结局: true }, ctx)
+  assert.equal(r.ok, true)
+  assert.ok(r.output.includes('血仇得报'))
+})
+
+test('read-outline --总纲 --section=核心冲突 返回该小节', async () => {
+  const r = await run([], { 总纲: true, section: '核心冲突' }, ctx)
+  assert.equal(r.ok, true)
+  assert.ok(r.output.includes('灭门血仇'))
+})
+
+test('read-outline --卷=1 返回卷纲全文', async () => {
+  const r = await run([], { 卷: '1' }, ctx)
+  assert.equal(r.ok, true)
+  assert.ok(r.output.includes('卷定位'))
+})
+
+test('read-outline --卷=1 --section=卷定位 返回卷内小节', async () => {
+  const r = await run([], { 卷: '1', section: '卷定位' }, ctx)
+  assert.equal(r.ok, true)
+  assert.ok(r.output.includes('开篇立人设'))
+})
+
+test('read-outline 不存在的卷 → ok=false', async () => {
+  const r = await run([], { 卷: '99' }, ctx)
+  assert.equal(r.ok, false)
+})

+ 32 - 0
v7/test/commands/read-secret.test.js

@@ -0,0 +1,32 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/read-secret.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('read-secret --基本信息 返回 JSON(含读者知道/关键词)', async () => {
+  const r = await run(['信息差-001'], { 基本信息: true }, ctx)
+  assert.equal(r.ok, true)
+  const data = JSON.parse(r.output)
+  assert.equal(data.读者知道, false)
+  assert.ok(Array.isArray(data.关键词))
+})
+
+test('read-secret --内容 返回内容段落', async () => {
+  const r = await run(['信息差-001'], { 内容: true }, ctx)
+  assert.equal(r.ok, true)
+  assert.ok(r.output.includes('封印邪灵'))
+})
+
+test('read-secret 不存在 → ok=false', async () => {
+  const r = await run(['信息差-999'], { 基本信息: true }, ctx)
+  assert.equal(r.ok, false)
+  assert.match(r.error, /不存在/)
+})

+ 20 - 0
v7/test/commands/read-timeline.test.js

@@ -19,6 +19,26 @@ test('read-timeline --current-and-prev 返回时间线数组', async () => {
   assert.ok(timeline.length >= 1)
 })
 
+test('read-timeline --current-volume 返回当前卷时间线', async () => {
+  const r = await run([], { 'current-volume': true }, ctx)
+  assert.equal(r.ok, true)
+  assert.ok(Array.isArray(JSON.parse(r.output)))
+})
+
+test('read-timeline --卷=1 返回指定卷时间线', async () => {
+  const r = await run([], { 卷: '1' }, ctx)
+  assert.equal(r.ok, true)
+  assert.ok(JSON.parse(r.output).length >= 1)
+})
+
+test('read-timeline --在场=林晚 只返回该角色在场的行', async () => {
+  const r = await run([], { 在场: '林晚' }, ctx)
+  assert.equal(r.ok, true)
+  const rows = JSON.parse(r.output)
+  assert.ok(rows.length >= 1)
+  assert.ok(rows.every((e) => e.在场.includes('林晚')))
+})
+
 test('read-timeline 无任何选项 → ok=false(提示需指定)', async () => {
   const r = await run([], {}, ctx)
   assert.equal(r.ok, false)

+ 29 - 0
v7/test/commands/read-worldview.test.js

@@ -0,0 +1,29 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/read-worldview.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('read-worldview --section=修炼体系 返回该小节', async () => {
+  const r = await run([], { section: '修炼体系' }, ctx)
+  assert.equal(r.ok, true)
+  assert.ok(r.output.includes('练气'))
+  assert.ok(!r.output.includes('## 势力'))
+})
+
+test('read-worldview 缺 --section → ok=false', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, false)
+})
+
+test('read-worldview 不存在的小节 → ok=false', async () => {
+  const r = await run([], { section: '不存在的节' }, ctx)
+  assert.equal(r.ok, false)
+})