Files
PlantAI-rag-new/tests/frontend/unit/b2/chat.store.test.js
T
jerryandClaude Fable 5 0875bb036c P0/M1: 四部分重构——frontend→ui、backend→sdk 改名 + 引用修复 + 骨架
- git mv 保留历史:ui/(Vue3+Vite)、sdk/(BFF 中间层)
- 路径语义修正:db_path/figures_cache 改相对 sdk/(BFF cwd),
  历史库 sdk/backend/data 迁移至 sdk/data/(会话/图片缓存无损)
- 引用修复 ~15 处:config.py、deploy/*.sh、vitest/playwright 配置、
  单测导入路径、docs、README、gitignore、.env.example
- 骨架:agent/{qa,methodology,legacy-rag,vendor}、knowledge/manifest.yaml、
  tooling/{compile,kb,crypto,assemble,audit,tools,scripts} + README
- 门禁验证:sdk pytest 125 通过、ui vitest 158 通过、
  生产 BFF 脚本重启四上游全绿 + 静态托管 + 会话库无损

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:56:47 +08:00

474 lines
22 KiB
JavaScript
Raw 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.store.test.js —— chat store 状态机单测
// 覆盖:send 全流程(FR-CHAT-008 时序 1–7,SSE 帧序列驱动 → 相位推进 →
// 揭示动画(fake timers)→ 持久化调用时序)、stop(FR-CHAT-011)、
// retry(FR-CHAT-012)、恢复与「已中断」、错误帧映射(FR-CHAT-008)。
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { newSkeletonMessage, qaToBlocks, PIPELINE_PHASES } from '../../../../ui/src/stores/chat.js';
// ---- mock 依赖模块 ----
let postImpl;
let frames = [];
let updateSessionCalls = [];
vi.mock('../../../../ui/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 });
// 与真实 api/qa.js 一致:timeout 帧补 code TIMEOUT 后走 onError
else if (f.event === 'timeout') handlers.onError({ payload: { code: 'TIMEOUT', ...f.data } });
else if (f.event === 'progress') handlers.onProgress({ payload: f.data });
else if (f.event === 'evidence') handlers.onEvidence({ payload: f.data });
else handlers.onRaw({ payload: f.data });
}
}),
search: vi.fn(async () => { throw new Error('unused'); }),
info: vi.fn(async () => ({})),
}));
vi.mock('../../../../ui/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) => { updateSessionCalls.push({ sid, body }); return { id: sid, ...body }; }),
deleteSession: vi.fn(async () => {}),
getSessionSources: vi.fn(async () => ({ session_id: 's1', total: 0, sources: [] })),
_calls: calls,
};
});
import { useChatStore } from '../../../../ui/src/stores/chat.js';
import { useSessionsStore } from '../../../../ui/src/stores/sessions.js';
import * as qaApi from '../../../../ui/src/api/qa.js';
import * as sessApi from '../../../../ui/src/api/sessions.js';
const calls = sessApi._calls;
const progress = (phase, label, extra = {}) => ({
event: 'progress',
data: { stage: `${phase}_stage`, phase, label, details: {}, at: '2026-08-25T00:00:01Z', ...extra },
});
const completePayload = {
answer: '水稻SPL家族共有19个成员',
source_count: 1,
sources: [{ id: 'src-1', title: 'T', paper_id: 'PMID1', pmid: '1', snippet: 's', score: 0.9, url: 'https://x' }],
chains: [],
graph: { nodes: [{ id: 'OsSPL14', label: 'OsSPL14', type: 'Gene' }], edges: [], chains: [] },
raw: {},
};
async function setupChat({ sessionId = 'sess-1', active = true } = {}) {
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: {} }];
if (active) sessions.activeId = sessionId;
chat.sessionId = sessionId;
frames = [];
updateSessionCalls = [];
calls.messages.length = 0;
calls.patches.length = 0;
return { chat, sessions };
}
describe('chat store:send 全流程(FR-CHAT-008)', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); });
it('时序:用户消息落库 → 会话 running → 骨架 → 相位推进 → 揭示 → 终态落库', async () => {
const { chat, sessions } = await setupChat();
frames = [
progress('planning', '开始理解问题'),
progress('retrieval', '发现相关概念'),
progress('evidence', '证据审核完成'),
progress('generation', '开始生成答案'),
progress('done', '检索完成'),
{ event: 'complete', data: completePayload },
];
// 揭示动画走 matchMedia 默认 false(jsdom 无 matchMedia → 直接跳过揭示)
const p = chat.send('水稻SPL基因家族的功能研究有哪些关键进展?');
await vi.advanceTimersByTimeAsync(0);
await p;
// 1. 用户消息 POST
expect(calls.messages[0]).toMatchObject({ sid: 'sess-1', body: { role: 'user', text: '水稻SPL基因家族的功能研究有哪些关键进展?' } });
// 2. 会话 running PATCH
expect(updateSessionCalls.some((c) => c.body.status === 'running')).toBe(true);
// 3. 骨架 POST(pipeline 五相位 waiting)
const skel = calls.messages[1];
expect(skel.body.role).toBe('assistant');
expect(skel.body.blocks[0].type).toBe('pipeline');
expect(skel.body.blocks[0].steps.map((s) => s.state)).toEqual(['waiting', 'waiting', 'waiting', 'waiting', 'waiting']);
// 5. 消息内:用户 + 助手
expect(chat.messages).toHaveLength(2);
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('done');
// BUG-R1-06:text 块 html 必须经 markdown 渲染(<p> 包裹),而非原始 answer 字符串
const textBlock = aMsg.blocks.find((b) => b.type === 'text');
expect(textBlock).toBeTruthy();
expect(textBlock.html).toContain(`<p>${completePayload.answer}</p>`);
expect(aMsg.blocks.some((b) => b.type === 'sources')).toBe(true);
expect(aMsg.blocks.some((b) => b.type === 'graph')).toBe(true);
// pipeline 全部 done
const pipe = aMsg.blocks.find((b) => b.type === 'pipeline');
expect(pipe.status).toBe('done');
expect(pipe.steps.every((s) => s.state === 'done')).toBe(true);
// 6/7. 终态落库 PATCH:status done + blocks(stream 瞬态不落库)
const finalPatch = calls.patches[calls.patches.length - 1];
expect(finalPatch.body.status).toBe('done');
expect(finalPatch.body.blocks.some((b) => b.type === 'stream')).toBe(false);
expect(finalPatch.body.blocks.some((b) => b.type === 'text')).toBe(true);
// 会话状态同步 done
expect(updateSessionCalls.some((c) => c.body.status === 'done')).toBe(true);
expect(sessions.items[0].status).toBe('done');
// busy 复位 + 结果可观测
expect(chat.busy).toBe(false);
expect(chat.lastResult.ok).toBe(true);
// 阶段事件供 bench(B3 契约)
expect(chat.currentEvents).toHaveLength(5);
});
it('检查点:~3s 间隔 PATCH streaming(FR-CHAT-008 步骤 6)', async () => {
const { chat } = await setupChat();
let gateResolve;
qaApi.chatStream.mockImplementationOnce(async (body, handlers) => {
handlers.onProgress({ payload: progress('planning', '开始理解问题').data });
await new Promise((r) => { gateResolve = r; }); // 流挂起,检查点定时器得以触发
handlers.onComplete({ payload: completePayload });
});
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(3100);
// 检查点 PATCH(status streaming)
expect(calls.patches.some((c) => c.body.status === 'streaming')).toBe(true);
gateResolve();
await vi.advanceTimersByTimeAsync(0);
await p;
expect(chat.busy).toBe(false);
});
it('evidence 帧(方案 3):先挂 sources/graph、complete 覆盖、只挂一次', async () => {
const { chat } = await setupChat();
frames = [
progress('planning', '开始理解问题'),
{
event: 'evidence',
data: {
answer: '',
source_count: 3,
sources: [
{ id: 'ev-1', title: 'SearchEvidence', paper_id: 'PMID2', pmid: '2', snippet: 's', score: 0.8, url: 'https://y' },
],
graph: { nodes: [{ id: 'EvNode', label: 'EvNode', type: 'Gene' }], edges: [], chains: [] },
chains: [],
raw: {},
},
},
{ event: 'evidence', data: { answer: '', sources: [{ id: 'ev-2', title: 'Second', paper_id: 'PMID3', pmid: '3' }] } },
{ event: 'complete', data: completePayload },
];
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
const aMsg = chat.messages[1];
// complete 后 blocks 是 chat payload 的版本(sources 被覆盖,引用编号对)
const sources = aMsg.blocks.find((b) => b.type === 'sources');
expect(sources.items[0].title).toBe('T'); // 来自 completePayload
expect(aMsg.status).toBe('done');
});
it('evidence 帧:挂块期间保持 streaming(busy 未复位)', async () => {
const { chat } = await setupChat();
let gateResolve;
qaApi.chatStream.mockImplementationOnce(async (body, handlers) => {
handlers.onProgress({ payload: progress('planning', '开始理解问题').data });
handlers.onEvidence({
payload: { answer: '', sources: [{ id: 'ev-1', title: 'SearchEvidence', paper_id: 'PMID2', pmid: '2' }], chains: [], graph: { nodes: [], edges: [], chains: [] }, raw: {} },
});
await new Promise((r) => { gateResolve = r; }); // 流挂起:证据已到但答案未到
});
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
const aMsg = chat.messages[1];
// evidence 已挂上,消息仍在流式
expect(aMsg.blocks.some((b) => b.type === 'sources')).toBe(true);
expect(aMsg.status).toBe('streaming');
expect(chat.busy).toBe(true);
gateResolve();
await vi.advanceTimersByTimeAsync(0);
await p;
expect(chat.busy).toBe(false);
});
it('error 帧:TIMEOUT 文案映射 + 保留已收块 + error 落库', async () => {
const { chat, sessions } = await setupChat();
frames = [
progress('planning', '开始理解问题'),
{ event: 'timeout', data: { message: '检索超时' } },
];
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.streamErr).toBe('timeout');
// 已收到的块保留(pipeline 保留)
expect(aMsg.blocks.some((b) => b.type === 'pipeline')).toBe(true);
const finalPatch = calls.patches[calls.patches.length - 1];
expect(finalPatch.body.status).toBe('error');
expect(chat.lastResult.ok).toBe(false);
expect(chat.lastResult.error.message).toBe('检索超时');
expect(sessions.items[0].status).toBe('error');
});
it('error 帧:INVALID_CITATION 映射', async () => {
const { chat } = await setupChat();
frames = [{ event: 'error', data: { code: 'INVALID_CITATION', message: '', retryable: true } }];
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
expect(chat.messages[1].streamErr).toBe('citation');
expect(chat.lastResult.error.message).toBe('引用校验失败,请重试');
});
it('连接中断:fetch reject(网络)→ disconnected 文案', async () => {
const { chat } = await setupChat();
qaApi.chatStream.mockImplementationOnce(async () => { throw new TypeError('Failed to fetch'); });
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.streamErr).toBe('disconnected');
expect(chat.lastResult.error.message).toBe('连接中断,请检查网络后重试');
});
it('BUG-R1-05:502 UPSTREAM_ERROR 信封 → 展示上游 message 而非「连接中断」', async () => {
const { chat } = await setupChat();
// 模拟 http.js 抛出的 ApiError(统一错误信封已解析)
qaApi.chatStream.mockImplementationOnce(async () => {
throw { name: 'ApiError', code: 'UPSTREAM_ERROR', message: '上游问答服务返回错误', status: 502 };
});
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.streamErr).not.toBe('disconnected');
expect(aMsg.errText).toBe('上游问答服务返回错误');
expect(chat.lastResult.error).toMatchObject({ code: 'UPSTREAM_ERROR', message: '上游问答服务返回错误' });
// 终态落库 error
expect(calls.patches[calls.patches.length - 1].body.status).toBe('error');
});
it('BUG-R1-05:503 UPSTREAM_DOWN 信封 → 降级文案', async () => {
const { chat } = await setupChat();
qaApi.chatStream.mockImplementationOnce(async () => {
throw { name: 'ApiError', code: 'UPSTREAM_DOWN', message: '知识问答服务当前不可用,请稍后重试', status: 503, degraded: true };
});
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
expect(chat.messages[1].errText).toBe('知识问答服务当前不可用,请稍后重试');
expect(chat.lastResult.error.code).toBe('UPSTREAM_DOWN');
});
it('BUG-R1-06:qaToBlocks 把 markdown answer 渲染为 HTML(列表/加粗/表格)', () => {
const payload = {
...completePayload,
answer: '- 成员一\n- 成员二\n\n**加粗结论**\n\n| 列A | 列B |\n|---|---|\n| x | y |',
};
const blocks = qaToBlocks(payload);
const text = blocks.find((b) => b.type === 'text');
expect(text).toBeTruthy();
expect(text.html).toContain('<li>');
expect(text.html).toContain('<strong>');
expect(text.html).toContain('<table class="md-table">');
// 落库路径:_persistableBlocks 不输出 stream、text 为渲染后 HTML
expect(text.html).not.toContain('**');
});
it('BUG-R1-02:selectSession 同步 sessions.activeId(活动高亮/顶栏依赖)', async () => {
const { chat, sessions } = await setupChat({ sessionId: 'sess-1' });
sessions.items = [
{ id: 'sess-1', title: 'A', status: 'done', created_at: 'x', updated_at: 'x', message_count: 0, meta: {} },
{ id: 'sess-2', title: 'B', status: 'done', created_at: 'x', updated_at: 'x', message_count: 0, meta: {} },
];
sessApi.getSession.mockImplementationOnce(async () => ({ id: 'sess-2', title: 'B', status: 'done', meta: {}, messages: [] }));
await chat.selectSession('sess-2');
expect(chat.sessionId).toBe('sess-2');
expect(sessions.activeId).toBe('sess-2');
expect(sessions.active?.id).toBe('sess-2');
});
it('检索模式:走 /api/qa/search,无揭示', async () => {
const { chat } = await setupChat();
const searchPayload = { ...completePayload, answer: '', mode: 'search' };
qaApi.search.mockImplementationOnce(async () => searchPayload);
const p = chat.send('检索问题', { mode: 'search' });
await vi.advanceTimersByTimeAsync(0);
await p;
expect(qaApi.search).toHaveBeenCalledWith(expect.objectContaining({ use_llm: false }));
expect(qaApi.chatStream).not.toHaveBeenCalled();
expect(chat.messages[1].status).toBe('done');
// 无 answer → 无 text 块
expect(chat.messages[1].blocks.some((b) => b.type === 'text')).toBe(false);
});
it('模式参数集按 config/modes.js 拼装', async () => {
const { chat } = await setupChat();
frames = [{ event: 'complete', data: completePayload }];
const p = chat.send('问题', { mode: 'plan' });
await vi.advanceTimersByTimeAsync(0);
await p;
expect(qaApi.chatStream).toHaveBeenCalledWith(
expect.objectContaining({ use_llm: true, native_chain_enabled: true, native_chain_max_hops: 3, native_chain_top_k: 5 }),
expect.anything(),
);
});
});
describe('chat store:stop / retry / 恢复', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); });
it('stop:abort → 消息标「已停止」保留块(FR-CHAT-011)', async () => {
const { chat } = await setupChat();
let abortTrigger;
qaApi.chatStream.mockImplementationOnce(async (body, handlers) => {
handlers.onProgress({ payload: progress('planning', '开始理解问题').data });
await new Promise((_, reject) => {
abortTrigger = () => reject(new DOMException('The user aborted a request.', 'AbortError'));
});
});
const p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
chat.stop(); // 触发 AbortController.abort()
abortTrigger(); // mock 以 AbortError 模拟流读取中断
await vi.advanceTimersByTimeAsync(0);
await p;
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.streamErr).toBe('stopped');
// 已收到的块保留
expect(aMsg.blocks.some((b) => b.type === 'pipeline')).toBe(true);
const finalPatch = calls.patches[calls.patches.length - 1];
expect(finalPatch.body.status).toBe('error');
expect(chat.lastResult.error.code).toBe('ABORTED');
});
it('retry:同消息位置替换(FR-CHAT-012),不新增用户消息', async () => {
const { chat } = await setupChat();
frames = [
progress('planning', '开始理解问题'),
{ event: 'timeout', data: { message: '检索超时' } },
];
let p = chat.send('问题');
await vi.advanceTimersByTimeAsync(0);
await p;
const userMsg = chat.messages[0];
expect(chat.messages).toHaveLength(2);
// 第二轮:成功
frames = [
progress('planning', '开始理解问题'),
{ event: 'complete', data: completePayload },
];
p = chat.retry();
await vi.advanceTimersByTimeAsync(0);
await p;
expect(chat.messages).toHaveLength(2);
expect(chat.messages[0]).toBe(userMsg); // 同一用户消息对象
expect(chat.messages[1].status).toBe('done');
// 不再 POST 用户消息:骨架 POST 是第三个 messages 调用(用户 1 + 骨架 2 + retry 骨架 1)
const userPosts = calls.messages.filter((c) => c.body.role === 'user');
expect(userPosts).toHaveLength(1);
});
it('恢复:detail 加载 + interrupted 消息标「已中断」(FR-CHAT-013)', async () => {
const { chat } = await setupChat();
sessApi.getSession.mockImplementationOnce(async () => ({
id: 'sess-1',
title: 'T',
status: 'error',
meta: {},
messages: [
{ id: 'm1', role: 'user', kind: 'qa', status: 'done', blocks: [], raw: null, text: '问题', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z' },
{
id: 'm2', role: 'assistant', kind: 'qa', status: 'interrupted',
blocks: [
{ type: 'pipeline', status: 'running', steps: PIPELINE_PHASES.map((ph) => ({ name: ph, state: 'waiting', desc: '', phase: ph })) },
{ type: 'stream', text: '瞬态残留' },
],
raw: {}, created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z',
},
],
}));
await chat.loadSession('sess-1');
expect(chat.messages).toHaveLength(2);
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.interrupted).toBe(true);
expect(aMsg.streamErr).toBe('interrupted');
// stream 瞬态块被过滤(防御:绝不渲染落库的 stream)
expect(aMsg.blocks.some((b) => b.type === 'stream')).toBe(false);
// 用户消息转换为 text 块渲染
expect(chat.messages[0].blocks[0].type).toBe('text');
});
it('切换会话后台继续:流不被中断,切回拿实时数组(2026-08-26 新行为)', async () => {
const { chat, sessions } = await setupChat();
sessions.items = [
{ id: 'sess-1', title: 'A', status: 'running', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z', message_count: 2, meta: {} },
{ id: 'sess-2', title: 'B', status: 'done', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z', message_count: 2, meta: {} },
];
sessions.activeId = 'sess-1';
chat.sessionId = 'sess-1';
chat.messages = [
{ id: 'u1', role: 'user', kind: 'qa', status: 'done', text: 'q', blocks: [], raw: null },
{ id: 'a1', role: 'assistant', kind: 'qa', status: 'streaming', blocks: [{ type: 'pipeline', status: 'running', steps: [] }], raw: null },
];
chat.sessionMsgs['sess-1'] = chat.messages;
sessApi.getSession.mockImplementationOnce(async () => ({ id: 'sess-2', title: 'B', status: 'done', meta: {}, messages: [] }));
await chat.selectSession('sess-2');
// 切走后:原会话消息未被置 error(后台继续),session 状态仍是 running
expect(sessions.items.find((s) => s.id === 'sess-1').status).toBe('running');
const sess1Msgs = chat.sessionMsgs['sess-1'];
expect(sess1Msgs.find((m) => m.id === 'a1').status).toBe('streaming');
// 切回:拿到的是实时数组(同引用)
await chat.selectSession('sess-1');
expect(chat.messages).toBe(sess1Msgs);
expect(chat.messages.length).toBe(2);
});
});
describe('qaToBlocks', () => {
it('answer/sources/graph 装配;search 模式无 answer 不出 text 块', () => {
const blocks = qaToBlocks(completePayload);
expect(blocks.map((b) => b.type)).toEqual(['text', 'sources', 'graph']);
const sBlocks = qaToBlocks({ ...completePayload, answer: '' });
expect(sBlocks.map((b) => b.type)).toEqual(['sources', 'graph']);
});
});
describe('newSkeletonMessage', () => {
it('pipeline 五相位全 waiting', () => {
const m = newSkeletonMessage();
expect(m.status).toBe('streaming');
expect(m.blocks[0].steps.map((s) => s.phase)).toEqual(PIPELINE_PHASES);
expect(m.blocks[0].steps.every((s) => s.state === 'waiting')).toBe(true);
});
});