// @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),不锁内部实现。 // 关键修正(对齐 A1 交付): // - 可变状态用 vi.hoisted(vi.mock 工厂被提升,引用顶层 let 会 TDZ); // - 只 mock http.js 边界,api/private.js 走 A1 真实实现(L1-40 契约断言才真实); // - chatStream mock 每帧间隔 frameDelayMs(fake timers):保证「流未收口」时私有命中 // 可到达(pending 路径可被真实触发,而非仅终态直挂)。 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { flushPromises } from '@vue/test-utils'; import { setActivePinia, createPinia } from 'pinia'; // ---- 可变状态(vi.mock 工厂可安全引用)---- const h = vi.hoisted(() => ({ frames: [], frameDelayMs: 10, postCalls: [], privateCalls: [], privateImpl: () => ({ ok: true, hit: false, mode: 'empty', answer: '', items: [] }), })); // ---- mock http.js 边界(api/private.js、api/docgen.js 走真实实现)---- vi.mock('../../src/api/http.js', () => ({ post: vi.fn(async (path, body, opts) => { h.postCalls.push({ path, body, opts }); if (path === '/api/qa/private') h.privateCalls.push(body); return h.privateImpl({ path, body, opts }); }), get: vi.fn(async () => ({ ok: true, items: [] })), patch: vi.fn(async () => ({ ok: true })), del: vi.fn(async () => null), ApiError: class ApiError extends Error { constructor(message, opts = {}) { super(message); this.code = opts.code || 'INTERNAL'; } }, HttpError: class HttpError extends Error {}, })); // ---- mock QA 流(每帧间隔 frameDelayMs,让流式中间态可到达)---- vi.mock('../../src/api/qa.js', () => ({ chatStream: vi.fn(async (body, handlers) => { for (const f of h.frames) { await new Promise((resolve) => setTimeout(resolve, h.frameDelayMs)); 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, }; }); 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 MISS_PAYLOAD = { ok: true, hit: false, mode: 'empty', answer: '', items: [] }; 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; h.frames = []; h.frameDelayMs = 10; h.postCalls = []; h.privateCalls = []; h.privateImpl = () => MISS_PAYLOAD; 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(); h.privateImpl = async () => HIT_PAYLOAD; h.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(h.privateCalls.length).toBe(1); }); it('TC-PRIV-L1-32 未命中:无块、无用户可见错误、QA 流程不受影响', async () => { const { chat } = await setupChat(); h.privateImpl = async () => MISS_PAYLOAD; h.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(); h.privateImpl = async () => { throw new Error('boom'); }; h.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 流仍 streaming → pending 路径) h.privateImpl = async () => HIT_PAYLOAD; h.frames = [ { event: 'progress', data: { phase: 'planning', label: '理解问题' } }, { event: 'progress', data: { phase: 'retrieval', 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]'); // onComplete 整体替换 blocks 后 flush 挂尾部:pipeline 仍是第一个块 expect(aMsg.blocks[0].type).toBe('pipeline'); }); it('TC-PRIV-L1-34 onComplete 替换后命中才到:终态直挂 + 补 persist', async () => { const { chat } = await setupChat(); let resolvePrivate; h.privateImpl = () => new Promise((res) => { resolvePrivate = () => res(HIT_PAYLOAD); }); h.frames = [{ event: 'complete', data: completePayload }]; const sendP = chat.send('问题', { mode: 'ask' }); await vi.runAllTimersAsync(); await sendP; // QA 已终态(ctx 清理)后私有响应才返回 resolvePrivate(); await flushPromises(); await vi.runAllTimersAsync(); 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(); h.privateImpl = async () => HIT_PAYLOAD; h.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(); h.privateImpl = async () => HIT_PAYLOAD; const sendP = chat.send('问题', { mode: 'search' }); await vi.runAllTimersAsync(); await sendP; expect(h.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(); h.privateImpl = async () => MISS_PAYLOAD; h.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 正常完成,私有未命中 h.frames = [{ event: 'complete', data: completePayload }]; const retryP = chat.retry(); await vi.runAllTimersAsync(); await retryP; expect(h.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 契约:路径/默认参数/60s 超时(真实 api/private.js 经 http mock)', async () => { const { askPrivate } = await import('../../src/api/private.js'); const payload = await askPrivate({ question: 'q', session_id: 's1' }); expect(payload.hit).toBe(false); // 默认 miss 实现 expect(h.postCalls.length).toBe(1); const call = h.postCalls[0]; expect(call.path).toBe('/api/qa/private'); expect(call.body).toEqual({ question: 'q', session_id: 's1', top_k: 8, min_score: 0.30 }); expect(call.opts).toEqual({ timeout: 60000 }); }); it('TC-PRIV-L1-42 跨轮归属:迟到响应不得串挂到本轮消息(行为级判据)', async () => { const { chat } = await setupChat(); const pending = []; h.privateImpl = () => new Promise((res) => { pending.push(res); }); h.frames = [{ event: 'complete', data: completePayload }]; const first = chat.send('第一问', { mode: 'ask' }); await vi.runAllTimersAsync(); await first; expect(h.privateCalls.length).toBe(1); // 第 2 轮开始并推进到流式中间态(只走第一帧定时器,QA 流未收口) h.frames = [ { event: 'progress', data: { phase: 'planning', label: '理解问题' } }, { event: 'complete', data: completePayload }, ]; const second = chat.send('第二问', { mode: 'ask' }); await vi.advanceTimersByTimeAsync(h.frameDelayMs); expect(h.privateCalls.length).toBe(2); // 此刻才 resolve 第 1 轮的命中响应(跨轮迟到场景) pending[0](HIT_PAYLOAD); // 第 2 轮 QA 正常收口(其私有响应未命中) pending[1](MISS_PAYLOAD); await vi.runAllTimersAsync(); await second; const msgs = chat.messages.filter((m) => m.role === 'assistant'); const round2 = msgs[1]; // 行为级判据:第 1 轮迟到的命中结果不得串挂到第 2 轮消息 expect(privateBlockOf(round2)).toBeNull(); expect(JSON.stringify(round2.blocks || [])).not.toContain('[资料1]'); // 第 1 轮迟到的结果挂到第 1 轮消息或丢弃均可(不锁实现),但不得串挂 const blk1 = privateBlockOf(msgs[0]); if (blk1) expect(blk1.items[0].name).toBe('水稻SPL资料'); }); });