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:
@@ -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');
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user