Files
PlantAI-rag-new/ui/tests/r3debug/statusBarDots.test.js
adminandClaude Fable 5 36fc2a7250 fix(statusbar): 生信任务活跃期回退消息列表点亮 4 点
生产实测暴露缺口:生信对话的 chat/stream 在 plan_ready 后即关闭
(_finishCtx 清 streamCtx→currentMessage 为 null),而任务阶段恰在
流关闭后才真正运行——StatusBar 只读 currentMessage,阶段运行期
间 4 点仍不亮。这正是用户报的 bug 主场景。

- chat.js 新增 agentDotMessage getter:优先流式中 currentMessage;
  流已收口时回退到消息列表里持有活跃生信任务(progress.jobId 且
  jobStatus ∈ created/running/paused)的最后一条助手消息——其派生
  pipeline 块正被任务事件订阅(connectJobEvents onEvent →
  patchBioflowEvent → syncBioflowDotBlock)持续刷新。
- StatusBar.vue 数据源换 agentDotMessage;TopBar.vue runningPhaseOf
  同换 + aggregate 在生信任务活跃期(busy=false 但有 running 相位)
  点亮聚合点。
- 单测补生产实测场景:流已收口任务活跃→回退点亮;阶段推进随动;
  任务终态正确收口空态。

验证:r3debug 89/89(新增回退用例)、主套件 373/373、dist 重建。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 18:22:03 +08:00

224 lines
8.7 KiB
JavaScript

