Files
PlantAI-rag-new/ui/tests/r3debug/r4fixes.test.js
admin 75877ecab7 fix(r4-27/32/47b/52a): 前端四项缺陷修复
#27 消息级来源 title 空值串行:SourceRow 标题链空值 fallback(非空 title
>paper_id>id>「(无标题)」,绝不渲染空文本或正文片段);bench store 新增
sourcesStale 失效标记,chat.js QA 流 onComplete 与检索模式完成终态置标记,
SourcesPane 监听自动 force 重拉(终态一次,跨会话后台完成不打扰当前视图)。
后端 normalize title 回填由 Dev-B1 负责。

#32 KG 搜索「水稻」无结果无反馈:KgPane 成功空结果新增 kgNoResult
「未找到相关实体」提示(与 kgDown 服务离线、kgEmpty 初始空态三态文案区分、
触发条件互斥),搜索按钮空结果后保持可用,arginine 命中路径不回归。

#47b 关闭文档→返回→再关闭看不到文档:docgen store 状态机加固——open/
adoptArtifact 自含复位 viewMode='doc';close() 作废在途 content/html 拉取序号
(迟到响应不再复活已清空的 html/css/templateKey/letterhead);fetchHtml 追加
contentSeq 归属守卫。反复关闭/资料库往返/会话切换无残留。

#52a「总结论文」路由 QA:WRITE_VERB 补「总结/概括/归纳」;「总结」自身在
DOC_TYPE 中会误拉无附件普通总结句,规则 3 细化——纯总结类动词句须命中
其他文档类型词(如「论文」)才进 docgen,带附件走规则 4 兜底。后端
intent.py 词典同步由 Dev-B1 负责,请 TL 校验双端一致。

单测:tests/r3debug/r4fixes.test.js 新增 31 例(含 SourceRow fallback、
sourcesStale 链路、KgPane 三态、docgen 状态机迟到响应作废、意图路由),
r3debug 全套 68/68 通过。
2026-09-11 13:57:00 +08:00

