- #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>
117 lines
4.5 KiB
JavaScript
117 lines
4.5 KiB
JavaScript
// 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);
|
||
});
|
||
});
|