Files
PlantAI-rag-new/ui/tests/r3debug/reveal.test.js
adminandClaude Fable 5 671783eb1e fix(ui): r3 前端 10 项缺陷修复(#7/#27/#28/#31/#39/#40/#42/#47b/#52/#55)
- #7a reveal 超时:长文按总时长 ≤8s 反推 interval(下限 12ms),短路径
  30ms/字符不变(step=1);跳过按钮与 reduced-motion 行为不变
- #7b 标题编号:heading_open 注入 h1「一、」/h2「1.1」/h3「1.2.3」,
  层级变化归零、自带编号跳过、md-hN class 恒挂、计数器逐次渲染重置;
  DocPane md 回退预览与私有参考块用 renderMarkdownPlain 关闭注入
  (防止编号被序列化写回正本)
- #27 会话聚合:删除前端二次聚合,会话域直接以后端 sources 为准;
  SourcesPane/SourceRow 以 paper_id/id 为 key 与高亮依据,徽标显示
  真实 paper_id(截断+悬浮),无 paper_id 退 index+1;
  setHighlightSource 参数 index→key,chip 编号契约保持
- #31 节点详情:KgEntityDetail fetchExtra 序号守卫;KgPane :key 强制
  重建;KgGraphView pointer 事件重构(pointerdown 不捕获、位移>4px
  才捕获+标记 dragging、pointerup release、onClickNode dragging 拦截)
- #39 日志刷新:打开总是重拉+「刷新日志」按钮+有 running 时 5s 独立
  轮询(onBeforeUnmount 清理)
- #40 轮询暂停:任何离开 extract 的切换 pausePolling;详情/日志展开态
  提升到 store(detailOpenId/logOpen/runLog/logError),切 TAB 保留
- #42 模板选择:Composer docgen 模式模板下拉(自动推荐+内置+as_template
  懒加载,失败降级),template_id 经 sendDocgen 传入
- #47b 会话切换 resetSession(selectSession 动态 import,只清不丢)
- #55 文档 TAB:docgen store viewMode + DocPane「资料库」/「关闭文档」/
  「返回文档」按钮
- #28 检索文案:searchNoAnswer/searchTimeoutEmpty;200 降级体按
  payload.degraded 分流,不再走 _errorMarker TIMEOUT 报错
- #52 异步轮询:api getTask/listTasks;sendDocgen 兼容 200/202,
  202 后释放 busy 锁(生成期间可继续对话)、消息块记录 task_id、
  2s 轮询 getTask、progress 驱动「生成中 x/y 节」、done 复用现有
  artifact 挂块+落库+adoptArtifact+切 TAB、error 可重试;
  会话恢复时 running 块含 task_id 续轮询(不误标「已中断」)

自测:29 个新增 r3debug 单测 + 23 个既有 private_docs 测试全绿;
npm run build 通过;playwright 冒烟(dev server 5199)无 JS 错误,
模板下拉/提取面板/日志刷新按钮渲染正常。
未 push;Debug-2 9090 未就绪,#52/#28 联调留待 9090 就绪后执行。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 16:17:11 +08:00

117 lines
4.5 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.
// tests/r3debug/reveal.test.js —— 缺陷 #7a reveal 超时(fake-timer 断言)
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useChatStore } from '../../src/stores/chat.js';
vi.mock('../../src/api/sessions.js', () => ({
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 () => {}),
search: vi.fn(async () => ({ answer: '' })),
}));
vi.mock('../../src/api/private.js', () => ({
askPrivate: vi.fn(async () => ({ hit: false })),
}));
function fakeCtx(msgList, aMsg) {
return { sessionId: 's1', msgList, aMsg, events: [], controller: null, reveal: null };
}
describe('#7a reveal 总时长上限', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.useFakeTimers();
// jsdom 无 matchMedia:revealText 判定 reduced 直接定格——此处 stub 返回不命中 reduce
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })));
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn(() => ({ matches: false })),
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('≤2000 字符:30ms/字符节奏不变', async () => {
const chat = useChatStore();
const text = 'x'.repeat(500); // 500 字符 → 预期 500 × 30ms = 15000ms
const aMsg = { blocks: [], revealId: 0, status: 'streaming' };
const ctx = fakeCtx([aMsg], aMsg);
const p = chat.revealText(aMsg, text, ctx);
await vi.advanceTimersByTimeAsync(15000);
await p;
// 用真实时间轴:15000ms 恰好收尾;中途(14970ms)未收尾
expect(aMsg.status).toBe('done');
expect(aMsg.blocks.some((b) => b.type === 'text')).toBe(true);
expect(aMsg.blocks.some((b) => b.type === 'stream')).toBe(false);
});
it('≤2000 字符路径:总时长 = 字符数 × 30ms(±1 tick)', async () => {
const chat = useChatStore();
const text = 'x'.repeat(1999); // 需 1999 tick × 30ms = 59970ms
const aMsg = { blocks: [], revealId: 0, status: 'streaming' };
const ctx = fakeCtx([aMsg], aMsg);
const p = chat.revealText(aMsg, text, ctx);
// 1998 tick(59940ms)后未完成
await vi.advanceTimersByTimeAsync(1998 * 30);
await Promise.resolve();
expect(aMsg.status).toBe('streaming');
// 再推进 2 tick(60ms)必完成
await vi.advanceTimersByTimeAsync(60);
await p;
expect(aMsg.status).toBe('done');
});
it('>2000 字符:总时长 ≤8s(interval 下限 12ms)', async () => {
const chat = useChatStore();
const text = 'x'.repeat(5000); // 长文:加速分支
const aMsg = { blocks: [], revealId: 0, status: 'streaming' };
const ctx = fakeCtx([aMsg], aMsg);
const p = chat.revealText(aMsg, text, ctx);
await vi.advanceTimersByTimeAsync(8000);
await p;
expect(aMsg.status).toBe('done');
expect(aMsg.blocks.some((b) => b.type === 'text')).toBe(true);
});
it('长文单 tick 步进不超过 interval 允许的上限(不空转)', async () => {
const chat = useChatStore();
const text = 'x'.repeat(100000);
const aMsg = { blocks: [], revealId: 0, status: 'streaming' };
const ctx = fakeCtx([aMsg], aMsg);
const p = chat.revealText(aMsg, text, ctx);
// 观察若干 tick:stream 块内容单调递增且每 tick 增量 > 0
let prev = 0;
for (let i = 0; i < 10; i++) {
await vi.advanceTimersByTimeAsync(100);
const sb = aMsg.blocks[aMsg.blocks.length - 1];
expect(sb.text.length).toBeGreaterThan(prev);
prev = sb.text.length;
}
await vi.advanceTimersByTimeAsync(8000);
await p;
expect(aMsg.status).toBe('done');
});
it('revealId 守卫:skipReveal 后旧定时器下一 tick 自愈退出', async () => {
const chat = useChatStore();
chat.sessionId = 's1'; // skipReveal 经 _ctx getter 取当前会话流上下文
const text = 'x'.repeat(3000);
const aMsg = { blocks: [], revealId: 0, status: 'streaming' };
const ctx = fakeCtx([aMsg], aMsg);
chat.streamCtx['s1'] = ctx;
const p = chat.revealText(aMsg, text, ctx);
await vi.advanceTimersByTimeAsync(100);
chat.skipReveal();
await vi.advanceTimersByTimeAsync(500);
await p;
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);
});
});