From 9e5367b26806028c604e7a20a715b825dfced089 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 7 Sep 2026 21:42:22 +0800 Subject: [PATCH] =?UTF-8?q?test(priv):=20T1=20=E7=94=A8=E4=BE=8B=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF=2092=20=E6=9D=A1=E2=80=94=E2=80=94100%=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=20FR-PRIV=20=E8=B0=83=E6=95=B4=E5=86=85=E5=AE=B9=20+?= =?UTF-8?q?=20=E5=8F=8C=E5=90=91=E8=BF=BD=E6=BA=AF=E7=9F=A9=E9=98=B5=20+?= =?UTF-8?q?=20=E6=B5=8B=E8=AF=95=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/team/TEST_CASES_PRIVATE.md | 271 +++++++++++ ui/tests/private_docs/chat.private.test.js | 291 ++++++++++++ .../private_docs/components.private.test.js | 86 ++++ ui/tests/private_docs/docPaneMount.test.js | 69 +++ .../private_docs/privateLibraryPane.test.js | 85 ++++ ui/tests/private_docs/vitest.config.js | 21 + vstore/tests/test_chunks.py | 437 ++++++++++++++++++ 7 files changed, 1260 insertions(+) create mode 100644 docs/team/TEST_CASES_PRIVATE.md create mode 100644 ui/tests/private_docs/chat.private.test.js create mode 100644 ui/tests/private_docs/components.private.test.js create mode 100644 ui/tests/private_docs/docPaneMount.test.js create mode 100644 ui/tests/private_docs/privateLibraryPane.test.js create mode 100644 ui/tests/private_docs/vitest.config.js create mode 100644 vstore/tests/test_chunks.py diff --git a/docs/team/TEST_CASES_PRIVATE.md b/docs/team/TEST_CASES_PRIVATE.md new file mode 100644 index 0000000..08f2e6b --- /dev/null +++ b/docs/team/TEST_CASES_PRIVATE.md @@ -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 被移除(DOMPurify);加粗渲染为 | 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 先例),依赖不改 | diff --git a/ui/tests/private_docs/chat.private.test.js b/ui/tests/private_docs/chat.private.test.js new file mode 100644 index 0000000..65e36bb --- /dev/null +++ b/ui/tests/private_docs/chat.private.test.js @@ -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资料'); + }); +}); diff --git a/ui/tests/private_docs/components.private.test.js b/ui/tests/private_docs/components.private.test.js new file mode 100644 index 0000000..d38a138 --- /dev/null +++ b/ui/tests/private_docs/components.private.test.js @@ -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: '结论**成立**。', + }; + const wrapper = mount(AssistantMessage, { + props: { message: messageWithBlock(block) }, + }); + const html = wrapper.find('.private-doc-block').html(); + expect(html).not.toContain('成立'); + }); + + it('无 private_doc 块的消息不渲染该卡(零回归)', () => { + const wrapper = mount(AssistantMessage, { + props: { message: messageWithBlock({ type: 'text', html: '

普通答案

' }) }, + }); + expect(wrapper.find('.private-doc-block').exists()).toBe(false); + }); +}); diff --git a/ui/tests/private_docs/docPaneMount.test.js b/ui/tests/private_docs/docPaneMount.test.js new file mode 100644 index 0000000..af4d3fa --- /dev/null +++ b/ui/tests/private_docs/docPaneMount.test.js @@ -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'); + }); +}); diff --git a/ui/tests/private_docs/privateLibraryPane.test.js b/ui/tests/private_docs/privateLibraryPane.test.js new file mode 100644 index 0000000..4ce0b4c --- /dev/null +++ b/ui/tests/private_docs/privateLibraryPane.test.js @@ -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'); + }); +}); diff --git a/ui/tests/private_docs/vitest.config.js b/ui/tests/private_docs/vitest.config.js new file mode 100644 index 0000000..d030033 --- /dev/null +++ b/ui/tests/private_docs/vitest.config.js @@ -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, + }, +}); diff --git a/vstore/tests/test_chunks.py b/vstore/tests/test_chunks.py new file mode 100644 index 0000000..9f52e0c --- /dev/null +++ b/vstore/tests/test_chunks.py @@ -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))