Files
PlantAI-rag-new/ui/tests/private_docs/chat.private.test.js
2026-09-07 22:41:36 +08:00

323 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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资料');
});
});