# tests — 单元测试与夹具说明 ## 运行 packager 需在 `package.json` 声明(QA 只声明,不写 package.json): ```jsonc { "scripts": { "test": "vitest run" }, "devDependencies": { "vitest": "^3.0.0" } } ``` ```bash npm install # packager 负责 npx vitest run # 或 npm test ``` Node ≥ 22(使用 `node:sqlite`,ExperimentalWarning 属预期)。本仓库测试目标环境即宿主 Node 22.23.2。 ## 目录 ``` tests/ fixtures/ # 测试夹具(ground truth,由 dsh 真实实现验证生成) session-basic.json # 真实会话结构:中英混合 turn/step/tool/todo/error/chunk/title session-compaction.json # 压缩检查点:surface 替换,shadowed/current 分类 event-cases.json # 每类事件抽取与锚点键期望(dsh extractSessionEventText 验证) tokenizer-cases.json # 分词规则用例(合法 token 集合断言) unit/ # vitest 用例 fixtures.test.ts # 夹具完整性(不依赖实现,恒运行) tokenizer.test.ts # tokenize + buildFtsQuery extract.test.ts # extractSessionEventText + contentText/joinText anchor.test.ts # anchorKeyFor indexer.test.ts # 建库/插入/增量/压缩重建/删除/持久化 search.test.ts # 二段式搜索全部语义 + computeSpans/buildSnippet degrade.test.ts # node:sqlite 降级路径 + 静态守卫 helpers/load-module.ts README.md ``` ## 夹具来源(ground truth 验证) `fixtures/*.json` 由脚本生成(脚本本身不入库),生成时对**每个事件**调用 dsh 真实实现验证: - `@deepseek-ai/dsh-session-query` 的 `extractSessionEventText` → `expectedText` - `@deepseek-ai/dsh-session` 的 `foldSurface`(surface 分类 current/shadowed/log-only)→ records.surface - 锚点键公式 → records.anchorKey / expectedAnchorKey 事件结构与 `session.jsonl.zstd` 解压结果一致:`session` 头 + events(`seq` 从 0 连续,`events[i].seq === i`);表面事件(user/message、assistant/message、tool/result)带 `surfaceOp`;压缩检查点 = `user/message` + `data.source = { kind: 'plugin', plugin: 'compact' }` + `surfaceOp = { op:'replace', start, end }` + `sourceEventSeqs`(dsh 0.1.1 实测:未观察到 `type:'compaction'` 事件,任务书中的 compaction 事件形状以 `event-cases.json` 的 `compaction-event` 用例兜底,其抽取语义为 `""`)。 ## QA 测试契约(implement-host 必须对齐 · 改动需经 captain/architect 确认) 测试按以下模块接口编写;**与实现不符时以本契约为准修正实现,或由 captain 拍板改契约**。缺失导出的用例会显式失败(不会静默通过)。 ### src/tokenizer.ts ```ts export function tokenize(text: string): string[] export function buildFtsQuery(text: string): string // 每个 token 双引号包裹、空格分隔(FTS5 隐式 AND)、全部小写 ``` 规则:中文(Intl.Segmenter('zh') 词 之外,必须含)CJK 单字 + 相邻二元组;英文/数字按 `\p{L}+|\p{N}+` 切分;ASCII token 小写;token 不得含标点/空白/FTS 元字符;不重复;空串/纯空白 → `[]`。CJK 段分词词(如"查找")必然被单字/二元组规则覆盖,测试按集合断言。 ### src/extract.ts ```ts export function extractSessionEventText(event): string export function contentText(content): string export function joinText(parts: string[]): string export function anchorKeyFor(event): string | null ``` 语义逐字节复刻 dsh(见 event-cases.json 期望,全部经 dsh 真实实现验证):joinText = trim → filter(Boolean) → join("\n");contentText:text 块取 text、reasoning 跳过、tool-call 取 [name, arguments]、tool-result 递归;锚点键四类公式(user/message→`13:input-message{id}`、assistant/message→`14:assistant-step{turn}:{step}`、tool/call→`9:tool-call{callId}`、tool/result→`9:tool-call{message.source.callId}`、其余 null)。 ### src/indexer.ts ```ts export interface IndexRecord { sessionId: string; seq: number; type: string; time: number; surface: 'current' | 'shadowed' | 'log-only'; text: string; anchorKey: string | null; } export interface SearchHit { sessionId: string; sessionTitle: string; seq: number; type: string; role: 'user' | 'assistant' | 'tool' | string | null; time: number; anchorKey: string | null; text: string; snippet: string; spans: Array<[number, number]>; matchCount: number; textTruncated: boolean; } export function createSearchIndex(options?: { dbPath?: string; // 默认 ${DSH_HOME||~/.dsh}/storages/memory-search/index.db;支持 ':memory:' sqliteModule?: unknown; // 测试注入;缺省时内部动态导入 node:sqlite(try 包裹) maxIndexBytes?: number|null; // 逻辑容量预算;0/null=不限量 }): SearchIndex export interface SearchIndex { ready: boolean; degraded: boolean; status(): { ready: boolean; indexedSessions: number; indexedDocs: number; building: boolean; degraded: boolean; storageBytes?: number; storageLimitBytes?: number|null; prunedDocs?: number; coverageSince?: number|null }; upsertRecords(records: IndexRecord[], rebuildSessionIds?: string[]): void | Promise; removeSession(sessionId: string): void | Promise; search(query: string, options?: { limit?: number; surface?: 'current' | 'shadowed' | 'log-only' | 'all' }): { ok: boolean; hits: SearchHit[]; truncated: boolean }; close(): void; sessionState(sessionId: string): SessionStateRow | undefined; markSessionDirty(sessionId: string): void; clearSessionDirty(sessionId: string): void; advanceSessionWatermark(sessionId: string, maxSeq: number): void; listSessionIds(): string[]; beginBatch(): void; endBatch(): void; } export function upsertRecordsCooperatively(index, records, rebuildSessionIds?, batchSize?): Promise ``` 关键语义(测试即验收): - upsert 以 `(sessionId, seq)` 幂等;新增 seq 不重复计数。 - 低于当前 max_seq 的乱序补写若原键不存在,仍必须正确增加 docs;批次失败不得让内存计数镜像与 SQLite 漂移。 - 空/纯结构会话也保留 `session_state`;`advanceSessionWatermark` 可在无可索引文本时推进原始事件水印。 - `rebuildSessionIds` 指定整会话重建:**丢弃该会话 surface==='shadowed' 的记录**(压缩替换),保留 current/log-only。**首次全量建库(filterEvents 全量记录)同样应以 rebuildSessionIds 传入**(与增量压缩路径一致)。 - `:memory:` 与文件路径都支持;close 后重开同一路径数据仍在(WAL 持久化)。 - role 映射(B6 定案):user/message→user、assistant/message→assistant、tool/call|tool/result→tool,**其余 → 'other'**(client-eng t7 §6 复核确认;客户端 roleOf 亦重推导为 'other')。 - schema v3:`ftsevents` 必须为 `contentless_delete=1, detail=none`,正文与元数据只在 `docs` 表保存;v1/v2 迁移保留可搜索数据并删除 `event_keys`。 - 默认逻辑容量预算 1 GiB;超限按时间淘汰最旧派生行,但保留 `session_state.max_seq`,不得删除原始会话。 - 长用户/Agent 文本最多 8,192 字符,工具/错误文本最多 4,096 字符;截断时保留头尾并设置 `textTruncated=true`。 语义细化(与 architect ADR-6/§5.2-6 对齐,已由测试锁定): - **两层 spans 语义**:`SearchHit.spans` = 相对 **text** 的字符偏移(测试以 `h.text.slice(s, e)` 断言);`buildSnippet` 返回值的 `spans` = 相对 **snippet**(测试以 `r.snippet.slice(s, e)` 断言)。命中层保留原偏移供高亮,纯函数层归一化到摘要。 - `matchCount` = 响应 text 上合并后的 span 数(非全文本出现次数);测试仅断言 ≥1 与单次命中 =1。 - `sessionTitle`:indexer 层以 `sessionId.slice(0, 8)` 回退填充(测试只断言非空);RPC 层用 `readTitleSnapshots` 升级标题。 - `buildFtsQuery`:≥2 字符 token 必须进入查询;CJK 单字 token **推荐包含**(测试允许实现取舍,评审按推荐值检查)。 ### src/search.ts ```ts export function computeSpans(text: string, query: string): Array<[number, number]> // 大小写不敏感、字符偏移、不重叠贪婪 export function buildSnippet(text: string, spans: Array<[number, number]>, radius?: number /* 默认 120 */): { snippet: string; spans: Array<[number, number]>; start: number } // spans 相对 snippet ``` 二段式搜索入口为 `SearchIndex.search`:阶段 1 FTS token AND 召回,按时间桶 rowid 倒序从 128 候选开始自适应扩张,最多 2,048 候选并受 100 ms 阶段预算约束 → 阶段 2 `text.toLowerCase()` 按查询字面片段做 AND 校验(中文连续串不能被重排 token 冒充)→ 计算 spans/snippet → 精确按 `time DESC, seq DESC` 排序,默认返回 100、最多 200 条。未穷尽候选或命中超过 limit 时 `truncated=true`。 ### 降级 - 禁止 `static import ... from 'node:sqlite'`(indexer.ts / index.ts);必须 try 包裹动态导入。 - `sqliteModule: null` → degraded 内存索引:status.degraded=true,索引/搜索可用,不持久化,插件可加载。 ### Client 纯函数(src/client/panel.tsx、src/client/rpc.tsx,语义已锁定) ```ts // panel.tsx export function roleOf(hit: SearchHit): 'user' | 'assistant' | 'tool' | 'other' // role(空则回退 type)小写后 user/assistant/tool 前缀 → 对应,其余 'other' export function groupHits(hits: readonly SearchHit[]): HitGroup[] // 按 sessionId 聚合(命中非连续也可以);组序 = 组内 max time DESC,tie 按 sessionId 升序;组内保持全局扫描序 export function shiftSpans(spans, base, length): [number, number][] // [max(0,s-base), min(length,e-base)],end<=start 丢弃 export function mergeRanges(ranges): [number, number][] // 倒置归一 → 排序 → 重叠/相邻合并 export function snippetView(hit): { text: string; spans: [number, number][] } // spans 是相对 text 的偏移;窗口 = 首 span ±120(延展覆盖尾 span)切片 text,spans 平移到窗口并合并。 // 宿主 snippet 仅在空 text/无 spans 时使用,不作为对齐基准(final contract:hit 不带 snippet 窗口元数据) // rpc.tsx // rpc.tsx(Remote 通道) export type RemoteMethod = 'search' | 'status' | 'reindex' | 'setDbPath' export interface RemoteMemorySearch { search(args: { query: string; limit?: number }): Promise status(): Promise reindex(): Promise setDbPath(args: { path: string }): Promise } export async function callRemote(remote: RemoteMemorySearch, method: RemoteMethod, args?: unknown): Promise // 分发到 remote.search(args)/status()/reindex()/setDbPath({path});远端拒绝原样传播(不吞错);无默认分支 export function normalizeSearch(payload: unknown): SearchResult // ok=false→error(含 errorCode);null/空 anchorKey 保留(''),sessionId==='' 丢弃;畸形 span 过滤; // snippet 缺省回退 text;matchCount 缺省 1 export function normalizeStatus(payload: unknown): IndexStatus // ok=false→error;degraded→degraded;building→indexing;ready→ready; // ready=false 且非 degraded → indexing(“索引初始化中”);无契约字段→unknown(备用 phase/status 拼写兜底) // 透传 dbPath / defaultDbPath(缺省为 null) ``` > host 契约与 client 契约的衔接点:host 返回 `spans`(相对 text)+ `snippet`(±120 窗口);client 用自己的 `snippetView` 从 text+spans 重推导显示窗口(同一 ±120 公式),不依赖 snippet 对齐。`matchCount` = 响应 text 上合并后 span 数。 ## 与实现不一致时的处置 单测要的是**黑盒语义**:若实现正确但契约细节不同(如导出名 `toFtsQuery`、spans 是 snippet 相对偏移),由审查任务(终验)对照本契约记录差异,修改实现或由 captain 拍板修订本文件后再跑全量。跳过(skip)的套件必须视为审查阻塞项,不得在 REVIEW.md 记 pass。