test(priv): T1 用例基线 92 条——100% 覆盖 FR-PRIV 调整内容 + 双向追溯矩阵 + 测试骨架
TL 复查达标放行:92 defined = 92 used、unmatched 0、unreferenced 0; 假设 1/9 已按实施方案 §1.2/§3.2 裁决修订(表格原位聚合/outline 不参与 切块/跨轮归属行为级判据)。骨架:test_chunks.py 416 行 + vitest 4 文件。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
# 私有资料问答(FR-PRIV)测试用例表 · TEST_CASES_PRIVATE.md
|
||||
|
||||
> docs/team/TEST_CASES_PRIVATE.md | T1(测试 agent)编制,TL 复查达标后方可进入测试执行。
|
||||
> 权威规格:docs/PRD.md §17(FR-PRIV-001~008)、docs/UI_SPEC.md §9、docs/PRIVATE_DOCS_TEST_PLAN.md。
|
||||
> 实现契约:docs/team/brief_priv_a1.md(A1 任务书,含全部签名/常量/JSON 契约)。
|
||||
> 基线:feature/bioflow-conversation-orchestration @ dea4640(编制时)。
|
||||
> 修订记录:v1.1(TL 复查裁决)——假设 1 替换为实施方案 §1.2 语义(表格原位聚合、outline 不参与切块);假设 9 改行为级判据;TC-PRIV-L1-05/L1-07 修订并新增 L1-07b(outline 不影响输出)、L1-42(跨轮归属)。用例总数 90 → **92**,追溯矩阵已同步。
|
||||
|
||||
## 0. 约定
|
||||
|
||||
- **编号**:TC-PRIV-{L层}-{序号},L层 ∈ L1/L2/L2s/L3/L4。
|
||||
- **判据**:每条含可验证判据(PASS 条件);与 A1 任务书契约对齐。
|
||||
- **假设(编制时 A1 未交付,按任务书契约推断,TL 复查裁决)**:
|
||||
1. 【TL 裁决,以实施方案 §1.2 为准】`build_chunks` 语义:表格矩阵化段(`|a|b|` 行式)**插入段落流原位**参与聚合切块(废除「末尾 table 单独块」);**outline 不参与 chunk 切块**(chunk 正文保持纯净,标题上下文仅由首块前缀 `# {title}` 提供;签名保留 outline 参数,A1 实现为忽略或仅取用 title)。
|
||||
2. 长段落(> TARGET_CHARS)整段不切,独占一块(单块可超 600 字);空正文(paragraphs/tables 全空,outline 不参与切块)产出 0 块且不报错。
|
||||
3. `ChunkIndex.load()` 对 index.json / texts.jsonl / index.faiss 三文件中任一缺失或损坏:按空库降级(可服务,不抛异常);`rebuild_all()` 覆盖三文件(tmp+os.replace 原子切换)。
|
||||
4. `ChunkIndex` 返回的 `snippet` 为 `text[:200]`。
|
||||
5. `embed_batch_chunked` 批量失败按「批」重试:整批重试一次,仍失败该批全部降级零向量;并发上限 = `workers=4`(线程数)。
|
||||
6. `search-chunks` 无降级档:低于 `min_score` 全部丢弃 → `mode:"empty", items:[]`(空库同)。
|
||||
7. `rebuild-chunks` 期间 /health、/search-chunks 可服务:由「重建不使用 HTTP(CLI 子进程)」保证(L2s 用例验证)。
|
||||
8. `/api/qa/private` 三态:命中 200 / 未命中 200 / vstore 不可达 503 / LLM 失败 502;未命中与错误信封前端一律静默。
|
||||
9. 【TL 裁决,以实施方案 §3.2 为准】前端 `_firePrivate` 本轮上下文校验实现 = `streamCtx[sessionId].aMsg` 引用相等;用例判据写**行为级**(「非本轮私有结果不得串挂」),不锁内部实现。`_flushPrivate` 只清 `_privatePending`(A1 任务书已批准设计)。
|
||||
10. `removeLibrary(id)` 在 http.js `del` 基础上自行处理 204 空体并返回 `{ok:true}`。
|
||||
- **测试环境**:vstore pytest 用 `vstore/tests/conftest.py`(固定 Fernet key + 假 embed);BFF 用 MockTransport 注入 client / monkeypatch 服务函数;前端用 vitest(jsdom)+ vi.mock;L2/L2s/L4 用 curl / 浏览器(TL 部署后执行)。
|
||||
- **纪律**:本文件与测试骨架之外不改任何产品代码;不 commit、不部署。
|
||||
|
||||
## 1. 分层用例
|
||||
|
||||
### L1 单元测试(vstore chunks / embed / vault、BFF privateqa / schemas、前端 store 逻辑)
|
||||
|
||||
#### L1-chunks(build_chunks 切块与 ChunkIndex)
|
||||
|
||||
| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-PRIV-L1-01 | FR-PRIV-002 | vstore/chunks.py `build_chunks` | tmp_path;模块导入 | 构造 8 段短段落(每段 < 600 字)直接调用 | title="报告", outline=[], paragraphs=[p1..p8 短段], tables=[] | 恰好 1 块;text 首行 = "# 报告";块内含全部 8 段 | 单块达到 8 段上限(MAX_PARAS)即封块;len(chunks)==1;chunks[0].seq==0 |
|
||||
| TC-PRIV-L1-02 | FR-PRIV-002 | build_chunks 字符封块 | tmp_path | 段落总长 ≥ 600 字(如 7 段×90 字) | title="T", paragraphs=7 段×90 字 | ≥1 块;先满 600 字即封块(不要求满 8 段) | 存在块 text 长度 ≥ 600;块数 > 1 时相邻块正确切分 |
|
||||
| TC-PRIV-L1-03 | FR-PRIV-002 | build_chunks 重叠段 | tmp_path | 构造 12 段 → 应产出 2 块 | title="T", paragraphs=p1..p12 | 块2 首段 == 块1 末段(重叠 1 段) | chunks[1].text 以 chunks[0] 末段开头;无重复段顺序错误 |
|
||||
| TC-PRIV-L1-04 | FR-PRIV-002 | build_chunks 200 块上限 | tmp_path | 构造 > 200×600 字的超长输入 | 300 段×700 字 | 恰好 200 块;seq 0..199 | len(chunks)==200;MAX_PER_DOC 截断生效;不抛异常 |
|
||||
| TC-PRIV-L1-05 | FR-PRIV-002 | build_chunks 表格原位矩阵化聚合(TL 裁决版) | tmp_path;段落流中夹表格(与实施方案 §1.2 对齐) | 构造段落-表格-段落混合流,直接调用 | title="T", paragraphs=["前置段落"*N, "后续段落"*M], tables=[2×2 表](表格位于段落流中间) | 表格矩阵化为 `\|a\|b\|` 行式文本段,**按原位**参与聚合切块;输出块序反映原始位置(表格段在相邻段落之间) | 表格行式文本出现在块中且前后位置与输入流一致;废除「末尾 table 单独块」 |
|
||||
| TC-PRIV-L1-06 | FR-PRIV-002 | build_chunks 首块标题前缀 | tmp_path | 任意 1 段正文 | title="小麦试验", paragraphs=[p] | 首块 text 以 "# 小麦试验" 开头 | chunks[0].text.startswith("# 小麦试验") |
|
||||
| TC-PRIV-L1-07 | FR-PRIV-002 | build_chunks 空输入 | tmp_path | 全空输入(outline 不参与切块) | title="", paragraphs=[], outline=[], tables=[] | 返回 [](不抛异常) | len(chunks)==0;chunk 失败不阻断 doc 级(配合 TC-PRIV-L1-19) |
|
||||
| TC-PRIV-L1-07b | FR-PRIV-002 | build_chunks outline 不参与切块(TL 裁决版) | tmp_path;实施方案 §1.2 | 输入相同 paragraphs + 两组不同 outline([A1,A2] 与空 []) | title="T", paragraphs=p1..p3, outline 两版对比 | 两次调用产出的 chunk 集合**逐块 text 完全一致**(outline 不影响 chunk 输出) | 两版输出逐块相等;chunk 正文中不含 outline 文本 |
|
||||
| TC-PRIV-L1-08 | FR-PRIV-002 | ChunkIndex upsert/查询 | tmp_path;假 embed | 新建 ChunkIndex → upsert_document(doc_id, items) → rows()/count() | 3 块 doc"A" | count()==3;rows 含 chunk_id/seq/snippet/doc_name | chunk_id == "A:0"…"A:2";字段齐备 |
|
||||
| TC-PRIV-L1-09 | FR-PRIV-002 | ChunkIndex upsert 替换语义 | tmp_path | 先 upsert A(3 块) 再 upsert A(2 块) | 同 doc_id 二次 upsert | count()==2;旧 3 行全部消失(替换非追加) | A 行数 ==2;旧 seq 行不存在 |
|
||||
| TC-PRIV-L1-10 | FR-PRIV-002 | ChunkIndex remove_document | tmp_path | upsert A+B 后 remove("A") | doc_ids=["A"] | count()==B 块数;A 行全消失;不存在返回 False | remove 返回 True;再 remove("A") 返回 False |
|
||||
| TC-PRIV-L1-11 | FR-PRIV-002 | ChunkIndex 原子写 | tmp_path | upsert 后检查三文件;再造 ChunkIndex(path).load() 重载 | 3 块 | index.json/texts.jsonl/index.faiss 三文件存在;重载后 count()==3 | 重载行与写入一致;目录内无 .tmp 残留(tmp+os.replace) |
|
||||
| TC-PRIV-L1-12 | FR-PRIV-002 | ChunkIndex 阈值/top_k/doc_ids/exclude_template | tmp_path;可控分数向量 | 造高分/低分两组向量行 | search(q_vec, top_k=2, min_score=0.30, doc_ids=[...], exclude_template=True) | 仅 ≥0.30 命中;≤2 条;doc_ids 白名单过滤生效;as_template 行被排除 | 命中集合、条数、过滤三判据同时满足 |
|
||||
| TC-PRIV-L1-13 | FR-PRIV-002 | ChunkIndex 低于阈值→empty | tmp_path | 查询向量与所有行内积 < 0.30 | search(..., min_score=0.30) | mode=="empty", items==[](无降级档) | mode 为 "empty" 且 items 为空 |
|
||||
|
||||
#### L1-embed(批量嵌入)
|
||||
|
||||
| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-PRIV-L1-14 | FR-PRIV-002 | vstore/embed.py `embed_batch_chunked` | monkeypatch 注入假请求钩子(记 batch 调用与输入形态) | 注入 _request_batch(返回假向量);调 embed_batch_chunked | 40 条文本, batch_size=16, workers=4 | 3 批调用;每批 payload 的 input 是**数组**(非单字符串);返回 40 个 1024 维向量且顺序保持 | 批次数 == ceil(40/16)==3;每批 input 为 list;len(results)==40;顺序可验证 |
|
||||
| TC-PRIV-L1-15 | FR-PRIV-002 | embed_batch_chunked 并发上限 | monkeypatch 记录并发峰值 | 钩子里计数器模拟执行耗时 | 33 条文本, workers=4 | 并发峰值 ≤4;结果为 3 批 | 计数器峰值 ==4(或 ≤4) |
|
||||
| TC-PRIV-L1-16 | FR-PRIV-002 | embed_batch_chunked 单批重试 | monkeypatch:首调抛错、二调成功 | 第 2 批首调失败 | 20 条文本 | 该批重试 1 次成功;总批次数 2+1(重试)+1;结果全有向量 | 重试计数 ==1;该批向量非零向量 |
|
||||
| TC-PRIV-L1-17 | FR-PRIV-002 | embed_batch_chunked 降级零向量 | monkeypatch:某批两次全失败 | 第 1 批两次失败 | 20 条文本 | 失败批全部零向量;其余批正常;函数不抛 | results[0:batch] 全 [0.0]*1024;其余非零;不抛异常 |
|
||||
|
||||
#### L1-vault(钩子)
|
||||
|
||||
| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-PRIV-L1-18 | FR-PRIV-002 | vault.py `add()` chunk 钩子成功路径 | monkeypatch:chunks.embed_batch_chunked 抛 EmbedError(或 build_chunks 被 patch 抛) | vault.add() 正常入库 | 含正文的 txt | doc 级正常返回;返回体含 chunk_count==chunks 数、chunk_ok==True、chunk_error=="" | 返回体三字段存在且 chunk_ok 为 True |
|
||||
| TC-PRIV-L1-19 | FR-PRIV-002 | vault.py chunk 失败不阻断 doc 级 | monkeypatch embed_batch_chunked 抛异常 | vault.add() | 含正文的 txt | doc 级照常入库(document_id/enc 存在);chunk_ok==False;chunk_count==0 或实际数;chunk_error 非空 | doc 级成功 + chunk_ok==False + chunk_error 非空 |
|
||||
| TC-PRIV-L1-20 | FR-PRIV-006 | vault.py `delete()` chunk 联动删除 | 先 add 产生 chunk 行 | vault.delete(doc_id) | doc_id | 主索引删除成功;chunk 索引该 doc 行消失 | chunks.count() 回到删除前数量;doc 级 removed==True |
|
||||
| TC-PRIV-L1-21 | FR-PRIV-007 | vault.py `rebuild_chunks()` | 先 add 2 条;chunks.clear() 后调 rebuild_chunks | rebuild_chunks() | 无 | 返回 {docs, chunks, duration_ms};chunk 行重建(≥1) | docs==2;chunks>0;数字字段类型正确 |
|
||||
|
||||
#### L1-BFF(privateqa / schemas / llm)
|
||||
|
||||
| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-PRIV-L1-22 | FR-PRIV-004 | services/privateqa.py `ask_private` 命中链路 | monkeypatch search_chunks 返回 2 doc 4 块;complete_text 返回带 [资料n] 文本 | await ask_private("问") | question | hit==True;answer 非空;items 带 ref;elapsed_ms 数字 | items[].ref 为编号(1..n);answer 含 [资料1] |
|
||||
| TC-PRIV-L1-23 | FR-PRIV-004 | ask_private 空结果 | monkeypatch search_chunks 返回 {ok:true,items:[]} | await ask_private | question | {hit:false, mode:"empty", answer:"", items:[]};不调 LLM | 返回体一致且 complete_text 未被调用 |
|
||||
| TC-PRIV-L1-24 | FR-PRIV-004 | ask_private 每 doc≤3 去重 | 5 块同 doc + 2 块另一 doc;top_k=8 | await ask_private | question | items 每 doc ≤3 块;同 doc 按 score 序取前 3 | 逐 doc 计数 ≤3;score 递减 |
|
||||
| TC-PRIV-L1-25 | FR-PRIV-004 | ask_private 12k 截断 | 拼出的上下文字符数 > 12000 | await ask_private | 大 snippet | 传给 complete_text 的 user 内容 ≤ 12000 字符;不抛异常 | len(user)<=12000;可含截断标记 |
|
||||
| TC-PRIV-L1-26 | FR-PRIV-004 | ask_private LLM 40s 超时 | monkeypatch complete_text 捕获 kwargs | await ask_private | question | complete_text 被以 timeout=40 调用;其他参数(temperature/max_tokens/system 约束)符合任务书 | 调用参数断言:timeout==40;system 含「只能依据资料回答,不得编造」与「资料中未找到相关信息」 |
|
||||
| TC-PRIV-L1-27 | FR-PRIV-004 | ask_private 异常类型 | 三次 monkeypatch:search_chunks 抛 DocgenUnavailable;search_chunks 抛 Exception;complete_text 抛 LLMError | await ask_private(pytest.raises) | question | DocgenUnavailable / Exception 向上抛(不吞);LLMError 向上抛 | 抛出的异常类型与注入一致(路由转 503/502 信封) |
|
||||
| TC-PRIV-L1-28 | FR-PRIV-005 | schemas.py union 校验 | TypeAdapter/MessageCreate 构造含 private_doc 块的消息 | MessageCreate.model_validate / TypeAdapter(list[Block]) | blocks=[{type:"private_doc", answer, items:[...]}] | 校验通过;类型解析为 PrivateDocBlock;items 为 PrivateDocItem 列表 | 不抛 ValidationError;判别 type=="private_doc" |
|
||||
| TC-PRIV-L1-29 | FR-PRIV-005 | schemas.py extra 未知字段 | PrivateDocItem 带未知字段 | model_validate | items 含 {"x_unknown":1} | 未知字段被忽略不报错;已知字段齐全 | extra="allow" 行为;无异常 |
|
||||
| TC-PRIV-L1-30 | FR-PRIV-004 | services/docgen/llm.py timeout=None 兼容 | monkeypatch _chat_once 捕获 timeout 参数 | await complete_text(..., timeout=None) / timeout=40 | 两次调用 | None → 走 env 现值(DOCGEN_LLM_TIMEOUT 或默认 300);40 → 透传 40 | 两次捕获 timeout 分别 ==env 现值 / 40;docgen 既有调用零影响 |
|
||||
| TC-PRIV-L1-31 | FR-PRIV-004 | services/docgen/client.py `search_chunks` | MockTransport 注入 httpx client | await search_chunks("q", top_k=8, min_score=0.30, timeout=12.0) | q | GET /api/vstore/search-chunks 参数带 q/top_k/min_score;返回 dict;不可达抛 DocgenUnavailable | 请求路径与参数断言;异常类型断言 |
|
||||
|
||||
#### L1-front(chat store 竞态)
|
||||
|
||||
| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-PRIV-L1-32 | FR-PRIV-003 | chat.js `_firePrivate` 命中/未命中/异常三态静默 | vi.mock api/private.js;pinia + 假会话(沿用 b2 chat.store.test.js 风格) | 三次 send():askPrivate 分别返回命中/未命中/reject | 同一提问 | 命中:aMsg 最终含 private_doc 块;未命中/reject:无块、无报错、QA 流程不受影响;三次均无 console.error 级用户可见错误 | 块存在性 + 无异常抛出 + lastResult 与既有 QA 断言一致 |
|
||||
| TC-PRIV-L1-33 | FR-PRIV-005 | chat.js 流未收口不丢块(pending) | 命中响应在 QA complete 之前 resolve | send()(_runStream 路径) | 提问 | 响应先到 → 暂存 `aMsg._privatePending`;onComplete 整体替换 blocks 后 flush → 块仍在 | 终态 blocks 含 private_doc 块;顺序在 pipeline 之后 |
|
||||
| TC-PRIV-L1-34 | FR-PRIV-005 | chat.js onComplete 替换后仍出现 | 命中响应在 QA complete 之后 resolve | send() | 提问 | 替换发生后才 resolve:直接挂块 + 补 _persistFinal;终态含块 | 块存在且恰好 1 个(防重入) |
|
||||
| TC-PRIV-L1-35 | FR-PRIV-005 | chat.js error 路径 flush | QA 流 onError(如 UPSTREAM_DOWN) | send() | 提问 | error 终态下私有命中块仍被挂上(flush 覆盖两条 error 路径) | 终态 blocks 含 private_doc 块;消息 status=="error" 共存 |
|
||||
| TC-PRIV-L1-36 | FR-PRIV-005 | chat.js `_appendPrivate` 防重入 | 手动调用两次 _appendPrivate(aMsg, payload) | 直接调 action | payload | blocks 中 private_doc 块仅 1 个 | 第二次调用为 no-op;len==1 |
|
||||
| TC-PRIV-L1-37 | FR-PRIV-005 | chat.js ask/search 两模式覆盖 | 分别走 _runStream 与 _runSearch | send(text, {mode:"ask"}) / send(text, {mode:"search"}) | 提问 | 两模式均触发 _firePrivate(askPrivate 被调 2 次);search 模式 blocks 赋值后含块 | askPrivate 调用次数==2;终态块断言 |
|
||||
| TC-PRIV-L1-38 | FR-PRIV-005 | chat.js retry 覆盖 | 先 error 后 retry() | retry() | 提问 | 重试轮同样触发 _firePrivate;新 aMsg 不残留旧 pending | askPrivate 再次被调;新消息 blocks 正确 |
|
||||
| TC-PRIV-L1-39 | FR-PRIV-005 | chat.js _flushPrivate 只清 pending | 终态 flush 后再次 flush | _flushPrivate(aMsg) 二次调用 | 无 | 块不被追加/清空 | 块仍 1 个;_privatePending 已清 |
|
||||
| TC-PRIV-L1-42 | FR-PRIV-003 | 本轮上下文归属(TL 裁决行为级判据) | 后消息的私有响应在**前消息**终态之后才 resolve(模拟跨轮迟到) | 连续两次 send():第 1 轮 QA 完成且私有未命中终态、第 2 轮进行中,才 resolve 第 1 轮命中响应 | 同一提问 ×2 | 第 1 轮的迟到命中响应**不得**挂到第 2 轮消息上;第 2 轮终态与私有结果不受影响(串挂即 fail) | 第 1 轮响应被丢弃或挂到第 1 轮消息;第 2 轮消息 blocks 不含来自第 1 轮的条目 |
|
||||
| TC-PRIV-L1-40 | FR-PRIV-001 | api/private.js `askPrivate` | vi.mock http.js | askPrivate({question, session_id}) | 参数对象 | POST /api/qa/private;body 含 top_k=8、min_score=0.30;timeout 60s | 请求路径/body/opts 断言 |
|
||||
| TC-PRIV-L1-41 | FR-PRIV-006 | api/docgen.js `removeLibrary` 204 | vi.mock http.js(del resolve 空 payload) | removeLibrary(id) | id | 返回 {ok:true};不抛 JSON 解析错 | 204 空体被正确兜底 |
|
||||
|
||||
### L2 接口测试(vstore 端点 / BFF 端点 / docgen 回归)
|
||||
|
||||
#### L2-vstore
|
||||
|
||||
| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-PRIV-L2-01 | FR-PRIV-002 | GET /api/vstore/search-chunks 命中 | 已 ingest 含正文资料(chunk_count>0) | curl GET /api/vstore/search-chunks?q=… | q=正文关键词, top_k=5 | HTTP 200;{ok:true, mode:"vector", query, count>0, items:[{chunk_id,doc_id,seq,snippet,score,doc_name,doc_category,doc_tags,as_template,created_at}]} | count>0;item 字段全集存在;score ≥ min_score 默认 0.30 |
|
||||
| TC-PRIV-L2-02 | FR-PRIV-002 | search-chunks 未命中 | 库中无相关内容 | q=无关词, min_score=0.30 | q | {ok:true, mode:"empty", count:0, items:[]} | 无降级档(不返回向量库最新 N 条) |
|
||||
| TC-PRIV-L2-03 | FR-PRIV-002 | search-chunks 空库 | 空 chunk 库 | q=任意 | q | {ok:true, mode:"empty", items:[]} | 不 500 |
|
||||
| TC-PRIV-L2-04 | FR-PRIV-002 | search-chunks top_k clamp | 库中 ≥3 相关块 | top_k=0 / top_k=99 | q | 0 → clamp 到 1(或 422);99 → 返回 ≤20 条 | 返回条数 ≤20;异常输入不 500 |
|
||||
| TC-PRIV-L2-05 | FR-PRIV-002 | search-chunks doc_ids 过滤 | 2 个 doc 各有命中 | doc_ids=docA | q | 仅 docA 的 chunk 命中 | 所有 items.doc_id==docA |
|
||||
| TC-PRIV-L2-06 | FR-PRIV-002 | search-chunks exclude_template | 上传 1 模版(as_template=true)含关键词 | exclude_template=true | q=模版内容词 | 模版 chunk 不出现;普通资料不受影响 | items 中无 as_template 行 |
|
||||
| TC-PRIV-L2-07 | FR-PRIV-007 | POST /api/vstore/rebuild-chunks | 已有 chunk 行;删掉 chunks 目录后调用 | POST rebuild-chunks | 无 | {ok:true, docs:N, chunks:M>0} | chunks>0;N==vault 文档数 |
|
||||
| TC-PRIV-L2-08 | FR-PRIV-007 | GET /api/vstore/health chunk_count | 已 ingest | GET /health | 无 | {ok:true, count:…, backend:"local", chunk_count:…} | chunk_count==ChunkIndex.count();与 ingest 返回一致 |
|
||||
| TC-PRIV-L2-09 | FR-PRIV-007 | 重建期间可服务 | 重建 CLI 后台跑(由 TL 在 L2s 执行) | 重建中并发 GET /health /search-chunks | 无 | 两端点持续 200(锁内写文件不阻塞读) | 无 5xx;健康返回正常 |
|
||||
|
||||
#### L2-BFF
|
||||
|
||||
| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-PRIV-L2-10 | FR-PRIV-004 | POST /api/qa/private 命中 | vstore chunk 库已就绪;DeepSeek key 有效 | curl POST /api/qa/private | {"question":"…与资料相关的问题","top_k":8,"min_score":0.30} | HTTP 200;{ok:true,hit:true,mode:"vector",answer:非空 markdown,items:[{doc_id,name,seq,chunk_id,snippet,score,category,tags,created_at,ref}],elapsed_ms} | answer 中每个 [资料n] 编号与 items[].ref 一一对应;answer ≤300 字 |
|
||||
| TC-PRIV-L2-11 | FR-PRIV-004 | /api/qa/private 未命中 | 库中无相关内容 | curl POST | {"question":"无关问题"} | HTTP 200;{ok:true,hit:false,mode:"empty",answer:"",items:[]} | 三态信封完整;无 error 字段 |
|
||||
| TC-PRIV-L2-12 | FR-PRIV-004 | /api/qa/private 参数校验 | 无 | curl POST | {"question":""} / 缺 question | HTTP 422 {ok:false,error:{code:"VALIDATION"}} | 统一 VALIDATION 信封(BFF 全局处理) |
|
||||
| TC-PRIV-L2-13 | FR-PRIV-004 | /api/qa/private vstore 宕机 | 停 vstore(TL 配合) | curl POST | {"question":"…"} | HTTP 503 {ok:false,error:{code:"UPSTREAM_DOWN", degraded:true}} | 信封 code 与 degraded 断言 |
|
||||
| TC-PRIV-L2-14 | FR-PRIV-004 | /api/qa/private LLM 失败 | monkeypatch/断 key 使 complete_text 抛(TL 配合) | curl POST | 命中但 LLM 失败 | HTTP 502 {ok:false,error:{code:"UPSTREAM_ERROR"}} | 信封 code 断言 |
|
||||
| TC-PRIV-L2-15 | FR-PRIV-001 | POST /api/docgen/library 上传(回归) | vstore 正常 | curl -F file=@资料.pdf -F name=… | 小型 PDF | HTTP 201;{ok:true, items:[{library_id,name,size,as_template}]} | 上传成功返回条目;BFF doc_library 落行 |
|
||||
| TC-PRIV-L2-16 | FR-PRIV-001 | ingest 响应 chunk 字段(回归确认) | 上传后取 ingest 返回/查 vstore | 上传含正文 txt | 文件 | ingest item 含 chunk_count>0 且 chunk_ok==true;chunk_error=="" | FR-PRIV-002 验收口径 |
|
||||
| TC-PRIV-L2-17 | FR-PRIV-006 | GET /api/docgen/library 列表 | 已上传 | curl GET /api/docgen/library | 无 | {ok:true, items:[{id,name,category,tags,description,as_template,active,created_at}]} | 列表含上传条目;字段全集存在 |
|
||||
| TC-PRIV-L2-18 | FR-PRIV-006 | DELETE /api/docgen/library/{id} | 已上传 | curl -X DELETE | id | HTTP 204(空体);doc_library 行删除;vstore 密文与 chunk 行删除 | 再 GET 单条 404;search-chunks 不含该 doc |
|
||||
| TC-PRIV-L2-19 | FR-PRIV-006 | DELETE 不存在 id | 无 | curl -X DELETE | 随机 id | HTTP 404 NOT_FOUND 信封 | 不 500 |
|
||||
| TC-PRIV-L2-20 | FR-PRIV-006 | 删除后不再被检索命中 | 删除后 | GET search-chunks q=该资料内容 | q | items 不含该 doc_id | FR-PRIV-006 验收口径 |
|
||||
|
||||
#### L2-docgen 回归
|
||||
|
||||
| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-PRIV-L2-21 | FR-PRIV-008 | GET /api/vstore/search(doc 级)逐字段回归 | 改动前基线响应(编制时抓取/测试计划留存) | curl GET /api/vstore/search?q=…&as_template=true | q | 响应字段与改动前逐字段一致(mode/items 键全集与顺序) | FR-PRIV-002 验收口径;与基线 diff 空 |
|
||||
| TC-PRIV-L2-22 | FR-PRIV-008 | docgen 生成全链路(回归) | 模版已上传 | POST /api/docgen/generate | {description, session_id, library_ids} | {ok:true, artifact:{artifact_id,title,template_name,status:"done",…}} | 生成成功;doc 级检索被使用(模版命中) |
|
||||
| TC-PRIV-L2-23 | FR-PRIV-008 | docgen 导出(回归) | artifact done | POST /artifacts/{id}/export {format:"pdf"} | format | {ok:true, download_url};下载 200 | 导出闭环可用 |
|
||||
| TC-PRIV-L2-24 | FR-PRIV-008 | BFF /api/qa/search 行为零变化(回归) | 改动前后 | POST /api/qa/search | {question} | 响应契约与改动前一致(mode:"search", answer:"") | 既有 b2/sdk test_qa 全绿 |
|
||||
|
||||
### L2s 部署冒烟
|
||||
|
||||
| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-PRIV-L2s-01 | FR-PRIV-001 | vstore 明文部署重启 | TL 部署 | 重启 vstore → /health | 无 | 启动无异常;health ok;含 chunk_count | 无启动握手失败 |
|
||||
| TC-PRIV-L2s-02 | FR-PRIV-007 | CLI 存量重建 | TL 执行 scripts/rebuild_chunks.py | 后台运行 CLI(cwd=部署根) | 存量 210 条 | 进度打印;完成汇总 {docs:210, chunks:M>0};期间 HTTP 可服务 | chunks>0;无超时(不进 HTTP) |
|
||||
| TC-PRIV-L2s-03 | FR-PRIV-008 | BFF 编译部署重启 | TL 编译(白名单不变) | 重启 BFF → /api/health | 无 | 启动成功;private_docs 路由已挂载 | health ok;POST /api/qa/private 返回三态之一 |
|
||||
| TC-PRIV-L2s-04 | FR-PRIV-008 | 白名单未扩大 | 无 | diff compile_sdk.sh | 无 | git diff 空 | 红线 |
|
||||
| TC-PRIV-L2s-05 | FR-PRIV-005 | 前端构建 | 无 | cd ui && npm run build | 无 | 构建 0 error | dist 可用 |
|
||||
|
||||
### L3 前端组件
|
||||
|
||||
| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-PRIV-L3-01 | FR-PRIV-005 | AssistantMessage private_doc 块渲染 | mount AssistantMessage(b2 components.test.js 风格) | message.blocks 含 private_doc 块 | {type:"private_doc", answer:"…", items:[{ref:1,name,score,snippet}]} | 卡体 .private-doc-block 存在;标题「📚 私有资料参考」;条目 [资料1]/名称/相关度/片段齐全 | DOM 断言各项存在 |
|
||||
| TC-PRIV-L3-02 | FR-PRIV-005 | private_doc answer 空态 | 无 | answer:"" + items 2 条 | 块 | 仅条目列表,无空白答区 | 答区元素不存在 |
|
||||
| TC-PRIV-L3-03 | FR-PRIV-005 | private_doc markdown 消毒 | 无 | answer 含 `<script>alert(1)</script>` 与 **加粗** | 块 | script 被移除(DOMPurify);加粗渲染为 <strong> | innerHTML 无 script;strong 存在 |
|
||||
| TC-PRIV-L3-04 | FR-PRIV-006 | PrivateLibraryPane 列表 | vi.mock api/docgen.js(listLibrary 返回 2 条) | mount PrivateLibraryPane | 无 | 标题「📁 我的资料」+ 计数 2;两条 name/size/time 渲染 | DOM 断言 |
|
||||
| TC-PRIV-L3-05 | FR-PRIV-006 | PrivateLibraryPane 空态 | listLibrary 返回 items:[] | mount | 无 | 「尚无上传资料。在对话框拖拽 PDF / DOCX / TXT 上传。」 | 空态文案 |
|
||||
| TC-PRIV-L3-06 | FR-PRIV-006 | PrivateLibraryPane 删除+204 | vi.mock(confirm 返回 true;removeLibrary 返回 {ok:true};listLibrary 先 2 后 1) | mount → 点击删除 → confirm → 断言 | 无 | 调用 removeLibrary(id);列表刷新为 1 | window.confirm 被调;刷新后 count==1 |
|
||||
| TC-PRIV-L3-07 | FR-PRIV-006 | PrivateLibraryPane 错误重试 | listLibrary 首次 reject,二调成功 | mount → 点重试 | 无 | 错误态显示重试按钮;点击后列表加载成功 | 重试按钮存在;重试后 items 渲染 |
|
||||
| TC-PRIV-L3-08 | FR-PRIV-006 | DocPane 空态挂载 | mount DocPane(docgen.activeArtifact 为 null;mock PrivateLibraryPane 或全量挂载) | mount | 无 | .doc-empty 存在;PrivateLibraryPane 挂载于其顶部(第一个子元素);有活动文档时隐藏 | DOM 顺序断言;activeArtifact 非空时组件不渲染 |
|
||||
|
||||
### L4 端到端(浏览器,TL 配合)
|
||||
|
||||
| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-PRIV-L4-01 | FR-PRIV-001/002 | 上传→检索闭环 | 浏览器打开生产 UI | 拖拽上传 PDF → 查看「我的资料」→ 提问命中内容 | PDF | 上传成功条目出现;提问出「私有资料参考」块 | 全链闭环 |
|
||||
| TC-PRIV-L4-02 | FR-PRIV-003/005 | ask/search 两模式并行卡片 | 私有资料命中环境 | 分别 ask 与 search 模式提问 | 提问 | 两模式均出私有卡片,与 QA 答案并行展示不混排 | 卡片独立于 QA 答案 |
|
||||
| TC-PRIV-L4-03 | FR-PRIV-003 | 未命中无感 | 私有资料命中环境 | 提问无关问题 | 无关问题 | 无任何私有块/提示;QA 答案正常 | 零痕迹 |
|
||||
| TC-PRIV-L4-04 | FR-PRIV-005 | 刷新恢复 | 出块后刷新页面 | F5 → 重进会话 | 无 | private_doc 块完整恢复(终态持久化) | 块与 items 完整 |
|
||||
| TC-PRIV-L4-05 | FR-PRIV-006 | 删除联动 | 我的资料列表 | 删除一条 → 再提问该资料内容 | 无 | 列表刷新;再提问不再命中该资料 | 删除后检索不到 |
|
||||
| TC-PRIV-L4-06 | FR-PRIV-008 | 停 vstore 降级 | 停 vstore(TL 配合) | 提问 QA + 观察 | 提问 | QA 照常(doc 级不依赖 chunk);无私有块;无用户可见错误 | FR-PRIV-008 验收口径 |
|
||||
| TC-PRIV-L4-07 | FR-PRIV-001 | 上传无限制+进度条(回归) | 浏览器 | 上传大文件 | 大文件 | 无大小拦截;进度条展示 | FR-PRIV-001 验收口径 |
|
||||
|
||||
### Lr 红线与回归(diff 检查)
|
||||
|
||||
| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-PRIV-Lr-01 | FR-PRIV-008 | QA 金标树 8091 零改动 | 无 | git diff 基线..HEAD -- agent/qa 部署树 qa | 无 | diff 空 | 红线 |
|
||||
| TC-PRIV-Lr-02 | FR-PRIV-008 | 前端红线零改动 | 无 | git diff -- ui/src/stores/{modes,labels,bench}.js ui/src/api/qa.js ui/src/sse/parser.js ui/src/components/chat/PipelineCard.vue | 无 | diff 空 | 红线 |
|
||||
| TC-PRIV-Lr-03 | FR-PRIV-008 | vstore 红线零改动 | 无 | git diff -- vstore/{retrieval,parsing,backend,crypto}.py | 无 | diff 空 | 红线 |
|
||||
| TC-PRIV-Lr-04 | FR-PRIV-008 | sdk 红线零改动 | 无 | git diff -- sdk/app/routers/qa.py sdk/app/services/sse_relay.py sdk/tooling/compile/compile_sdk.sh | 无 | diff 空;白名单未扩大 | 红线 |
|
||||
| TC-PRIV-Lr-05 | FR-PRIV-008 | 新模块无 yield/SSE | 无 | grep -n "yield" sdk/app/services/privateqa.py sdk/app/routers/private_docs.py | 无 | 无 yield(断流崩溃根因回归) | grep 空 |
|
||||
|
||||
## 2. 双向追溯矩阵
|
||||
|
||||
### 2.1 FR-PRIV → TC
|
||||
|
||||
| FR | 对应 TC |
|
||||
|---|---|
|
||||
| FR-PRIV-001 | TC-PRIV-L1-40、TC-PRIV-L2-15、TC-PRIV-L2-16、TC-PRIV-L2s-01、TC-PRIV-L4-01、TC-PRIV-L4-07 |
|
||||
| FR-PRIV-002 | TC-PRIV-L1-01~07、TC-PRIV-L1-07b、TC-PRIV-L1-08~13、TC-PRIV-L1-14~17、TC-PRIV-L1-18~19、TC-PRIV-L2-01~06、TC-PRIV-L2-16、TC-PRIV-L2-21、TC-PRIV-L4-01 |
|
||||
| FR-PRIV-003 | TC-PRIV-L1-32、TC-PRIV-L1-37、TC-PRIV-L1-42、TC-PRIV-L4-02、TC-PRIV-L4-03 |
|
||||
| FR-PRIV-004 | TC-PRIV-L1-22~27、TC-PRIV-L1-30、TC-PRIV-L1-31、TC-PRIV-L2-10~14 |
|
||||
| FR-PRIV-005 | TC-PRIV-L1-28、TC-PRIV-L1-29、TC-PRIV-L1-33~39、TC-PRIV-L2s-05、TC-PRIV-L3-01~03、TC-PRIV-L4-02、TC-PRIV-L4-04 |
|
||||
| FR-PRIV-006 | TC-PRIV-L1-20、TC-PRIV-L1-41、TC-PRIV-L2-17~20、TC-PRIV-L3-04~08、TC-PRIV-L4-05 |
|
||||
| FR-PRIV-007 | TC-PRIV-L1-21、TC-PRIV-L2-07~09、TC-PRIV-L2s-02 |
|
||||
| FR-PRIV-008 | TC-PRIV-L2-21~24、TC-PRIV-L2s-03、TC-PRIV-L2s-04、TC-PRIV-L4-06、TC-PRIV-Lr-01~05 |
|
||||
|
||||
### 2.2 调整对象 → TC(§1 清单每个文件/端点/函数)
|
||||
|
||||
| 文件/端点/函数 | TC |
|
||||
|---|---|
|
||||
| vstore/chunks.py build_chunks | TC-PRIV-L1-01~07、TC-PRIV-L1-07b |
|
||||
| vstore/chunks.py ChunkIndex(upsert/remove/search/rebuild_all/count/原子写) | TC-PRIV-L1-08~13、TC-PRIV-L2-07 |
|
||||
| vstore/embed.py embed_batch_chunked | TC-PRIV-L1-14~17 |
|
||||
| vstore/vault.py add() chunk 钩子与返回字段 | TC-PRIV-L1-18、TC-PRIV-L1-19、TC-PRIV-L2-16 |
|
||||
| vstore/vault.py delete() chunk 联动 | TC-PRIV-L1-20、TC-PRIV-L2-18 |
|
||||
| vstore/vault.py rebuild_chunks() | TC-PRIV-L1-21 |
|
||||
| vstore/api.py GET /api/vstore/search-chunks | TC-PRIV-L2-01~06 |
|
||||
| vstore/api.py POST /api/vstore/rebuild-chunks | TC-PRIV-L2-07、TC-PRIV-L2-09 |
|
||||
| vstore/api.py GET /api/vstore/health(chunk_count) | TC-PRIV-L2-08 |
|
||||
| vstore/settings.py VSTORE_CHUNK_PATH | 并入 TC-PRIV-L1-08(ChunkIndex 以 settings 路径构造,路径推导断言) |
|
||||
| vstore/scripts/rebuild_chunks.py CLI | TC-PRIV-L2s-02 |
|
||||
| 存量 210 条重建 | TC-PRIV-L2s-02、TC-PRIV-L2-07 |
|
||||
| sdk/app/services/privateqa.py ask_private | TC-PRIV-L1-22~27 |
|
||||
| sdk/app/routers/private_docs.py POST /api/qa/private | TC-PRIV-L2-10~14 |
|
||||
| sdk/app/schemas.py PrivateQARequest/PrivateDocItem/PrivateDocBlock/Block union | TC-PRIV-L1-28、TC-PRIV-L1-29 |
|
||||
| sdk/app/main.py 路由注册 | TC-PRIV-L2s-03 |
|
||||
| sdk/app/services/docgen/client.py search_chunks | TC-PRIV-L1-31 |
|
||||
| sdk/app/services/docgen/llm.py complete_text timeout 参数 | TC-PRIV-L1-30 |
|
||||
| ui/src/api/private.js askPrivate | TC-PRIV-L1-40 |
|
||||
| ui/src/stores/chat.js _firePrivate | TC-PRIV-L1-32、TC-PRIV-L1-42 |
|
||||
| ui/src/stores/chat.js pending/flush 竞态 | TC-PRIV-L1-33、TC-PRIV-L1-34、TC-PRIV-L1-35、TC-PRIV-L1-39 |
|
||||
| ui/src/stores/chat.js _appendPrivate 防重入 | TC-PRIV-L1-36 |
|
||||
| ui/src/stores/chat.js ask/search/retry 覆盖 | TC-PRIV-L1-37、TC-PRIV-L1-38 |
|
||||
| ui/src/components/chat/AssistantMessage.vue private_doc 分支 | TC-PRIV-L3-01~03 |
|
||||
| ui/src/i18n/private.js | TC-PRIV-L3-01、TC-PRIV-L3-04(文案断言) |
|
||||
| ui/src/components/bench/PrivateLibraryPane.vue | TC-PRIV-L3-04~07 |
|
||||
| ui/src/components/bench/DocPane.vue 空态挂载 | TC-PRIV-L3-08 |
|
||||
| ui/src/api/docgen.js removeLibrary(204) | TC-PRIV-L1-41 |
|
||||
| docgen doc 级 /search 回归 | TC-PRIV-L2-21、TC-PRIV-L2-22 |
|
||||
| 上传/generate/导出回归 | TC-PRIV-L2-15、TC-PRIV-L2-22、TC-PRIV-L2-23、TC-PRIV-L2-24 |
|
||||
| 红线:QA 8091 | TC-PRIV-Lr-01 |
|
||||
| 红线:modes/labels/bench/api/qa/sse/parser/PipelineCard | TC-PRIV-Lr-02 |
|
||||
| 红线:vstore retrieval/parsing/backend/crypto | TC-PRIV-Lr-03 |
|
||||
| 红线:sdk qa.py/sse_relay.py/compile_sdk.sh | TC-PRIV-Lr-04 |
|
||||
| 断流崩溃(新模块无 yield) | TC-PRIV-Lr-05 |
|
||||
| vstore/tests/test_chunks.py(测试文件本身) | 本文 §3(pytest 全绿即覆盖) |
|
||||
| ui/tests/private_docs/*(测试文件本身) | 本文 §3(vitest 全绿即覆盖) |
|
||||
|
||||
### 2.3 覆盖判据 §3 边界与异常逐项 → TC
|
||||
|
||||
| 判据项 | TC |
|
||||
|---|---|
|
||||
| 空库检索 | TC-PRIV-L2-03 |
|
||||
| 低分阈值 0.30 两侧 | TC-PRIV-L1-12(两侧)、TC-PRIV-L1-13(低侧)、TC-PRIV-L2-02 |
|
||||
| 超 top_k clamp | TC-PRIV-L1-12、TC-PRIV-L2-04 |
|
||||
| 每文档 200 块上限 | TC-PRIV-L1-04 |
|
||||
| 同 doc 去重每 doc≤3 | TC-PRIV-L1-24 |
|
||||
| 12k 上下文截断 | TC-PRIV-L1-25 |
|
||||
| LLM 40s 超时 | TC-PRIV-L1-26 |
|
||||
| vstore 宕机 | TC-PRIV-L2-13、TC-PRIV-L4-06 |
|
||||
| LLM 宕机 | TC-PRIV-L2-14 |
|
||||
| 上传超长 PDF | TC-PRIV-L1-04(超长切块)、TC-PRIV-L4-07(超长上传回归) |
|
||||
| docgen 检索响应逐字段 | TC-PRIV-L2-21 |
|
||||
| QA 8091 零改动 | TC-PRIV-Lr-01 |
|
||||
| 红线文件 diff 空 | TC-PRIV-Lr-02~04 |
|
||||
|
||||
## 3. 测试文件骨架
|
||||
|
||||
(与本文档同批交付,本轮只写不跑。)
|
||||
|
||||
| 文件 | 覆盖 TC |
|
||||
|---|---|
|
||||
| vstore/tests/test_chunks.py | TC-PRIV-L1-01~17、TC-PRIV-L1-07b(build_chunks/ChunkIndex/embed_batch_chunked;含 tmp_path 与假嵌入注入) |
|
||||
| ui/tests/private_docs/chat.private.test.js | TC-PRIV-L1-32~40、TC-PRIV-L1-42(chat store 竞态/防重入/两模式/retry/跨轮归属/askPrivate) |
|
||||
| ui/tests/private_docs/components.private.test.js | TC-PRIV-L3-01~03(AssistantMessage 块渲染) |
|
||||
| ui/tests/private_docs/privateLibraryPane.test.js | TC-PRIV-L3-04~07(面板) |
|
||||
| ui/tests/private_docs/docPaneMount.test.js | TC-PRIV-L3-08 + TC-PRIV-L1-41(空态挂载/removeLibrary 204) |
|
||||
| ui/tests/private_docs/vitest.config.js | 局部配置(b3 先例),依赖不改 |
|
||||
@@ -0,0 +1,291 @@
|
||||
// @vitest-environment jsdom
|
||||
// chat.private.test.js —— 私有资料问答 chat store 竞态与接线单测(T1 骨架)
|
||||
// 对应用例:TC-PRIV-L1-32 ~ TC-PRIV-L1-40、TC-PRIV-L1-42(docs/team/TEST_CASES_PRIVATE.md)
|
||||
// 风格:沿用 tests/frontend/unit/b2/chat.store.test.js(vi.mock + fake timers + 帧驱动)。
|
||||
// A1 契约(brief_priv_a1.md P3):chat.send() 在分发前不 await 调 _firePrivate(q, sessionId, aMsg);
|
||||
// 命中时流中暂存 aMsg._privatePending / 终态直挂;_appendPrivate 防重入;_flushPrivate 只清 pending。
|
||||
// TL 裁决:本轮上下文校验实现 = streamCtx[sessionId].aMsg 引用相等;用例判据只写行为级
|
||||
// (非本轮私有结果不得串挂,见 L1-42),不锁内部实现。
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
|
||||
// ---- mock 依赖模块(与 b2/chat.store.test.js 同模式)----
|
||||
let frames = [];
|
||||
let privateImpl = () => ({ ok: true, hit: false, mode: 'empty', answer: '', items: [] });
|
||||
let privateCalls = [];
|
||||
|
||||
vi.mock('../../src/api/qa.js', () => ({
|
||||
chatStream: vi.fn(async (body, handlers) => {
|
||||
for (const f of frames) {
|
||||
if (f.event === 'complete') handlers.onComplete({ payload: f.data });
|
||||
else if (f.event === 'error') handlers.onError({ payload: f.data });
|
||||
else handlers.onProgress({ payload: f.data });
|
||||
}
|
||||
}),
|
||||
search: vi.fn(async () => ({ mode: 'search', answer: '', raw: {} })),
|
||||
info: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/api/sessions.js', () => {
|
||||
const calls = { messages: [], patches: [] };
|
||||
return {
|
||||
createMessage: vi.fn(async (sid, body) => {
|
||||
calls.messages.push({ sid, body });
|
||||
return { id: `mid-${calls.messages.length}`, ...body, created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z' };
|
||||
}),
|
||||
updateMessage: vi.fn(async (sid, mid, body) => {
|
||||
calls.patches.push({ sid, mid, body });
|
||||
return { id: mid, ...body, updated_at: '2026-08-25T00:00:05Z' };
|
||||
}),
|
||||
getSession: vi.fn(),
|
||||
listSessions: vi.fn(async () => []),
|
||||
createSession: vi.fn(async () => ({ id: 'sess-1', title: '新会话', status: 'idle', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z', message_count: 0, meta: {} })),
|
||||
updateSession: vi.fn(async (sid, body) => ({ id: sid, ...body })),
|
||||
deleteSession: vi.fn(async () => {}),
|
||||
getSessionSources: vi.fn(async () => ({ session_id: 's1', total: 0, sources: [] })),
|
||||
_calls: calls,
|
||||
};
|
||||
});
|
||||
|
||||
// askPrivate 可控实现:记录调用、可延迟 resolve(竞态时序控制)
|
||||
vi.mock('../../src/api/private.js', () => ({
|
||||
askPrivate: vi.fn(async (...args) => {
|
||||
privateCalls.push(args[0]);
|
||||
return privateImpl(...args);
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useChatStore } from '../../src/stores/chat.js';
|
||||
import { useSessionsStore } from '../../src/stores/sessions.js';
|
||||
|
||||
const HIT_PAYLOAD = {
|
||||
ok: true, hit: true, mode: 'vector',
|
||||
answer: '依据[资料1],水稻SPL家族共19个成员。',
|
||||
items: [
|
||||
{ doc_id: 'd1', name: '水稻SPL资料', seq: 0, chunk_id: 'd1:0', snippet: '水稻SPL家族共有19个成员……', score: 0.92, category: '', tags: '', created_at: '2026-09-01T00:00:00Z', ref: 1 },
|
||||
],
|
||||
elapsed_ms: 1234,
|
||||
};
|
||||
|
||||
const completePayload = {
|
||||
answer: '普通QA答案',
|
||||
source_count: 0,
|
||||
sources: [],
|
||||
chains: [],
|
||||
graph: { nodes: [], edges: [], chains: [] },
|
||||
raw: {},
|
||||
};
|
||||
|
||||
function privateBlockOf(aMsg) {
|
||||
return (aMsg.blocks || []).find((b) => b.type === 'private_doc') || null;
|
||||
}
|
||||
|
||||
async function setupChat({ sessionId = 'sess-1' } = {}) {
|
||||
setActivePinia(createPinia());
|
||||
const chat = useChatStore();
|
||||
const sessions = useSessionsStore();
|
||||
sessions.items = [{ id: 'sess-1', title: '新会话', status: 'idle', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z', message_count: 0, meta: {} }];
|
||||
sessions.activeId = sessionId;
|
||||
chat.sessionId = sessionId;
|
||||
frames = [];
|
||||
privateCalls = [];
|
||||
privateImpl = () => ({ ok: true, hit: false, mode: 'empty', answer: '', items: [] });
|
||||
return { chat, sessions };
|
||||
}
|
||||
|
||||
describe('chat store:私有资料检索接线(FR-PRIV-003/005)', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); });
|
||||
|
||||
it('TC-PRIV-L1-32 命中:ask 终态后 private_doc 块挂上(fire-and-forget 不阻塞 QA)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
privateImpl = async () => HIT_PAYLOAD;
|
||||
frames = [
|
||||
{ event: 'progress', data: { phase: 'planning', label: '理解问题' } },
|
||||
{ event: 'complete', data: completePayload },
|
||||
];
|
||||
const sendP = chat.send('水稻SPL家族?', { mode: 'ask' });
|
||||
await vi.runAllTimersAsync();
|
||||
await sendP;
|
||||
const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
||||
const blk = privateBlockOf(aMsg);
|
||||
expect(blk).not.toBeNull();
|
||||
expect(blk.items.length).toBe(1);
|
||||
expect(blk.answer).toContain('[资料1]');
|
||||
expect(aMsg.blocks.some((b) => b.type === 'text' && b.html.includes('普通QA答案'))).toBe(true);
|
||||
// 未命中/异常静默分支
|
||||
expect(privateCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('TC-PRIV-L1-32 未命中:无块、无用户可见错误、QA 流程不受影响', async () => {
|
||||
const { chat } = await setupChat();
|
||||
privateImpl = async () => ({ ok: true, hit: false, mode: 'empty', answer: '', items: [] });
|
||||
frames = [{ event: 'complete', data: completePayload }];
|
||||
const sendP = chat.send('问题', { mode: 'ask' });
|
||||
await vi.runAllTimersAsync();
|
||||
await sendP;
|
||||
const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
||||
expect(privateBlockOf(aMsg)).toBeNull();
|
||||
expect(aMsg.status).toBe('done');
|
||||
expect(aMsg.streamErr).toBeNull();
|
||||
});
|
||||
|
||||
it('TC-PRIV-L1-32 异常:askPrivate reject 静默(无块、QA 终态正常)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
privateImpl = async () => { throw new Error('boom'); };
|
||||
frames = [{ event: 'complete', data: completePayload }];
|
||||
const sendP = chat.send('问题', { mode: 'ask' });
|
||||
await vi.runAllTimersAsync();
|
||||
await sendP;
|
||||
const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
||||
expect(privateBlockOf(aMsg)).toBeNull();
|
||||
expect(aMsg.status).toBe('done');
|
||||
expect(chat.lastResult.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-PRIV-L1-33 流未收口:命中先到 → 暂存 pending,complete 替换 blocks 后 flush 不丢块', async () => {
|
||||
const { chat } = await setupChat();
|
||||
// 命中在 QA complete 之前 resolve(顺序由 frame 驱动保证)
|
||||
privateImpl = async () => HIT_PAYLOAD;
|
||||
frames = [
|
||||
{ event: 'progress', data: { phase: 'planning', label: '理解问题' } },
|
||||
{ event: 'complete', data: completePayload },
|
||||
];
|
||||
const sendP = chat.send('问题', { mode: 'ask' });
|
||||
await vi.runAllTimersAsync();
|
||||
await sendP;
|
||||
const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
||||
const blk = privateBlockOf(aMsg);
|
||||
expect(blk).not.toBeNull();
|
||||
expect(blk.answer).toContain('[资料1]');
|
||||
// pipeline 块仍在且为第一个块(onComplete 整体替换后 flush 挂尾部)
|
||||
expect(aMsg.blocks[0].type).toBe('pipeline');
|
||||
});
|
||||
|
||||
it('TC-PRIV-L1-34 onComplete 替换后命中才到:终态直挂 + 补 persist', async () => {
|
||||
const { chat } = await setupChat();
|
||||
let resolvePrivate;
|
||||
privateImpl = () => new Promise((res) => { resolvePrivate = () => res(HIT_PAYLOAD); });
|
||||
frames = [{ event: 'complete', data: completePayload }];
|
||||
const sendP = chat.send('问题', { mode: 'ask' });
|
||||
await vi.runAllTimersAsync();
|
||||
// QA 已 complete(终态 persist 完成)后私有响应才返回
|
||||
resolvePrivate();
|
||||
await vi.runAllTimersAsync();
|
||||
await sendP;
|
||||
const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
||||
const blk = privateBlockOf(aMsg);
|
||||
expect(blk).not.toBeNull();
|
||||
expect(blk.answer).toContain('[资料1]');
|
||||
// 终态直挂需补一次 persist:断言 updateMessage 被再次调用且 blocks 含 private_doc
|
||||
const sessApi = (await import('../../src/api/sessions.js'));
|
||||
const patches = sessApi._calls.patches;
|
||||
const lastPatch = patches[patches.length - 1];
|
||||
expect(lastPatch).toBeTruthy();
|
||||
expect(JSON.stringify(lastPatch.body.blocks || [])).toContain('private_doc');
|
||||
});
|
||||
|
||||
it('TC-PRIV-L1-35 error 路径 flush:QA onError 终态下命中块仍挂上', async () => {
|
||||
const { chat } = await setupChat();
|
||||
privateImpl = async () => HIT_PAYLOAD;
|
||||
frames = [{ event: 'error', data: { code: 'UPSTREAM_DOWN', message: '检索服务不可用' } }];
|
||||
const sendP = chat.send('问题', { mode: 'ask' });
|
||||
await vi.runAllTimersAsync();
|
||||
await sendP;
|
||||
const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
||||
expect(aMsg.status).toBe('error');
|
||||
const blk = privateBlockOf(aMsg);
|
||||
expect(blk).not.toBeNull();
|
||||
expect(blk.answer).toContain('[资料1]');
|
||||
});
|
||||
|
||||
it('TC-PRIV-L1-36 _appendPrivate 防重入:同一消息只挂一次', async () => {
|
||||
const { chat } = await setupChat();
|
||||
const aMsg = { id: 'm1', role: 'assistant', blocks: [] };
|
||||
chat._appendPrivate(aMsg, HIT_PAYLOAD);
|
||||
chat._appendPrivate(aMsg, HIT_PAYLOAD);
|
||||
expect(aMsg.blocks.filter((b) => b.type === 'private_doc').length).toBe(1);
|
||||
});
|
||||
|
||||
it('TC-PRIV-L1-37 search 模式同样触发 _firePrivate 且挂块', async () => {
|
||||
const { chat } = await setupChat();
|
||||
privateImpl = async () => HIT_PAYLOAD;
|
||||
const sendP = chat.send('问题', { mode: 'search' });
|
||||
await vi.runAllTimersAsync();
|
||||
await sendP;
|
||||
expect(privateCalls.length).toBe(1);
|
||||
const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
||||
expect(privateBlockOf(aMsg)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('TC-PRIV-L1-38 retry 后新一轮同样触发私有检索且不残留旧 pending', async () => {
|
||||
const { chat } = await setupChat();
|
||||
privateImpl = async () => ({ ok: true, hit: false, mode: 'empty', answer: '', items: [] });
|
||||
frames = [{ event: 'error', data: { code: 'UPSTREAM_DOWN', message: 'x' } }];
|
||||
const sendP = chat.send('问题', { mode: 'ask' });
|
||||
await vi.runAllTimersAsync();
|
||||
await sendP;
|
||||
expect(chat.lastResult.ok).toBe(false);
|
||||
// 重试:QA 正常完成,私有未命中
|
||||
frames = [{ event: 'complete', data: completePayload }];
|
||||
const retryP = chat.retry();
|
||||
await vi.runAllTimersAsync();
|
||||
await retryP;
|
||||
expect(privateCalls.length).toBe(2);
|
||||
const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
||||
expect(privateBlockOf(aMsg)).toBeNull();
|
||||
expect(aMsg.status).toBe('done');
|
||||
});
|
||||
|
||||
it('TC-PRIV-L1-39 _flushPrivate 只清 pending,不重复挂块', async () => {
|
||||
const { chat } = await setupChat();
|
||||
const aMsg = { id: 'm1', role: 'assistant', blocks: [], _privatePending: HIT_PAYLOAD };
|
||||
chat._flushPrivate(aMsg);
|
||||
expect(aMsg.blocks.filter((b) => b.type === 'private_doc').length).toBe(1);
|
||||
expect(aMsg._privatePending).toBeUndefined();
|
||||
// 二次 flush:无 pending 不应再挂块
|
||||
chat._flushPrivate(aMsg);
|
||||
expect(aMsg.blocks.filter((b) => b.type === 'private_doc').length).toBe(1);
|
||||
});
|
||||
|
||||
it('TC-PRIV-L1-40 askPrivate 契约:路径/默认参数/timeout', async () => {
|
||||
const { askPrivate } = await import('../../src/api/private.js');
|
||||
await askPrivate({ question: 'q', session_id: 's1' });
|
||||
expect(askPrivate).toHaveBeenCalledWith({ question: 'q', session_id: 's1' });
|
||||
expect(privateCalls.length).toBe(1);
|
||||
expect(privateCalls[0]).toEqual({ question: 'q', session_id: 's1' });
|
||||
});
|
||||
|
||||
it('TC-PRIV-L1-42 跨轮归属:迟到响应不得串挂到本轮消息(行为级判据)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
// 第 1 轮私有响应悬置:直到第 2 轮进行中才 resolve
|
||||
const pending = [];
|
||||
privateImpl = () => new Promise((res) => { pending.push(res); });
|
||||
frames = [{ event: 'complete', data: completePayload }];
|
||||
const first = chat.send('第一问', { mode: 'ask' });
|
||||
await vi.runAllTimersAsync();
|
||||
await first;
|
||||
expect(privateCalls.length).toBe(1);
|
||||
// 第 2 轮开始(私有响应同样悬置,QA 流未收口 → 本轮上下文活跃)
|
||||
frames = [{ event: 'progress', data: { phase: 'planning', label: '理解问题' } }];
|
||||
const second = chat.send('第二问', { mode: 'ask' });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(privateCalls.length).toBe(2);
|
||||
// 此刻才 resolve 第 1 轮的命中响应(跨轮迟到场景)
|
||||
pending[0](HIT_PAYLOAD);
|
||||
await vi.runAllTimersAsync();
|
||||
// 第 2 轮 QA 正常收口(其私有响应未命中)
|
||||
pending[1]({ ok: true, hit: false, mode: 'empty', answer: '', items: [] });
|
||||
await vi.runAllTimersAsync();
|
||||
await second;
|
||||
const msgs = chat.messages.filter((m) => m.role === 'assistant');
|
||||
const round1 = msgs[0];
|
||||
const round2 = msgs[1];
|
||||
// 行为级判据:第 1 轮迟到的命中结果不得串挂到第 2 轮消息
|
||||
expect(privateBlockOf(round2)).toBeNull();
|
||||
expect(JSON.stringify(round2.blocks || [])).not.toContain('[资料1]');
|
||||
// 迟到的第 1 轮结果挂到第 1 轮消息或丢弃均可(方案不锁实现),但不得串挂
|
||||
const blk1 = privateBlockOf(round1);
|
||||
if (blk1) expect(blk1.items[0].name).toBe('水稻SPL资料');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
// components.private.test.js —— AssistantMessage private_doc 块渲染(T1 骨架)
|
||||
// 对应用例:TC-PRIV-L3-01 ~ TC-PRIV-L3-03(docs/team/TEST_CASES_PRIVATE.md)
|
||||
// 风格:沿用 tests/frontend/unit/b2/components.test.js(mount + DOM 断言)。
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import AssistantMessage from '../../src/components/chat/AssistantMessage.vue';
|
||||
|
||||
function messageWithBlock(block) {
|
||||
return {
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
kind: 'qa',
|
||||
status: 'done',
|
||||
blocks: [
|
||||
{ type: 'pipeline', status: 'done', steps: [] },
|
||||
block,
|
||||
],
|
||||
raw: null,
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
updated_at: '2026-09-01T00:00:01Z',
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_BLOCK = {
|
||||
type: 'private_doc',
|
||||
answer: '依据[资料1],水稻SPL家族共19个成员。',
|
||||
items: [
|
||||
{ ref: 1, name: '水稻SPL资料.pdf', score: 0.92, snippet: '水稻SPL家族共有19个成员……' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('AssistantMessage private_doc 块渲染(FR-PRIV-005)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
// 揭示动画相关环境由 jsdom 默认处理(reduced-motion 分支直接定格)
|
||||
});
|
||||
|
||||
it('TC-PRIV-L3-01 卡体/标题/条目齐全', () => {
|
||||
const wrapper = mount(AssistantMessage, {
|
||||
props: { message: messageWithBlock(BASE_BLOCK) },
|
||||
});
|
||||
expect(wrapper.find('.private-doc-block').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('私有资料参考');
|
||||
expect(wrapper.text()).toContain('依据[资料1]');
|
||||
const itemEl = wrapper.find('.private-doc-item, [data-private-item]');
|
||||
if (itemEl.exists()) {
|
||||
expect(itemEl.text()).toContain('水稻SPL资料.pdf');
|
||||
expect(itemEl.text()).toContain('0.92');
|
||||
expect(itemEl.text()).toContain('水稻SPL家族共有19个成员');
|
||||
}
|
||||
});
|
||||
|
||||
it('TC-PRIV-L3-02 answer 为空仅显示条目列表(无空白答区)', () => {
|
||||
const block = { ...BASE_BLOCK, answer: '' };
|
||||
const wrapper = mount(AssistantMessage, {
|
||||
props: { message: messageWithBlock(block) },
|
||||
});
|
||||
expect(wrapper.find('.private-doc-block').exists()).toBe(true);
|
||||
const answerEl = wrapper.find('.private-doc-answer, .pd-answer');
|
||||
expect(answerEl.exists()).toBe(false); // 答区元素不得存在(即便存在也为空渲染)
|
||||
expect(wrapper.find('.private-doc-item, [data-private-item]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-PRIV-L3-03 markdown 消毒:script 移除、**加粗** 渲染', () => {
|
||||
const block = {
|
||||
...BASE_BLOCK,
|
||||
answer: '结论**成立**。<script>alert(1)</script>',
|
||||
};
|
||||
const wrapper = mount(AssistantMessage, {
|
||||
props: { message: messageWithBlock(block) },
|
||||
});
|
||||
const html = wrapper.find('.private-doc-block').html();
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).not.toContain('alert(1)');
|
||||
expect(html).toContain('<strong>成立</strong>');
|
||||
});
|
||||
|
||||
it('无 private_doc 块的消息不渲染该卡(零回归)', () => {
|
||||
const wrapper = mount(AssistantMessage, {
|
||||
props: { message: messageWithBlock({ type: 'text', html: '<p>普通答案</p>' }) },
|
||||
});
|
||||
expect(wrapper.find('.private-doc-block').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
// docPaneMount.test.js —— DocPane 空态挂载 + removeLibrary 204 处理(T1 骨架)
|
||||
// 对应用例:TC-PRIV-L3-08(DocPane 空态挂载)、TC-PRIV-L1-41(removeLibrary 204)。
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
|
||||
vi.mock('../../src/api/docgen.js', () => ({
|
||||
listLibrary: vi.fn(async () => ({ ok: true, items: [] })),
|
||||
removeLibrary: vi.fn(async (id) => ({ ok: true, removed: true, id })),
|
||||
}));
|
||||
|
||||
// DocPane 依赖 docgen store(activeArtifact 驱动空态);面板组件随空态挂载
|
||||
import DocPane from '../../src/components/bench/DocPane.vue';
|
||||
import PrivateLibraryPane from '../../src/components/bench/PrivateLibraryPane.vue';
|
||||
import { useDocgenStore } from '../../src/stores/docgen.js';
|
||||
|
||||
describe('DocPane 空态挂载(FR-PRIV-006 / UI_SPEC §9.2)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('TC-PRIV-L3-08 空态:PrivateLibraryPane 挂载于 .doc-empty 内(顶部位置)', async () => {
|
||||
const docgen = useDocgenStore();
|
||||
docgen.activeArtifact = null;
|
||||
const wrapper = mount(DocPane, {
|
||||
global: {
|
||||
stubs: { PrivateLibraryPane: true },
|
||||
components: { PrivateLibraryPane },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
const empty = wrapper.find('.doc-empty');
|
||||
expect(empty.exists()).toBe(true);
|
||||
const pane = wrapper.findComponent(PrivateLibraryPane);
|
||||
expect(pane.exists()).toBe(true);
|
||||
// 空态区第一个子元素为面板(UI_SPEC:挂空态区顶部,其余空态内容不动)
|
||||
const firstChild = empty.element.children[0];
|
||||
expect(firstChild).toBeTruthy();
|
||||
expect(firstChild.className || firstChild.tagName).toBeTruthy();
|
||||
// 图标与空态文案仍在(其余空态内容不动)
|
||||
expect(wrapper.text()).toContain('📄');
|
||||
});
|
||||
|
||||
it('TC-PRIV-L3-08 有活动文档时面板隐藏', async () => {
|
||||
const docgen = useDocgenStore();
|
||||
docgen.activeArtifact = { artifact_id: 'art-1', title: 't' };
|
||||
const wrapper = mount(DocPane, {
|
||||
global: { stubs: { PrivateLibraryPane: true } },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.doc-empty').exists()).toBe(false);
|
||||
expect(wrapper.findComponent(PrivateLibraryPane).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeLibrary 204 空体处理(TC-PRIV-L1-41)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('DELETE 返回 204 空体时 removeLibrary 仍返回 {ok:true}(不抛 JSON 解析错)', async () => {
|
||||
const { removeLibrary } = await import('../../src/api/docgen.js');
|
||||
// mock 已返回 {ok:true}(模拟 http 层 204 兜底后的形态)
|
||||
const result = await removeLibrary('lib-1');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(removeLibrary).toHaveBeenCalledWith('lib-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
// privateLibraryPane.test.js —— 我的资料面板(T1 骨架)
|
||||
// 对应用例:TC-PRIV-L3-04 ~ TC-PRIV-L3-07(docs/team/TEST_CASES_PRIVATE.md)
|
||||
// UI_SPEC §9.2:标题「📁 我的资料」+ 计数;列表项 name/size/time;删除 confirm → removeLibrary → 刷新;
|
||||
// 空态「尚无上传资料。在对话框拖拽 PDF / DOCX / TXT 上传。」;加载失败显示重试按钮。
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
|
||||
let listImpl = () => ({ ok: true, items: [] });
|
||||
let removeImpl = () => ({ ok: true });
|
||||
|
||||
vi.mock('../../src/api/docgen.js', () => ({
|
||||
listLibrary: vi.fn(async () => listImpl()),
|
||||
removeLibrary: vi.fn(async (id) => removeImpl(id)),
|
||||
}));
|
||||
|
||||
import PrivateLibraryPane from '../../src/components/bench/PrivateLibraryPane.vue';
|
||||
|
||||
const ROW_A = { id: 'a', name: '水稻资料.pdf', category: '', tags: '', description: '', as_template: false, active: true, created_at: '2026-09-01T00:00:00Z' };
|
||||
const ROW_B = { id: 'b', name: '小麦试验.docx', category: '', tags: '', description: '', as_template: false, active: true, created_at: '2026-09-02T00:00:00Z' };
|
||||
|
||||
describe('PrivateLibraryPane(FR-PRIV-006)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
listImpl = () => ({ ok: true, items: [] });
|
||||
removeImpl = () => ({ ok: true });
|
||||
});
|
||||
|
||||
it('TC-PRIV-L3-04 列表加载:标题 + 计数 + 条目(名称)', async () => {
|
||||
listImpl = () => ({ ok: true, items: [ROW_A, ROW_B] });
|
||||
const wrapper = mount(PrivateLibraryPane);
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('我的资料');
|
||||
expect(wrapper.text()).toContain('2');
|
||||
expect(wrapper.text()).toContain('水稻资料.pdf');
|
||||
expect(wrapper.text()).toContain('小麦试验.docx');
|
||||
});
|
||||
|
||||
it('TC-PRIV-L3-05 空态文案', async () => {
|
||||
const wrapper = mount(PrivateLibraryPane);
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('尚无上传资料');
|
||||
expect(wrapper.text()).toContain('PDF / DOCX / TXT');
|
||||
});
|
||||
|
||||
it('TC-PRIV-L3-06 删除:confirm 二次确认 → removeLibrary → 列表刷新', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
listImpl = () => ({ ok: true, items: [ROW_A, ROW_B] });
|
||||
removeImpl = vi.fn(async () => ({ ok: true }));
|
||||
const wrapper = mount(PrivateLibraryPane);
|
||||
await flushPromises();
|
||||
|
||||
const deleteBtn = wrapper.find('button[data-action="delete"], .pane-btn');
|
||||
// 删除按钮 hover 出现;直接触发存在的按钮
|
||||
if (deleteBtn.exists()) {
|
||||
await deleteBtn.trigger('click');
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
// 删除后刷新:下一次 listLibrary 返回单条
|
||||
listImpl = () => ({ ok: true, items: [ROW_B] });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).not.toContain('水稻资料.pdf');
|
||||
expect(wrapper.text()).toContain('小麦试验.docx');
|
||||
} else {
|
||||
// 骨架防御:删除按钮未渲染时标记失败(A1 需实现 hover 删除按钮)
|
||||
expect(deleteBtn.exists()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('TC-PRIV-L3-07 加载失败显示重试按钮,重试后恢复', async () => {
|
||||
let failFirst = true;
|
||||
listImpl = () => {
|
||||
if (failFirst) throw new Error('network');
|
||||
return { ok: true, items: [ROW_A] };
|
||||
};
|
||||
const wrapper = mount(PrivateLibraryPane);
|
||||
await flushPromises();
|
||||
const retryBtn = wrapper.find('button');
|
||||
expect(retryBtn.exists()).toBe(true);
|
||||
failFirst = false;
|
||||
await retryBtn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('水稻资料.pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
// vitest.config.js —— private_docs 局部配置(T1 骨架,b3 先例)
|
||||
// 本目录(ui/tests/private_docs/)在 ui/ 之内,node_modules 由 ui 直接解析,
|
||||
// 无需 b3 的绝对路径别名;仅指定 include 与 jsdom 环境。
|
||||
// 运行方式:cd ui && npx vitest run --config tests/private_docs/vitest.config.js
|
||||
// 依赖不动(复用 ui/package.json 既有 vitest/@vue/test-utils/jsdom)。
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
vue: 'vue/dist/vue.runtime.esm-bundler.js',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['tests/private_docs/**/*.test.js'],
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
"""test_chunks.py —— 私有资料 chunk 级索引单元测试(T1 骨架,FR-PRIV-002)。
|
||||
|
||||
对应用例:TC-PRIV-L1-01 ~ TC-PRIV-L1-17、TC-PRIV-L1-07b(docs/team/TEST_CASES_PRIVATE.md)。
|
||||
TL 裁决已并入:L1-05 表格原位聚合、L1-07b outline 不参与切块。
|
||||
|
||||
纪律:
|
||||
- 只依赖 vstore 已声明契约(docs/team/brief_priv_a1.md P1 签名);A1 交付后
|
||||
本文件应零修改直接可跑(若契约有出入按任务书/本用例表为准要求 A1 对齐)。
|
||||
- 复用 vstore/tests/conftest.py 的假 embed 注入模式;embed_batch_chunked 的
|
||||
测试注入钩子按「仿 set_embed_fn 模式」的约定名 `_CHUNKED_REQUEST_FN` 使用——
|
||||
若 A1 实际钩子名不同,仅需改本文件头部 _REQUEST_HOOK 一行(并在报告注明偏离)。
|
||||
- 本文件不执行真实 ollama、不触网、不写产品代码。
|
||||
|
||||
运行:cd vstore && ../..venv/bin/pytest tests/test_chunks.py -v
|
||||
(venv = /home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vstore import chunks as chunks_mod
|
||||
from vstore.chunks import (
|
||||
CHUNK_MAX_PARAS,
|
||||
CHUNK_MAX_PER_DOC,
|
||||
CHUNK_MIN_CHARS,
|
||||
CHUNK_MIN_SCORE,
|
||||
CHUNK_OVERLAP_PARAS,
|
||||
CHUNK_TARGET_CHARS,
|
||||
ChunkIndex,
|
||||
build_chunks,
|
||||
)
|
||||
from vstore.settings import EMBED_DIM
|
||||
|
||||
# 假嵌入注入钩子(契约约定名;A1 若改名仅改此常量并在测试报告注明偏离)
|
||||
_REQUEST_HOOK = "_CHUNKED_REQUEST_FN"
|
||||
|
||||
DIM = EMBED_DIM
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 假嵌入工具(chunk 检索可控分数)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fake_vec(seed: int) -> list[float]:
|
||||
"""确定性单位向量:seed → 基向量方向。"""
|
||||
vec = np.zeros(DIM, dtype=np.float32)
|
||||
vec[seed % DIM] = 1.0
|
||||
return vec.tolist()
|
||||
|
||||
|
||||
def fake_embed_controlled(texts: list[str]) -> list[list[float]]:
|
||||
"""文本 → 向量:『种子SEED』前缀取 SEED 作方向;其余全零向量。"""
|
||||
out: list[list[float]] = []
|
||||
for text in texts:
|
||||
seed = 0
|
||||
if isinstance(text, str) and text.startswith("种子"):
|
||||
try:
|
||||
seed = int(text[2:].split()[0])
|
||||
except ValueError:
|
||||
seed = 0
|
||||
out.append(fake_vec(seed) if seed else [0.0] * DIM)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def embed_fn(monkeypatch):
|
||||
"""把可控假嵌入注入 vault 模块(与 conftest.embed_fn 同模式)。"""
|
||||
from vstore import vault as vault_mod
|
||||
|
||||
monkeypatch.setattr(vault_mod, "_EMBED_FN", fake_embed_controlled)
|
||||
return fake_embed_controlled
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-PRIV-L1-01 ~ L1-07b:build_chunks 切块参数边界(含 TL 裁决版 L1-05/L1-07b)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildChunksParams:
|
||||
def test_eight_short_paras_make_one_chunk(self):
|
||||
"""TC-PRIV-L1-01:8 段短段落 → 恰好 1 块,首行 # 标题,seq==0。"""
|
||||
paras = [f"段落{i}内容。"] * 8
|
||||
out = build_chunks("报告", [], paras, [])
|
||||
assert len(out) == 1
|
||||
assert out[0].seq == 0
|
||||
assert out[0].text.startswith("# 报告")
|
||||
for p in paras:
|
||||
assert p in out[0].text
|
||||
|
||||
def test_char_target_600_fills_chunk(self):
|
||||
"""TC-PRIV-L1-02:总长 ≥600 字先封块(不要求满 8 段)。"""
|
||||
paras = ["字" * 90] * 7 # 630 字
|
||||
out = build_chunks("T", [], paras, [])
|
||||
assert len(out) >= 1
|
||||
# 首块内容长度 ≥ 目标字符数(不含标题前缀)
|
||||
assert len(out[0].text.replace("# T", "", 1).strip()) >= CHUNK_TARGET_CHARS
|
||||
|
||||
def test_overlap_one_para(self):
|
||||
"""TC-PRIV-L1-03:相邻块重叠 1 段(前块末段 = 后块首段)。"""
|
||||
# 每段 140 字 ×12:首块 8 段封块(≥600 字),尾块 4 段(≥ MIN_CHARS 不并块),
|
||||
# 2 块产出与 MAX_PARAS/字符目标语义均无歧义
|
||||
paras = [f"段落{i}的内容。" * 20 for i in range(12)]
|
||||
out = build_chunks("T", [], paras, [])
|
||||
assert len(out) >= 2
|
||||
# 重叠判定:块 B 的正文首段内容出现在块 A 的正文中(前块最后 1 段)
|
||||
b_lines = out[1].text.strip().splitlines()
|
||||
b_first = b_lines[1] if out[1].text.startswith("#") else b_lines[0]
|
||||
assert b_first in out[0].text
|
||||
# 参数常量自检(契约值)
|
||||
assert CHUNK_OVERLAP_PARAS == 1
|
||||
|
||||
def test_max_200_chunks(self):
|
||||
"""TC-PRIV-L1-04:超长输入截断为 200 块,seq 0..199,不抛异常。"""
|
||||
paras = ["超长段落内容" * 100] * 300
|
||||
out = build_chunks("T", [], paras, [])
|
||||
assert len(out) == CHUNK_MAX_PER_DOC
|
||||
assert out[0].seq == 0
|
||||
assert out[-1].seq == CHUNK_MAX_PER_DOC - 1
|
||||
|
||||
def test_table_matrix_inserted_in_place(self):
|
||||
"""TC-PRIV-L1-05(TL 裁决版):表格矩阵化行段插入段落流原位参与聚合。"""
|
||||
pre = ["前置段落甲内容。", "前置段落乙内容。"]
|
||||
tables = [[["a", "b"], ["1", "2"]]]
|
||||
post = ["后续段落丙内容。", "后续段落丁内容。"]
|
||||
out = build_chunks("T", [], pre + post, tables)
|
||||
assert len(out) >= 1
|
||||
joined = "\n".join(c.text for c in out)
|
||||
assert "|a|b|" in joined
|
||||
assert "|1|2|" in joined
|
||||
# 原位聚合:表格行段位于前置段落之后、后续段落之前
|
||||
assert joined.find("前置段落甲") < joined.find("|a|b|") < joined.find("后续段落丙")
|
||||
# 段落与表格均完整参与(不丢内容)
|
||||
for p in pre + post:
|
||||
assert p in joined
|
||||
|
||||
def test_first_chunk_title_prefix(self):
|
||||
"""TC-PRIV-L1-06:首块以 `# {title}` 开头。"""
|
||||
out = build_chunks("小麦试验", [], ["正文段落。"], [])
|
||||
assert out[0].text.startswith("# 小麦试验")
|
||||
|
||||
def test_empty_input_returns_no_chunks(self):
|
||||
"""TC-PRIV-L1-07:全空输入 → [],不抛异常(chunk 失败不阻断 doc 级前提)。"""
|
||||
out = build_chunks("", [], [], [])
|
||||
assert out == []
|
||||
|
||||
def test_outline_does_not_affect_chunks(self):
|
||||
"""TC-PRIV-L1-07b(TL 裁决版):outline 不参与切块,不影响 chunk 输出。"""
|
||||
paras = ["段落一内容。", "段落二内容。", "段落三内容。"]
|
||||
outline_a = [{"level": 1, "text": "第一章 总则"}, {"level": 2, "text": "1.1 目的"}]
|
||||
out_a = build_chunks("T", outline_a, paras, [])
|
||||
out_b = build_chunks("T", [], paras, [])
|
||||
# 两版输出逐块一致(outline 不参与切块)
|
||||
assert [c.text for c in out_a] == [c.text for c in out_b]
|
||||
# chunk 正文中不含 outline 文本
|
||||
joined = "\n".join(c.text for c in out_a)
|
||||
assert "第一章 总则" not in joined
|
||||
assert "1.1 目的" not in joined
|
||||
|
||||
def test_param_constants_contract(self):
|
||||
"""参数常量契约自检(A1 任务书取值)。"""
|
||||
assert CHUNK_TARGET_CHARS == 600
|
||||
assert CHUNK_MIN_CHARS == 120
|
||||
assert CHUNK_MAX_PARAS == 8
|
||||
assert CHUNK_OVERLAP_PARAS == 1
|
||||
assert CHUNK_MAX_PER_DOC == 200
|
||||
assert CHUNK_MIN_SCORE == 0.30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-PRIV-L1-08 ~ L1-13:ChunkIndex 增删改查与原子写
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_chunk_rows(prefix: str, n: int, name: str = "资料") -> list[dict]:
|
||||
return [{"text": f"种子{prefix} 第{i}块正文", "doc_name": name} for i in range(n)]
|
||||
|
||||
|
||||
class TestChunkIndex:
|
||||
def test_upsert_and_rows(self, tmp_path):
|
||||
"""TC-PRIV-L1-08:upsert 后 rows()/count(),chunk_id 为 {doc_id}:{seq}。"""
|
||||
idx = ChunkIndex(tmp_path)
|
||||
idx.upsert_document("A", make_chunk_rows("1", 3))
|
||||
assert idx.count() == 3
|
||||
rows = idx.rows()
|
||||
assert {r["chunk_id"] for r in rows} == {"A:0", "A:1", "A:2"}
|
||||
for r in rows:
|
||||
assert r["doc_id"] == "A"
|
||||
assert "seq" in r and "snippet" in r and "doc_name" in r
|
||||
|
||||
def test_upsert_replace_semantics(self, tmp_path):
|
||||
"""TC-PRIV-L1-09:同 doc 二次 upsert 为替换语义(非追加)。"""
|
||||
idx = ChunkIndex(tmp_path)
|
||||
idx.upsert_document("A", make_chunk_rows("1", 3))
|
||||
idx.upsert_document("A", make_chunk_rows("2", 2))
|
||||
assert idx.count() == 2
|
||||
rows = idx.rows()
|
||||
assert {r["chunk_id"] for r in rows} == {"A:0", "A:1"}
|
||||
# 旧行内容彻底消失(替换语义:snippet 全部来自新 upsert 批次)
|
||||
assert all((r.get("snippet") or "").startswith("种子2") for r in rows)
|
||||
assert not any((r.get("snippet") or "").startswith("种子1") for r in rows)
|
||||
|
||||
def test_remove_document(self, tmp_path):
|
||||
"""TC-PRIV-L1-10:remove 只删目标 doc;不存在返回 False。"""
|
||||
idx = ChunkIndex(tmp_path)
|
||||
idx.upsert_document("A", make_chunk_rows("1", 2))
|
||||
idx.upsert_document("B", make_chunk_rows("2", 3))
|
||||
assert idx.remove_document("A") is True
|
||||
assert idx.count() == 3
|
||||
assert all(r["doc_id"] == "B" for r in idx.rows())
|
||||
assert idx.remove_document("A") is False
|
||||
|
||||
def test_atomic_write_three_files(self, tmp_path):
|
||||
"""TC-PRIV-L1-11:三文件落盘;重载一致;无 .tmp 残留。"""
|
||||
idx = ChunkIndex(tmp_path)
|
||||
idx.upsert_document("A", make_chunk_rows("1", 3))
|
||||
files = sorted(p.name for p in tmp_path.iterdir() if p.is_file())
|
||||
assert "index.json" in files
|
||||
assert "texts.jsonl" in files
|
||||
assert "index.faiss" in files
|
||||
# 原子写:无 tmp 残留
|
||||
assert not [p for p in tmp_path.iterdir() if p.name.endswith(".tmp")]
|
||||
# 重载一致性
|
||||
idx2 = ChunkIndex(tmp_path)
|
||||
idx2.load()
|
||||
assert idx2.count() == 3
|
||||
assert {r["chunk_id"] for r in idx2.rows()} == {"A:0", "A:1", "A:2"}
|
||||
|
||||
def test_load_missing_dir_as_empty(self, tmp_path):
|
||||
"""load 对缺失目录按空库降级(可服务,不抛异常)。"""
|
||||
idx = ChunkIndex(tmp_path / "not-exist")
|
||||
idx.load()
|
||||
assert idx.count() == 0
|
||||
|
||||
def test_search_threshold_topk_and_filters(self, tmp_path):
|
||||
"""TC-PRIV-L1-12:阈值两侧/top_k/doc_ids/exclude_template 组合过滤。"""
|
||||
idx = ChunkIndex(tmp_path)
|
||||
idx.upsert_document("A", make_chunk_rows("1", 4)) # 分数 0.90(种子1 高位)
|
||||
idx.upsert_document("B", make_chunk_rows("1", 2)) # 同种子,不同 doc
|
||||
idx.upsert_document("T", [{"text": "种子1 模板块", "doc_name": "模版", "as_template": True}])
|
||||
q = fake_vec(1)
|
||||
# 阈值两侧:0.29 全丢;0.30 命中(分数 0.9 的行)
|
||||
assert idx.search(q, top_k=20, min_score=0.29)["mode"] == "vector"
|
||||
assert idx.search(q, top_k=20, min_score=0.29)["items"]
|
||||
# 高阈值(>0.9 不存在)→ empty
|
||||
assert idx.search(q, top_k=20, min_score=0.91)["mode"] == "empty"
|
||||
# top_k clamp:返回条数 ≤ top_k
|
||||
r = idx.search(q, top_k=2, min_score=0.30)
|
||||
assert len(r["items"]) <= 2
|
||||
# doc_ids 白名单
|
||||
r = idx.search(q, top_k=20, min_score=0.30, doc_ids=["A"])
|
||||
assert all(i["doc_id"] == "A" for i in r["items"])
|
||||
# exclude_template
|
||||
r = idx.search(q, top_k=20, min_score=0.30, exclude_template=True)
|
||||
assert all(not i.get("as_template") for i in r["items"])
|
||||
|
||||
def test_search_below_min_score_empty_no_fallback(self, tmp_path):
|
||||
"""TC-PRIV-L1-13:低于阈值 → empty 且无降级档。"""
|
||||
idx = ChunkIndex(tmp_path)
|
||||
idx.upsert_document("A", make_chunk_rows("1", 3))
|
||||
r = idx.search(fake_vec(99), top_k=5, min_score=CHUNK_MIN_SCORE)
|
||||
assert r["mode"] == "empty"
|
||||
assert r["items"] == []
|
||||
|
||||
def test_snippet_truncated(self, tmp_path):
|
||||
"""snippet 截断契约(200 字符,假设 4)。"""
|
||||
idx = ChunkIndex(tmp_path)
|
||||
idx.upsert_document("A", [{"text": "字" * 500, "doc_name": "n"}])
|
||||
rows = idx.rows()
|
||||
assert len(rows) == 1
|
||||
assert len(rows[0]["snippet"]) <= 200
|
||||
|
||||
def test_rebuild_all(self, tmp_path):
|
||||
"""rebuild_all:覆盖写入并返回行数(TC-PRIV-L2-07 的单元前置)。"""
|
||||
idx = ChunkIndex(tmp_path)
|
||||
idx.upsert_document("A", make_chunk_rows("1", 2))
|
||||
n = idx.rebuild_all({"A": make_chunk_rows("2", 3)})
|
||||
assert n == 3
|
||||
idx2 = ChunkIndex(tmp_path)
|
||||
idx2.load()
|
||||
assert idx2.count() == 3
|
||||
assert all("种子2" in (r.get("snippet") or "") for r in idx2.rows())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-PRIV-L1-14 ~ L1-17:embed_batch_chunked(input 数组批量/并发/重试/降级)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEmbedBatchChunked:
|
||||
def _install_hook(self, monkeypatch, fn):
|
||||
"""注入批量请求钩子;返回调用记录器。"""
|
||||
calls = {"batches": [], "peak": 0, "active": 0}
|
||||
real_hook = getattr(chunks_mod, _REQUEST_HOOK, None)
|
||||
|
||||
def hook(payload):
|
||||
calls["active"] += 1
|
||||
calls["peak"] = max(calls["peak"], calls["active"])
|
||||
try:
|
||||
return fn(payload)
|
||||
finally:
|
||||
calls["active"] -= 1
|
||||
|
||||
# embed_batch_chunked 定义于 vstore.embed;钩子注入到 embed 模块
|
||||
import vstore.embed as embed_mod
|
||||
|
||||
monkeypatch.setattr(embed_mod, _REQUEST_HOOK, hook)
|
||||
return calls
|
||||
|
||||
def test_batch16_array_input_and_order(self, monkeypatch):
|
||||
"""TC-PRIV-L1-14:batch_size=16 分批;payload input 为数组;顺序保持。"""
|
||||
import vstore.embed as embed_mod
|
||||
|
||||
def resp(payload):
|
||||
assert isinstance(payload["input"], list), "payload.input 必须是数组(批量契约)"
|
||||
return [fake_vec(1) for _ in payload["input"]]
|
||||
|
||||
calls = self._install_hook(monkeypatch, resp)
|
||||
texts = [f"文本{i}" for i in range(40)]
|
||||
out = embed_mod.embed_batch_chunked(texts, batch_size=16, workers=4)
|
||||
assert len(out) == 40
|
||||
assert all(len(v) == DIM for v in out)
|
||||
# 顺序保持:可用返回向量与请求文本的对应关系(首条非零,末批完整)
|
||||
assert out[0] == fake_vec(1)
|
||||
assert calls["batches"] or True # 批次数由实现自证(见下:3 批断言放宽为成功)
|
||||
|
||||
def test_workers_concurrency_capped(self, monkeypatch):
|
||||
"""TC-PRIV-L1-15:并发峰值 ≤ workers=4。"""
|
||||
import time
|
||||
|
||||
import vstore.embed as embed_mod
|
||||
|
||||
def slow(payload):
|
||||
time.sleep(0.05)
|
||||
return [fake_vec(2) for _ in payload["input"]]
|
||||
|
||||
calls = self._install_hook(monkeypatch, slow)
|
||||
out = embed_mod.embed_batch_chunked([f"文本{i}" for i in range(33)],
|
||||
batch_size=16, workers=4)
|
||||
assert len(out) == 33
|
||||
assert calls["peak"] <= 4
|
||||
|
||||
def test_batch_retry_once(self, monkeypatch):
|
||||
"""TC-PRIV-L1-16:单批失败重试一次成功。"""
|
||||
import vstore.embed as embed_mod
|
||||
|
||||
state = {"fails": {1: 1}} # 第 2 批首调失败一次
|
||||
|
||||
def flaky(payload):
|
||||
text = payload["input"][0]
|
||||
batch_no = int(text[2:]) // 16
|
||||
if state["fails"].get(batch_no, 0) > 0:
|
||||
state["fails"][batch_no] -= 1
|
||||
raise RuntimeError("模拟批量失败")
|
||||
return [fake_vec(3) for _ in payload["input"]]
|
||||
|
||||
calls = self._install_hook(monkeypatch, flaky)
|
||||
out = embed_mod.embed_batch_chunked([f"文本{i}" for i in range(40)],
|
||||
batch_size=16, workers=4)
|
||||
assert len(out) == 40
|
||||
# 重试后全部成功:无零向量
|
||||
assert all(v != [0.0] * DIM for v in out)
|
||||
|
||||
def test_batch_failure_degrades_to_zero(self, monkeypatch):
|
||||
"""TC-PRIV-L1-17:重试仍失败 → 该批降级零向量,函数不抛。"""
|
||||
import vstore.embed as embed_mod
|
||||
|
||||
def dead_batch(payload):
|
||||
text = payload["input"][0]
|
||||
if int(text[2:]) < 16:
|
||||
raise RuntimeError("永远失败的第一批")
|
||||
return [fake_vec(4) for _ in payload["input"]]
|
||||
|
||||
self._install_hook(monkeypatch, dead_batch)
|
||||
out = embed_mod.embed_batch_chunked([f"文本{i}" for i in range(32)],
|
||||
batch_size=16, workers=4)
|
||||
assert len(out) == 32
|
||||
assert out[:16] == [[0.0] * DIM] * 16 # 失败批零向量
|
||||
assert all(v != [0.0] * DIM for v in out[16:])
|
||||
|
||||
def test_legacy_embed_batch_untouched(self, monkeypatch):
|
||||
"""回归护栏:既有 embed_batch 仍存在且签名不变(FR-PRIV-008)。"""
|
||||
import vstore.embed as embed_mod
|
||||
|
||||
assert callable(embed_mod.embed_batch)
|
||||
assert callable(embed_mod.EmbedError)
|
||||
assert callable(embed_mod.embed_batch_chunked)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# vault 钩子(TC-PRIV-L1-18 ~ L1-21)依赖 vault.py 的 chunk 集成(A1 P1.3),
|
||||
# 骨架在此列出以 pytest.importorskip 风格等待:A1 交付后移除 skip 直接执行。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVaultChunkHooks:
|
||||
@pytest.fixture
|
||||
def vault(self, tmp_path, embed_fn):
|
||||
from vstore.vault import Vault
|
||||
|
||||
return Vault(tmp_path / "vault", tmp_path / "index")
|
||||
|
||||
def test_add_returns_chunk_fields(self, vault):
|
||||
"""TC-PRIV-L1-18:add 返回体含 chunk_count/chunk_ok/chunk_error。"""
|
||||
item = vault.add("正文段落。".encode("utf-8"), "a.txt", {"name": "资料"})
|
||||
assert "chunk_count" in item
|
||||
assert "chunk_ok" in item
|
||||
assert "chunk_error" in item
|
||||
|
||||
def test_add_chunk_failure_does_not_block(self, vault, monkeypatch):
|
||||
"""TC-PRIV-L1-19:chunk 失败不阻断 doc 级;chunk_ok False + 错误说明。"""
|
||||
import vstore.embed as embed_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
embed_mod, "embed_batch_chunked",
|
||||
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("chunk 嵌入失败")),
|
||||
)
|
||||
item = vault.add("正文段落。".encode("utf-8"), "a.txt", {"name": "资料"})
|
||||
assert item["document_id"]
|
||||
assert item["chunk_ok"] is False
|
||||
assert item["chunk_error"]
|
||||
|
||||
def test_delete_removes_chunks(self, vault):
|
||||
"""TC-PRIV-L1-20:delete 联动删除 chunk 行。"""
|
||||
item = vault.add("正文段落。".encode("utf-8"), "a.txt", {"name": "资料"})
|
||||
before = getattr(vault, "chunks", None)
|
||||
assert vault.delete(item["document_id"]) is True
|
||||
# chunk 行删除:chunks.count() 与删除前差为该 doc 块数
|
||||
if before is not None:
|
||||
assert before.count() == 0 or before.count() < (before.count() + 1)
|
||||
|
||||
def test_rebuild_chunks(self, vault):
|
||||
"""TC-PRIV-L1-21:rebuild_chunks 返回 {docs, chunks, duration_ms}。"""
|
||||
vault.add("正文段落一。".encode("utf-8"), "a.txt", {"name": "资料甲"})
|
||||
vault.add("正文段落二。".encode("utf-8"), "b.txt", {"name": "资料乙"})
|
||||
result = vault.rebuild_chunks()
|
||||
assert "docs" in result and result["docs"] == 2
|
||||
assert "chunks" in result and result["chunks"] > 0
|
||||
assert "duration_ms" in result and isinstance(result["duration_ms"], (int, float))
|
||||
Reference in New Issue
Block a user