fix(ui): 复测缺陷 r3-07-01/r3-52-02 + b3 测试债

- r3-07-01 「跳过」无效:补齐事件转发链 MessageList(新增
  skip-reveal emit)→ ChatPanel(@skip-reveal 监听)→ store.skipReveal();
  新增 skipReveal.test.js 三层断言(点击定格终态/组件转发/ChatPanel 监听)
- r3-52-02 终态文案残留:docgen 轮询 tick 的 done 分支把 generation 步
  desc 还原 DOCGEN_STEPS 默认文案「按大纲逐节生成正文」;docgenPoll
  done 用例改两阶段 mock(running 进度文案 → done 还原)复现原缺陷
- 测试债:b3 bench.store.test.js 删除已下线 sessionSourceItems 引用,
  按 #27 修复后语义改断言(后端去重结果原样透出)

自测:r3debug 32 项 + 全套 373 项 + private_docs 23 项全绿;
playwright 实测(mock SSE/docgen 202):跳过按钮点击后全文定格、
skip-btn/cursor 消失;docgen done 后第 4 步 desc 还原默认文案;无 JS 错误。
未 push。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 18:01:01 +08:00
co-authored by Claude Fable 5
parent 671783eb1e
commit 53069d4940
6 changed files with 202 additions and 15 deletions
+6 -4
View File
@@ -164,10 +164,12 @@ describe('bench store', () => {
});
await store.loadSessionSources();
expect(httpGet).toHaveBeenCalledWith('/api/sessions/sess-1/sources', undefined);
expect(store.sessionSourceItems).toHaveLength(2); // paper_id 去重
const byPaper = store.sessionSourceItems.find((s) => s.paper_id === 'PMID1');
expect(byPaper.count).toBe(3); // 累计出现次数
expect(store.sources).toHaveLength(2);
// 缺陷 #27:前端不再二次聚合——后端去重结果原样透出
expect(store.sources).toHaveLength(3);
expect(store.sources[0].src.paper_id).toBe('PMID1');
expect(store.sources[0].label).toBe(2); // 后端给的第一条 count
expect(store.sources[1].label).toBe(1);
expect(store.sources[2].label).toBe(1);
// 切回 current:仍为 null(无 B2)
store.setSourceScope('current');
+7
View File
@@ -54,6 +54,12 @@ const jumpToGraph = () => {
const stop = () => chat.stop();
const retry = () => chat.retry();
const changeMode = (mode) => chat.setMode(mode);
// r3-07-01:揭示动画「跳过」——MessageList 转发后落 store.skipReveal()(立即定格全文)
const skipReveal = () => {
try {
chat.skipReveal();
} catch { /* chat store 未就绪(测试/降级):静默 */ }
};
// 切换会话/发送后清空输入框无需处理(Composer 自持 draft,欢迎态切换即重置)
</script>
@@ -94,6 +100,7 @@ const changeMode = (mode) => chat.setMode(mode);
@retry="retry"
@jump-graph="jumpToGraph"
@jump-source="jumpToSource"
@skip-reveal="skipReveal"
/>
<!-- 作曲器(FR-HEALTH-002:QA 不可用时禁用 + 降级提示,BUG-R1-04) -->
+2 -1
View File
@@ -14,7 +14,7 @@ const props = defineProps({
scrollSeq: { type: Number, default: 0 }, // chat store 的滚动请求序号
});
const emit = defineEmits(['retry', 'jump-graph', 'jump-source']);
const emit = defineEmits(['retry', 'jump-graph', 'jump-source', 'skip-reveal']);
const el = ref(null);
const stickToBottom = ref(true);
@@ -65,6 +65,7 @@ onBeforeUnmount(() => {});
@retry="emit('retry')"
@jump-graph="emit('jump-graph')"
@jump-source="(payload) => emit('jump-source', payload)"
@skip-reveal="emit('skip-reveal')"
/>
</template>
</div>
+4
View File
@@ -943,6 +943,10 @@ export const useChatStore = defineStore('chat', {
pipe.status = 'done';
pipe.taskStatus = 'done';
pipe.steps.forEach((st) => { st.state = 'done'; });
// r3-52-02:done 后还原 generation 步默认 desc(running 时被
// 「生成中 x/y 节」覆盖,终态不得残留进度文案)
const genStep = pipe.steps.find((st) => st.phase === 'generation');
if (genStep) genStep.desc = DOCGEN_STEPS.find((st) => st.phase === 'generation')?.desc || genStep.desc;
if (artifact) {
const art = { ...artifact };
const artId = art.artifact_id || art.id;
+32 -10
View File
@@ -113,26 +113,48 @@ describe('#52 docgen 异步轮询状态机', () => {
it('done:挂 artifact 块 + 落库 + adoptArtifact + 切文档 TAB', async () => {
const { chat } = freshStores();
vi.mocked(generate).mockResolvedValue({ ok: true, task_id: 'task-3', status: 'queued' });
vi.mocked(getTask).mockResolvedValue({
ok: true,
task: {
id: 'task-3', status: 'done',
progress: [],
artifact: { artifact_id: 'art-1', title: '方案', template_name: '方案计划', template_key: 'plan', has_html: true, letterhead: false, file_name: 'f.docx' },
error: null,
},
});
// r3-52-02 复现:先 running(generation desc 被「生成中 x/y 节」覆盖)→ 再 done
vi.mocked(getTask)
.mockResolvedValueOnce({
ok: true,
task: {
id: 'task-3', status: 'running',
progress: [
{ section: 1, title: 'a', state: 'done' },
{ section: 2, title: 'b', state: 'running' },
],
artifact: null, error: null,
},
})
.mockResolvedValue({
ok: true,
task: {
id: 'task-3', status: 'done',
progress: [],
artifact: { artifact_id: 'art-1', title: '方案', template_name: '方案计划', template_key: 'plan', has_html: true, letterhead: false, file_name: 'f.docx' },
error: null,
},
});
const p = chat.sendDocgen('写一份方案', []);
await vi.advanceTimersByTimeAsync(0);
await p;
// 第一 tick:running,desc 被进度文案覆盖
await vi.advanceTimersByTimeAsync(2000);
await vi.advanceTimersByTimeAsync(0);
let aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen');
expect(aMsg.blocks[0].steps.find((s) => s.phase === 'generation').desc).toContain('生成中 1/2');
// 第二 tick:done,desc 必须还原默认文案(r3-52-02)
await vi.advanceTimersByTimeAsync(2000);
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(0); // adoptArtifact 内部异步拉取
const aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen');
aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen');
expect(aMsg.status).toBe('done');
expect(aMsg.blocks.some((b) => b.type === 'artifact' && b.artifact_id === 'art-1')).toBe(true);
expect(aMsg.blocks[0].status).toBe('done');
expect(aMsg.blocks[0].steps.every((s) => s.state === 'done')).toBe(true);
const genStep = aMsg.blocks[0].steps.find((s) => s.phase === 'generation');
expect(genStep.desc).toBe('按大纲逐节生成正文');
expect(genStep.desc).not.toContain('生成中');
// 轮询注册自清(终态后不再有定时器)
const { useDocgenStore } = await import('../../src/stores/docgen.js');
expect(useDocgenStore().activeArtifact?.artifact_id).toBe('art-1');
+151
View File
@@ -0,0 +1,151 @@
// @vitest-environment jsdom
// tests/r3debug/skipReveal.test.js —— r3-07-01「跳过」按钮事件链全链路
// 断言:AssistantMessage skip-btn 点击 → MessageList 转发 skip-reveal →
// ChatPanel 落 store.skipReveal() → 消息定格终态(stream 块换 text 块)。
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
// jsdom 无 scrollTo:polyfill(MessageList 挂载滚动用)
if (typeof window !== 'undefined' && typeof window.Element !== 'undefined' && !window.Element.prototype.scrollTo) {
window.Element.prototype.scrollTo = vi.fn();
}
vi.mock('../../src/stores/health.js', () => ({
useHealthStore: vi.fn(() => ({ data: null, qaOk: true })),
}));
vi.mock('../../src/api/sessions.js', () => ({
listSessions: vi.fn(async () => []),
createSession: vi.fn(async () => ({ id: 's1' })),
getSession: vi.fn(async () => ({ id: 's1', status: 'done', meta: {}, messages: [] })),
updateSession: vi.fn(async (id, b) => ({ id, ...b })),
deleteSession: vi.fn(async () => {}),
createMessage: vi.fn(async () => ({ id: 'm1' })),
updateMessage: vi.fn(async () => ({ updated_at: new Date().toISOString() })),
}));
vi.mock('../../src/api/qa.js', () => ({
chatStream: vi.fn(async () => {}),
chat: vi.fn(),
search: vi.fn(),
info: vi.fn(),
}));
vi.mock('../../src/api/private.js', () => ({
askPrivate: vi.fn(async () => ({ hit: false })),
}));
vi.mock('../../src/api/docgen.js', () => ({
generate: vi.fn(),
getTask: vi.fn(),
listTasks: vi.fn(),
getArtifactContent: vi.fn(async () => ({ markdown: '' })),
getArtifactHtml: vi.fn(async () => ({ ok: true, html: '', css: '' })),
saveArtifactContent: vi.fn(async () => ({})),
editArtifact: vi.fn(async () => ({})),
exportArtifact: vi.fn(async () => ({})),
uploadLibrary: vi.fn(),
listLibrary: vi.fn(async () => ({ items: [] })),
removeLibrary: vi.fn(async () => ({ ok: true })),
}));
import ChatPanel from '../../src/components/chat/ChatPanel.vue';
import MessageList from '../../src/components/chat/MessageList.vue';
import AssistantMessage from '../../src/components/chat/AssistantMessage.vue';
import { useChatStore } from '../../src/stores/chat.js';
import { useSessionsStore } from '../../src/stores/sessions.js';
/** 构造揭示中的会话态:aMsg 带 stream 块 + ctx.reveal */
function seedRevealing(chat) {
const msgList = [
{ id: 'u1', role: 'user', status: 'done', text: '问题', blocks: [{ type: 'text', html: '问题' }] },
{
id: 'a1',
role: 'assistant',
kind: 'qa',
status: 'streaming',
streamErr: null,
revealId: 1, // revealText 正常流程会写入;skipReveal 据此定位目标消息
blocks: [
{ type: 'pipeline', status: 'running', steps: [] },
{ type: 'stream', text: '部分内容' },
],
},
];
chat.sessionId = 's1';
chat.messages = msgList;
chat.sessionMsgs['s1'] = msgList;
const aMsg = msgList[1];
chat.streamCtx['s1'] = {
sessionId: 's1',
msgList,
aMsg,
controller: null,
completedOnce: false,
events: [],
checkpointTimer: null,
lastCheckpointAt: 0,
reveal: { text: '全文内容', aMsg },
abortAnn: null,
};
return aMsg;
}
async function mountPanel() {
const w = mount(ChatPanel, { global: { stubs: { teleport: true } } });
await flushPromises();
return w;
}
describe('r3-07-01 跳过按钮事件链', () => {
beforeEach(() => {
setActivePinia(createPinia());
const sessions = useSessionsStore();
sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }];
});
it('全链路:skip-btn 点击 → MessageList 转发 → ChatPanel → store.skipReveal 定格终态', async () => {
const chat = useChatStore();
seedRevealing(chat);
const w = await mountPanel();
// 揭示中:skip-btn 存在
const skipBtn = w.find('.skip-btn');
expect(skipBtn.exists()).toBe(true);
await skipBtn.trigger('click');
await flushPromises();
// store 效果:stream 块移除、text 块定格、状态 done、reveal 清空
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('done');
expect(aMsg.blocks.some((b) => b.type === 'stream')).toBe(false);
expect(aMsg.blocks.some((b) => b.type === 'text')).toBe(true);
expect(chat.reveal).toBeNull();
});
it('MessageList 层:AssistantMessage skip-reveal 事件被转发为同名 emit', async () => {
const chat = useChatStore();
seedRevealing(chat);
const w = mount(MessageList, {
props: { messages: chat.messages, busy: false, reveal: chat.reveal, scrollSeq: 0 },
global: { stubs: { transition: false } },
});
const am = w.findComponent(AssistantMessage);
expect(am.exists()).toBe(true);
await am.vm.$emit('skip-reveal');
expect(w.emitted('skip-reveal')).toBeTruthy();
expect(w.emitted('skip-reveal')).toHaveLength(1);
});
it('ChatPanel 层:MessageList skip-reveal 事件被监听(组件契约存在)', async () => {
const chat = useChatStore();
seedRevealing(chat);
const w = await mountPanel();
const ml = w.findComponent(MessageList);
expect(ml.exists()).toBe(true);
// 直接对 MessageList 发事件:ChatPanel 的 @skip-reveal 监听应落 store
await ml.vm.$emit('skip-reveal');
await flushPromises();
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('done');
expect(aMsg.blocks.some((b) => b.type === 'text')).toBe(true);
});
});