- 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>
474 lines
22 KiB
JavaScript
474 lines
22 KiB
JavaScript
// @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);
|
||
});
|
||
});
|