core-logic.spec.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. import { 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 { scanBooks, hasBooks } from '../src/bookshelf'
  6. import { parseBookPathInput, resolveToBookPath } from '../src/paths'
  7. import { renderOverview, renderCurrentBook, attachStatusToAgent, progressLineOf } from '../src/status-context'
  8. import { validateWorkspacePath, scaffoldStudy, STUDY_DIRS } from '../src/workspace'
  9. /** 临时工作范围:建书签名+契约。 */
  10. function makeWorkspace(): string {
  11. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-scan-'))
  12. const book = path.join(dir, '星辰')
  13. fs.mkdirSync(path.join(book, '作品契约'), { recursive: true })
  14. fs.writeFileSync(path.join(book, '作品契约', '契约.md'), '---\n书id: xingchen-001\n---\n正文\n', 'utf8')
  15. return dir
  16. }
  17. describe('bookshelf 书架扫描', () => {
  18. it('扫描出含契约的子目录为一本书,书id 从 frontmatter 读', () => {
  19. const ws = makeWorkspace()
  20. const books = scanBooks(ws)
  21. expect(books).toHaveLength(1)
  22. expect(books[0]).toMatchObject({ name: '星辰', bookId: 'xingchen-001' })
  23. expect(books[0].root).toBe(path.join(ws, '星辰'))
  24. expect(hasBooks(ws)).toBe(true)
  25. })
  26. it('空工作范围:无书', () => {
  27. const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-scan-empty-'))
  28. expect(scanBooks(ws)).toEqual([])
  29. expect(hasBooks(ws)).toBe(false)
  30. })
  31. it('无契约的子目录不算书', () => {
  32. const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-scan-nocontract-'))
  33. fs.mkdirSync(path.join(ws, '书房'), { recursive: true })
  34. fs.mkdirSync(path.join(ws, '随笔'), { recursive: true })
  35. expect(scanBooks(ws)).toEqual([])
  36. })
  37. it('输出有序(按目录名),确定性', () => {
  38. const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-scan-sort-'))
  39. for (const name of ['乙书', '甲书']) {
  40. fs.mkdirSync(path.join(ws, name, '作品契约'), { recursive: true })
  41. fs.writeFileSync(path.join(ws, name, '作品契约', '契约.md'), '---\n书id: x\n---\n', 'utf8')
  42. }
  43. expect(scanBooks(ws).map((b) => b.name)).toEqual(['乙书', '甲书'])
  44. })
  45. })
  46. describe('paths 当前书路径解析', () => {
  47. const roots = new Map([
  48. ['book-a', 'D:/repo/书仓/甲'],
  49. ['book-b', 'D:/repo/书仓/乙'],
  50. ])
  51. const rootOf = (id: string): string | undefined => roots.get(id)
  52. it('书id+仓内相对路径 → 仓内绝对路径', () => {
  53. const r = resolveToBookPath(rootOf, 'book-a:大纲/故事骨架.md')
  54. expect(r.ok).toBe(true)
  55. if (r.ok) expect(r.path).toContain('书仓')
  56. })
  57. it('裸相对路径拒绝(PRD §4.7)', () => {
  58. const r = resolveToBookPath(rootOf, '大纲/故事骨架.md')
  59. expect(r.ok).toBe(false)
  60. if (!r.ok) expect(r.reason).toContain('裸相对路径拒绝')
  61. expect(parseBookPathInput('大纲/故事骨架.md').kind).toBe('bare-relative')
  62. })
  63. it('未知书id 拒绝', () => {
  64. const r = resolveToBookPath(rootOf, 'ghost:作品契约/契约.md')
  65. expect(r.ok).toBe(false)
  66. if (!r.ok) expect(r.reason).toContain('未知书目')
  67. })
  68. it('逃逸(../)拒绝', () => {
  69. const r = resolveToBookPath(rootOf, 'book-a:../../outside.md')
  70. expect(r.ok).toBe(false)
  71. if (!r.ok) expect(r.reason).toContain('逃逸')
  72. })
  73. it('绝对路径:直接放行由门禁再判', () => {
  74. const absolute = process.platform === 'win32' ? 'D:/repo/书仓/甲/大纲/骨架.md' : '/repo/书仓/甲/大纲/骨架.md'
  75. const r = resolveToBookPath(rootOf, absolute)
  76. expect(r.ok).toBe(true)
  77. if (r.ok) expect(r.path).toContain('大纲/骨架.md')
  78. })
  79. })
  80. describe('status-context 状态注入', () => {
  81. it('renderOverview:空工作区给出新建入口', () => {
  82. const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-status-'))
  83. const text = renderOverview(ws)
  84. expect(text).toContain('【工作区总览】')
  85. expect(text).toContain('目前还没有书')
  86. expect(text).toContain('新建一本书')
  87. })
  88. it('renderOverview:有书列出书名与书id', () => {
  89. const ws = makeWorkspace()
  90. const text = renderOverview(ws)
  91. expect(text).toContain('《星辰》')
  92. expect(text).toContain('xingchen-001')
  93. expect(text).toContain('继续写这本书')
  94. })
  95. it('renderCurrentBook:选书回复包含书名、书id 与近况', () => {
  96. const text = renderCurrentBook('星辰', 'xingchen-001', '当前推进:第1卷·第2章 定稿准备与沉淀')
  97. expect(text).toContain('现在写的是《星辰》')
  98. expect(text).toContain('xingchen-001')
  99. expect(text).toContain('当前推进')
  100. })
  101. it('attach:书架事实每次从文件读取,不需要会话记录', () => {
  102. let registered: { name: string; order: number; text: (a: unknown) => string } | null = null
  103. const fakeCtx = {
  104. systemPrompt: {
  105. context: (c: unknown) => {
  106. const entry = c as NonNullable<typeof registered>
  107. if (entry.name === 'webnovel.status') registered = entry
  108. },
  109. },
  110. inject: (_deps: unknown, cb: (scope: unknown) => void) => {
  111. cb(fakeCtx)
  112. return { dispose: () => Promise.resolve() }
  113. },
  114. }
  115. const ws = makeWorkspace()
  116. const deps = {
  117. workspaceRoot: () => ws,
  118. }
  119. const dispose = attachStatusToAgent(fakeCtx as never, deps)
  120. expect(dispose).toBeDefined()
  121. expect(registered).not.toBeNull()
  122. expect(registered!.name).toBe('webnovel.status')
  123. expect(registered!.text({})).toContain('【工作区总览】')
  124. fs.mkdirSync(path.join(ws, '夜航', '作品契约'), { recursive: true })
  125. fs.writeFileSync(path.join(ws, '夜航', '作品契约', '契约.md'), '---\n书id: yehang-002\n---\n')
  126. expect(registered!.text({})).toContain('《夜航》')
  127. expect(dispose).toBeDefined()
  128. })
  129. })
  130. describe('workspace 装机', () => {
  131. it('校验:普通目录通过', () => {
  132. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-ws-'))
  133. const r = validateWorkspacePath(dir, () => undefined, () => false)
  134. expect(r.ok).toBe(true)
  135. })
  136. it('校验:本身是 git 仓被拒', () => {
  137. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-wsgit-'))
  138. fs.mkdirSync(path.join(dir, '.git'))
  139. const r = validateWorkspacePath(dir, () => undefined, () => false)
  140. expect(r.ok).toBe(false)
  141. if (!r.ok) expect(r.reason).toContain('git 仓')
  142. })
  143. it('校验:落在别人 git 仓内部被拒', () => {
  144. const outer = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-outer-'))
  145. fs.mkdirSync(path.join(outer, '.git'))
  146. const inner = path.join(outer, 'child')
  147. fs.mkdirSync(inner)
  148. const r = validateWorkspacePath(inner, () => undefined, () => false)
  149. expect(r.ok).toBe(false)
  150. if (!r.ok) expect(r.reason).toContain('git 仓内部')
  151. })
  152. it('校验:已是 workspace 或位于其下被拒', () => {
  153. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-wsowned-'))
  154. expect(validateWorkspacePath(dir, () => ({ owned: true }), () => false).ok).toBe(false)
  155. const inner = path.join(dir, 'sub')
  156. fs.mkdirSync(inner)
  157. expect(validateWorkspacePath(inner, () => undefined, () => true).ok).toBe(false)
  158. })
  159. it('书房脚手架:建出四目录且幂等', () => {
  160. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-study-'))
  161. const created = scaffoldStudy(dir)
  162. expect(created.length).toBe(STUDY_DIRS.length)
  163. for (const d of STUDY_DIRS) {
  164. expect(fs.statSync(path.join(dir, '书房', d)).isDirectory()).toBe(true)
  165. }
  166. // 幂等:再次调用不抛且目录仍在
  167. scaffoldStudy(dir)
  168. for (const d of STUDY_DIRS) {
  169. expect(fs.statSync(path.join(dir, '书房', d)).isDirectory()).toBe(true)
  170. }
  171. })
  172. })
  173. describe('progressLineOf 选书近况(一行固定形状,只扫当前卷)', () => {
  174. it('有章节动静时输出固定形状一行;连续两次求值完全相同', () => {
  175. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-progress-'))
  176. // 构造有近期窗口+候选细纲的最小书仓
  177. fs.mkdirSync(path.join(dir, '大纲', '卷规划', '卷01'), { recursive: true })
  178. fs.writeFileSync(path.join(dir, '大纲', '卷规划', '卷01', '近期窗口.md'), '# 近期窗口\n\n- 第一章 〔已确认〕\n\n', 'utf8')
  179. fs.mkdirSync(path.join(dir, '草稿区', '章细纲'), { recursive: true })
  180. fs.writeFileSync(path.join(dir, '草稿区', '章细纲', '卷01-第一章.md'), '---\n状态: 候选\n---\n', 'utf8')
  181. const line = progressLineOf(dir)
  182. expect(line).toBe('卷01 第0000章 第一章 · 章细纲(待确认)')
  183. expect(progressLineOf(dir)).toBe(line)
  184. })
  185. it('无章节规划时给出推进提示', () => {
  186. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-progress-empty-'))
  187. const line = progressLineOf(dir)
  188. expect(line).toContain('尚无已规划章节')
  189. })
  190. })