// tests/r3debug/statusBarDots.test.js —— 底部状态栏 4 个 agent 点端到端渲染
// 缺陷:主界面底部状态栏左 4 个点在对话过程中不随相位点亮。
// 修复:StatusBar 读取 chat.currentMessage 的 pipeline 块;生信对话无原生 pipeline
// 块,由 chat.js 派生 derived pipeline 块(由 bioflow_progress.stages 镜像)。
// 本测试真实挂载 StatusBar(health store mock),在 pinia 写入 chat 流上下文,
// 断言 4 个点的 class 随数据点亮。
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
vi.mock('../../src/stores/health.js', () => ({
useHealthStore: () => ({
qaVersion: '1.1.73-test',
sessionCount: 3,
unknown: false,
qaOk: true,
neo4jOk: true,
extractionOk: true,
}),
}));
import StatusBar from '../../src/components/layout/StatusBar.vue';
import {
useChatStore,
syncBioflowDotBlock,
} from '../../src/stores/chat.js';
const PHASES = ['planning', 'retrieval', 'evidence', 'generation'];
function step(phase, state) {
return { name: phase, state, desc: '', phase };
}
function dotClasses(wrapper) {
return wrapper.findAll('.agent-dot-item .ad')
.map((d) => d.classes().filter((c) => c !== 'ad').join(','));
}
function setCurrentMsg(chat, aMsg) {
chat.sessionId = 's-dots';
chat.messages = [aMsg];
chat.sessionMsgs['s-dots'] = [aMsg];
chat.streamCtx['s-dots'] = {
sessionId: 's-dots',
msgList: [aMsg],
controller: null,
completedOnce: false,
events: [],
aMsg,
checkpointTimer: null,
lastCheckpointAt: 0,
reveal: null,
abortAnn: null,
};
}
describe('StatusBar 4 个 agent 点', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('QA 流式:planning running → 点1 亮 running,其余空', async () => {
const chat = useChatStore();
const aMsg = {
id: 'm1', role: 'assistant', kind: 'qa', status: 'streaming',
blocks: [{
type: 'pipeline', status: 'running',
steps: PHASES.map((p) => step(p, p === 'planning' ? 'running' : 'waiting')),
}],
};
setCurrentMsg(chat, aMsg);
const wrapper = mount(StatusBar);
await new Promise((r) => setTimeout(r, 30)); // onMounted 动态 import
await wrapper.vm.$nextTick();
expect(dotClasses(wrapper)).toEqual(['running', '', '', '']);
});
it('QA 流式:generation running(evidence 已 done)→ 点1/3 done、点4 running', async () => {
const chat = useChatStore();
const aMsg = {
id: 'm2', role: 'assistant', kind: 'qa', status: 'streaming',
blocks: [{
type: 'pipeline', status: 'running',
steps: PHASES.map((p) => step(p, p === 'generation' ? 'running' : (p === 'evidence' || p === 'planning' ? 'done' : 'waiting'))),
}],
};
setCurrentMsg(chat, aMsg);
const wrapper = mount(StatusBar);
await new Promise((r) => setTimeout(r, 30));
await wrapper.vm.$nextTick();
expect(dotClasses(wrapper)).toEqual(['done', '', 'done', 'running']);
});
it('生信对话:bioflow_progress + 派生 pipeline 块 → 点随镜像点亮(本缺陷修复)', async () => {
const chat = useChatStore();
// 模拟 sendBioflow 消息:path 块 + 进度块(stages 3 阶段:第1 running) + 派生块
const aMsg = {
id: 'm3', role: 'assistant', kind: 'bioflow', status: 'streaming',
blocks: [
{ type: 'bioflow_path', status: 'done', candidates: [{ stages: ['a', 'b', 'c'] }], selected: 0 },
{
type: 'bioflow_progress', jobId: 'j1', jobStatus: 'running',
stages: [
{ stage: 'a', status: 'running' },
{ stage: 'b', status: 'waiting' },
{ stage: 'c', status: 'waiting' },
],
},
{
type: 'pipeline', derived: true, status: 'running',
steps: PHASES.map((p) => step(p, p === 'planning' ? 'running' : 'waiting')),
},
],
};
setCurrentMsg(chat, aMsg);
const wrapper = mount(StatusBar);
await new Promise((r) => setTimeout(r, 30));
await wrapper.vm.$nextTick();
expect(dotClasses(wrapper)).toEqual(['running', '', '', '']);
});
it('生信对话:历史消息无派生块(落库过滤)→ 点空态不报错', async () => {
const chat = useChatStore();
const aMsg = {
id: 'm4', role: 'assistant', kind: 'bioflow', status: 'done',
blocks: [{ type: 'bioflow_path', status: 'done', candidates: [] }],
};
setCurrentMsg(chat, aMsg);
const wrapper = mount(StatusBar);
await new Promise((r) => setTimeout(r, 30));
await wrapper.vm.$nextTick();
expect(dotClasses(wrapper)).toEqual(['', '', '', '']);
});
it('无当前消息(空闲)→ 4 点全空态', async () => {
const chat = useChatStore();
chat.sessionId = 's-dots';
chat.streamCtx['s-dots'] = null;
const wrapper = mount(StatusBar);
await new Promise((r) => setTimeout(r, 30));
await wrapper.vm.$nextTick();
expect(dotClasses(wrapper)).toEqual(['', '', '', '']);
});
it('生信流已收口但任务活跃(2026-09-12 生产实测缺口)→ 回退消息列表点亮', async () => {
const chat = useChatStore();
// 模拟真实场景:chat/stream 已在 plan_ready 后关闭,_finishCtx 清理了 ctx
// (streamCtx 无本会话键),但任务阶段仍在运行,派生块正被任务事件订阅刷新
const aMsg = {
id: 'm6', role: 'assistant', kind: 'bioflow', status: 'streaming',
blocks: [
{ type: 'bioflow_path', status: 'done', jobId: 'j2', candidates: [{ stages: ['a', 'b'] }], selected: 0 },
{
type: 'bioflow_progress', jobId: 'j2', jobStatus: 'running',
stages: [
{ stage: 'a', status: 'running' },
{ stage: 'b', status: 'waiting' },
],
},
{
type: 'pipeline', derived: true, status: 'running',
steps: PHASES.map((p) => step(p, p === 'planning' ? 'running' : 'waiting')),
},
],
};
chat.sessionId = 's-dots2';
chat.messages = [aMsg];
chat.sessionMsgs['s-dots2'] = [aMsg];
chat.streamCtx = {}; // 关键:无活跃流(ctx 已被 _finishCtx 清理)
expect(chat.agentDotMessage).toBe(chat.messages[0]); // getter 回退正确
const wrapper = mount(StatusBar);
await new Promise((r) => setTimeout(r, 30));
await wrapper.vm.$nextTick();
expect(dotClasses(wrapper)).toEqual(['running', '', '', '']);
// 阶段推进:事件 patch 后派生块随动(模拟任务事件订阅 onEvent 链)
const proxyMsg = chat.messages[0];
const blk = proxyMsg.blocks.find((b) => b.type === 'bioflow_progress');
blk.stages[0].status = 'done';
blk.stages[1].status = 'running';
syncBioflowDotBlock(proxyMsg);
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 10));
expect(dotClasses(wrapper)).toEqual(['done', 'running', '', '']);
// 任务终态:jobStatus done → getter 不再回退,点全空态(正确收口)
blk.jobStatus = 'done';
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 10));
expect(dotClasses(wrapper)).toEqual(['', '', '', '']);
});
it('真实时序:先 mount(挂载完成)后流到达 → computed 响应式点亮', async () => {
const chat = useChatStore();
// 1. 先 mount,onMounted 动态 import 完成(与真实 app 启动时序一致)
const wrapper = mount(StatusBar);
await new Promise((r) => setTimeout(r, 30));
await wrapper.vm.$nextTick();
expect(dotClasses(wrapper)).toEqual(['', '', '', '']);
// 2. 流在 mount 之后才到达(用户发送消息)
const aMsg = {
id: 'm5', role: 'assistant', kind: 'qa', status: 'streaming',
blocks: [{
type: 'pipeline', status: 'running',
steps: PHASES.map((p) => step(p, p === 'planning' ? 'running' : 'waiting')),
}],
};
setCurrentMsg(chat, aMsg);
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 10));
expect(dotClasses(wrapper)).toEqual(['running', '', '', '']);
// 3. 相位推进:经 store 响应式代理调真实 action(_advancePipeline),
// 与真实 SSE 链同路径(直接改 raw 对象不会触发渲染——chat.js 响应式断链陷阱)
const proxyMsg = chat.streamCtx['s-dots'].aMsg;
chat._advancePipeline(proxyMsg, 'retrieval', '多路检索');
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 10));
expect(dotClasses(wrapper)).toEqual(['done', 'running', '', '']);
// 4. 终态:done 相位全置 done
chat._advancePipeline(proxyMsg, 'done', '检索完成');
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 10));
expect(dotClasses(wrapper)).toEqual(['done', 'done', 'done', 'done']);
});
});