469 lines
19 KiB
JavaScript
Raw Permalink 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
// tests/r4debug/r4fixes.test.js —— r4 前端四项缺陷单测(Dev-F 归属)
// #27 消息级来源 title 空值 fallback + 新回答完成后会话来源强制重拉
// #32 知识图谱搜索成功但空结果的明确提示(与 kgDown/kgEmpty 三态互斥)
// #47b DocPane 视图状态机:关闭→重开 / 资料库↔文档往返 / 会话切换
// #52a 意图路由 WRITE_VERB 补「总结/概括/归纳」(带附件进 docgen,普通问答不误伤)
//
// 运行方式(worktree 内):cd ui && npx vitest run --config tests/r3debug/vitest.config.js tests/r4debug/r4fixes.test.js
// (r3debug/vitest.config.js 的 include 覆盖 tests/r3debug/**,显式指定文件路径即可跑本文件)
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia, getActivePinia } from 'pinia';
// jsdom 无 scrollTo:polyfill(SourceRow 高亮滚动 / 组件挂载用)
if (typeof window !== 'undefined' && typeof window.Element !== 'undefined' && !window.Element.prototype.scrollTo) {
window.Element.prototype.scrollTo = vi.fn();
}
// ---- mock 依赖链(与 r3debug/docgenPoll.test.js 同款)----
vi.mock('../../src/api/sessions.js', () => ({
createMessage: vi.fn(async () => ({ id: 'm' + Math.random().toString(36).slice(2, 6) })),
updateMessage: vi.fn(async () => ({ updated_at: new Date().toISOString() })),
}));
vi.mock('../../src/api/qa.js', () => ({
chatStream: vi.fn(async () => {}),
search: vi.fn(async () => ({ answer: '' })),
}));
vi.mock('../../src/api/private.js', () => ({
askPrivate: vi.fn(async () => ({ hit: false })),
}));
vi.mock('../../src/api/docgen.js', () => ({
generate: vi.fn(),
getTask: 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 })),
listTasks: vi.fn(async () => ({ tasks: [] })),
}));
vi.mock('../../src/api/kg.js', () => ({
searchEntities: vi.fn(),
getNeighborhood: vi.fn(),
kgStatus: vi.fn(async () => ({ ok: true, active: true })),
}));
// ---- 被测模块 ----
import { useChatStore } from '../../src/stores/chat.js';
import { useSessionsStore } from '../../src/stores/sessions.js';
import { useBenchStore, __setHttpGet } from '../../src/stores/bench.js';
import { useDocgenStore } from '../../src/stores/docgen.js';
import SourceRow from '../../src/components/bench/SourceRow.vue';
import KgPane from '../../src/components/bench/KgPane.vue';
import { route } from '../../src/intent.js';
function freshPinia() {
const pinia = createPinia();
setActivePinia(pinia);
return pinia;
}
/** 预置会话(跳过 create 网络路径) */
function seedSession() {
const chat = useChatStore();
const sessions = useSessionsStore();
chat.sessionId = 's1';
sessions.activeId = 's1';
sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }];
return { chat, sessions };
}
describe('#27 SourceRow title 空值 fallback(消息级来源条目)', () => {
afterEach(() => {
vi.restoreAllMocks();
});
function mountRow(source) {
return mount(SourceRow, {
props: { source, index: 0, count: null },
global: { stubs: { teleport: true } },
});
}
it('title 为空 → 显示 paper_id(绝不渲染空文本或正文片段)', () => {
const w = mountRow({ paper_id: 'PMC12345678', title: '', snippet: 'RESEARCH ARTICLE 正文片段…', score: 0.21 });
const name = w.find('.ft-name');
expect(name.text()).toBe('PMC12345678');
// tooltip 与显示文本一致(完整 paper_id)
expect(name.attributes('title')).toBe('PMC12345678');
// 正文片段绝不进入标题行
expect(name.text()).not.toContain('RESEARCH ARTICLE');
});
it('title 与 paper_id 都空 → 显示 id', () => {
const w = mountRow({ id: 'src-9', title: null, snippet: 'x' });
expect(w.find('.ft-name').text()).toBe('src-9');
});
it('title/paper_id/id 全空 → 显示 i18n 占位「(无标题)」', () => {
const w = mountRow({ title: ' ', snippet: 'x' });
expect(w.find('.ft-name').text()).toBe('(无标题)');
});
it('正常 title 显示不受影响(r3 契约不回归)', () => {
const w = mountRow({ paper_id: 'P1', title: '水稻抗病基因研究进展', score: 0.5 });
expect(w.find('.ft-name').text()).toBe('水稻抗病基因研究进展');
// 徽标真实 paper_id 契约不回归
expect(w.find('.ft-ico.idx').text()).toBe('P1');
});
});
describe('#27 新回答完成后会话来源强制重拉(sourcesStale 链路)', () => {
beforeEach(() => {
vi.useFakeTimers();
__setHttpGet(null);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
__setHttpGet(null);
});
it('有缓存且无失效标记 → 缓存短路不重拉', async () => {
freshPinia();
seedSession();
const bench = useBenchStore();
bench.sessionSources = { sources: [{ paper_id: 'P1', count: 1 }] };
const http = vi.fn(async () => ({ sources: [] }));
__setHttpGet(http);
const out = await bench.loadSessionSources();
expect(out.sources[0].paper_id).toBe('P1');
expect(http).not.toHaveBeenCalled();
});
it('chat 检索模式完成 → sourcesStale 置真 → 自动绕过缓存重拉,成功后标记清除', async () => {
freshPinia();
const { chat } = seedSession();
const bench = useBenchStore();
bench.sessionSources = { sources: [{ paper_id: 'P1', count: 1 }] };
const http = vi.fn(async () => ({ sources: [{ paper_id: 'P2', count: 2 }] }));
__setHttpGet(http);
const { search } = await import('../../src/api/qa.js');
vi.mocked(search).mockResolvedValue({ answer: 'ok', sources: [{ paper_id: 'P2', title: 't' }], degraded: false });
await chat.send('问题', { mode: 'search' });
// 终态后失效标记已置
expect(bench.sourcesStale).toBe(true);
const out = await bench.loadSessionSources();
expect(http).toHaveBeenCalledWith('/api/sessions/s1/sources', undefined);
expect(out.sources[0].paper_id).toBe('P2');
expect(bench.sourcesStale).toBe(false); // 成功后清除,不会反复重拉
});
it('QA 流 onComplete 终态 → sourcesStale 置真(终态才一次,不在流式中触发)', async () => {
freshPinia();
const { chat } = seedSession();
const bench = useBenchStore();
const { chatStream } = await import('../../src/api/qa.js');
let captured = null;
vi.mocked(chatStream).mockImplementation(async (_body, handlers) => {
captured = handlers;
handlers.onProgress({ payload: { phase: 'retrieval', label: 'r' } });
handlers.onProgress({ payload: { phase: 'generation', label: 'g' } });
handlers.onComplete({ payload: { answer: '答案', sources: [] } });
});
const p = chat.send('问题', { mode: 'ask' });
await p;
expect(captured).not.toBeNull();
// 完成前(流式过程中)不触发失效:终态后才一次
expect(chat.messages.find((m) => m.role === 'assistant')?.status).toBe('done');
expect(bench.sourcesStale).toBe(true);
});
it('跨会话后台完成不打扰当前视图(sessionId 不匹配时失效标记不置真)', async () => {
freshPinia();
const { chat } = seedSession();
const bench = useBenchStore();
// 当前视图在 s2;后台流属于 s1
chat.sessionId = 's2';
chat._markSourcesStaleSafe('s1');
expect(bench.sourcesStale).toBe(false);
chat._markSourcesStaleSafe('s2');
expect(bench.sourcesStale).toBe(true);
});
it('resetSessionData 清理 sourcesStale(会话切换不残留失效标记)', () => {
freshPinia();
const bench = useBenchStore();
bench.sourcesStale = true;
bench.resetSessionData();
expect(bench.sourcesStale).toBe(false);
expect(bench.sessionSources).toBeNull();
});
});
describe('#32 知识图谱搜索空结果提示(kgNoResult)', () => {
afterEach(() => {
vi.restoreAllMocks();
});
function mountKg() {
return mount(KgPane, {
global: { plugins: [getActivePinia()], stubs: { teleport: true, KgGraphView: true, KgEntityDetail: true } },
});
}
it('搜索成功但 results 空 → 显示「未找到相关实体」,搜索按钮恢复可用', async () => {
freshPinia();
const bench = useBenchStore();
bench.kgReady = true;
bench.kgStatus = { ok: true };
const { searchEntities } = await import('../../src/api/kg.js');
vi.mocked(searchEntities).mockResolvedValue({ results: [] });
const w = mountKg();
await w.find('.search-box').setValue('水稻');
await w.find('.search-btn').trigger('click');
await flushPromises();
expect(w.text()).toContain('未找到相关实体');
// 与 kgDown(服务不可用)互斥:不出现离线文案
expect(w.text()).not.toContain('知识图谱服务离线');
// 搜索按钮恢复可用(空结果不锁按钮)
const btn = w.find('.search-btn');
expect(btn.attributes('disabled')).toBeUndefined();
});
it('搜索失败(kgDown)不出现空结果提示(触发条件互斥)', async () => {
freshPinia();
const bench = useBenchStore();
bench.kgReady = true;
bench.kgStatus = { ok: true };
const { searchEntities } = await import('../../src/api/kg.js');
vi.mocked(searchEntities).mockRejectedValue(new Error('down'));
const w = mountKg();
await w.find('.search-box').setValue('水稻');
await w.find('.search-btn').trigger('click');
await flushPromises();
expect(w.text()).toContain('知识图谱服务离线');
expect(w.text()).not.toContain('未找到相关实体');
});
it('正常命中路径不回归:arginine 命中渲染结果列表、无「未找到」提示', async () => {
freshPinia();
const bench = useBenchStore();
bench.kgReady = true;
bench.kgStatus = { ok: true };
const { searchEntities } = await import('../../src/api/kg.js');
vi.mocked(searchEntities).mockResolvedValue({
results: [{ id: 'e1', name: 'arginine', type: 'Compound', support_count: 42 }],
});
const w = mountKg();
await w.find('.search-box').setValue('arginine');
await w.find('.search-btn').trigger('click');
await flushPromises();
expect(w.find('.result-item').exists()).toBe(true);
expect(w.find('.rs-name').text()).toBe('arginine');
expect(w.text()).not.toContain('未找到相关实体');
});
it('初始空态 kgEmpty 与空结果提示不同文案(三态区分)', () => {
freshPinia();
const bench = useBenchStore();
bench.kgReady = true;
const w = mountKg();
expect(w.find('.empty-note').text()).toContain('本次检索未产生图谱关系');
expect(w.find('.empty-note').text()).not.toContain('未找到相关实体');
});
});
describe('#47b DocPane 视图状态机(close→重开 / 资料库往返 / 会话切换)', () => {
beforeEach(() => {
freshPinia();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('打开文档 A → 关闭 → 重开:viewMode 复位为 doc、内容完整渲染', async () => {
const docgen = useDocgenStore();
await docgen.open({ artifact_id: 'a1', title: 'A', template_name: 't', status: 'done' });
expect(docgen.viewMode).toBe('doc');
expect(docgen.activeArtifact?.artifact_id).toBe('a1');
docgen.close();
expect(docgen.viewMode).toBe('doc');
expect(docgen.activeArtifact).toBeNull();
expect(docgen.md).toBe('');
expect(docgen.html).toBe('');
await docgen.open({ artifact_id: 'a1', title: 'A', template_name: 't', status: 'done' });
expect(docgen.viewMode).toBe('doc');
expect(docgen.activeArtifact?.artifact_id).toBe('a1');
expect(docgen.loading).toBe(false);
expect(docgen.loadError).toBeNull();
});
it('反复关闭 3 次:状态干净、无残留(html/css/templateKey/letterhead 全空)', async () => {
const docgen = useDocgenStore();
for (let i = 0; i < 3; i++) {
await docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
docgen.close();
expect(docgen.activeArtifact).toBeNull();
expect(docgen.md).toBe('');
expect(docgen.html).toBe('');
expect(docgen.css).toBe('');
expect(docgen.templateKey).toBe('');
expect(docgen.letterhead).toBe('');
expect(docgen.viewMode).toBe('doc');
expect(docgen.loading).toBe(false);
expect(docgen.loadError).toBeNull();
}
});
it('资料库↔文档往返:资料库→返回文档→再资料库,viewMode 切换无残留', async () => {
const docgen = useDocgenStore();
const { getArtifactContent } = await import('../../src/api/docgen.js');
vi.mocked(getArtifactContent).mockResolvedValue({ markdown: '# 文档 A', title: 'A' });
await docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
for (let i = 0; i < 3; i++) {
docgen.viewMode = 'library';
expect(docgen.viewMode).toBe('library');
docgen.viewMode = 'doc';
expect(docgen.viewMode).toBe('doc');
expect(docgen.activeArtifact?.artifact_id).toBe('a1');
expect(docgen.md).toBe('# 文档 A');
}
// 资料库视图下重开同一文档 → open 自含复位回 doc
docgen.viewMode = 'library';
await docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
expect(docgen.viewMode).toBe('doc');
});
it('文档 A 关闭后迟到的 html 响应作废:不得复活 A 的状态(#47b 根因)', async () => {
const docgen = useDocgenStore();
const { getArtifactHtml } = await import('../../src/api/docgen.js');
// A 的 html 响应悬挂 10s:模拟打开过程中用户点击「关闭文档」(缺陷场景)
vi.mocked(getArtifactHtml).mockImplementation(async (id) => {
await new Promise((r) => setTimeout(r, 10000));
return { ok: true, html: `<h1>${id}</h1>`, css: 'body{}', template_key: 'official', letterhead: '' };
});
const openA = docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
await vi.advanceTimersByTimeAsync(0); // content 微任务排干,html 已发出(悬挂中)
// 打开未完成时关闭:close 必须作废在途拉取
docgen.close();
expect(docgen.activeArtifact).toBeNull();
// 放行悬挂响应
await vi.advanceTimersByTimeAsync(10000);
await flushPromises();
await openA.catch(() => {});
// 关闭后迟到响应被序号守卫作废:A 的 html/css/templateKey 不复活
expect(docgen.html).toBe('');
expect(docgen.css).toBe('');
expect(docgen.templateKey).toBe('');
expect(docgen.activeArtifact).toBeNull();
// 重开文档 B:完整渲染不受 A 残留影响
vi.mocked(getArtifactHtml).mockResolvedValue({ ok: true, html: '<h1>B</h1>', css: 'b{}', template_key: 'report', letterhead: '' });
await docgen.open({ artifact_id: 'a2', title: 'B', status: 'done' });
expect(docgen.activeArtifact?.artifact_id).toBe('a2');
expect(docgen.html).toBe('<h1>B</h1>');
expect(docgen.viewMode).toBe('doc');
});
it('adoptArtifact 自含复位视图并清空旧内容(生成接管)', async () => {
const docgen = useDocgenStore();
docgen.viewMode = 'library';
docgen.html = '<h1>old</h1>';
docgen.css = 'old{}';
docgen.templateKey = 'old';
docgen.letterhead = 'old';
await docgen.adoptArtifact({ artifact_id: 'a1', title: 'A', status: 'done' });
expect(docgen.viewMode).toBe('doc');
expect(docgen.html).toBe('');
expect(docgen.css).toBe('');
expect(docgen.templateKey).toBe('');
expect(docgen.letterhead).toBe('');
expect(docgen.activeArtifact?.artifact_id).toBe('a1');
});
it('会话切换 resetSession 清空文档域:A 不串挂到 B', async () => {
const docgen = useDocgenStore();
await docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
expect(docgen.hasActiveArtifact).toBe(true);
docgen.resetSession();
expect(docgen.activeArtifact).toBeNull();
expect(docgen.md).toBe('');
expect(docgen.html).toBe('');
expect(docgen.viewMode).toBe('doc');
expect(docgen.templateCandidates).toEqual([]);
});
});
describe('#52a 意图路由 WRITE_VERB 补总结/概括/归纳', () => {
it('「帮我总结论文」+ 附件 → docgen(规则 4 附件兜底)', () => {
expect(route('帮我总结论文', true, false)).toBe('docgen');
});
it('「帮我概括这篇文章」+ 附件 → docgen', () => {
expect(route('帮我概括这篇文章', true, false)).toBe('docgen');
});
it('「帮我归纳资料」+ 附件 → docgen', () => {
expect(route('帮我归纳资料', true, false)).toBe('docgen');
});
it('无附件「总结一下植物光合作用的过程」→ qa(无其他 DOC_TYPE 证据)', () => {
expect(route('总结一下植物光合作用的过程', false, false)).toBe('qa');
});
it('无附件「概括一下 OsSPL14 的功能」→ qa(无其他 DOC_TYPE 证据)', () => {
expect(route('概括一下 OsSPL14 的功能', false, false)).toBe('qa');
});
it('无附件「总结这篇论文」→ docgen(TL 裁决:DOC_TYPE 含「论文」,接受该行为变化)', () => {
expect(route('总结这篇论文', false, false)).toBe('docgen');
});
it('无附件「帮我概括这篇文章」→ docgen(DOC_TYPE 含「文章」类材料词之外,按 DOC_TYPE 命中)', () => {
// 「文章」不在 DOC_TYPE:按漏判安全应走 qa(附规则 4 附件兜底覆盖带附件场景)
expect(route('帮我概括这篇文章', false, false)).toBe('qa');
expect(route('帮我概括这篇文章', true, false)).toBe('docgen');
});
it('普通问答不误伤:你好 / 介绍自己 → qa', () => {
expect(route('你好,介绍一下你自己', false, false)).toBe('qa');
expect(route('你是谁', true, false)).toBe('qa');
});
it('既有写类动词回归:写/撰写 + 论文 → docgen;无附件无 DOC_TYPE 写类仍 qa', () => {
expect(route('帮我写一份水稻研究报告', false, false)).toBe('docgen');
expect(route('撰写论文', false, false)).toBe('docgen');
expect(route('写一首诗', false, false)).toBe('qa'); // DOC_TYPE_EXCLUDE 优先
});
it('「写一篇总结」仍进 docgen(DOC_TYPE 含「总结」,行为不变)', () => {
expect(route('写一篇总结', false, false)).toBe('docgen');
});
it('既有写类动词 + 总结类文档词不回归:「写一份工作总结」→ docgen', () => {
expect(route('写一份工作总结', false, false)).toBe('docgen');
});
it('规则 4 附件兜底:总结类动词带附件无 DOC_TYPE 也进 docgen', () => {
expect(route('总结一下这份资料', true, false)).toBe('docgen');
expect(route('归纳一下内容', true, false)).toBe('docgen');
});
});