自证: vstore pytest 27 passed; vitest 23 passed; 追溯矩阵 92=92 无孤儿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
323 lines
14 KiB
JavaScript
323 lines
14 KiB
JavaScript
// @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资料');
|
||
});
|
||
});
|