/** * 回归测试:9b1c3f1 修的"多聊天串台"不变式(40 号文档方案三)。 * * 不变式:handleEvent 收到的每一条事件都要无条件写进它自己的 st(每个 agent 一份), * 但只有在事件所属的 requestAgentId 仍等于"用户当前选中的 agent"时,才允许触碰 * 全局副作用(路由跳转 / 对话列表 / DOM 滚动)。这条测试不需要挂载整个 Vue 组件—— * handleEvent 已经被抽成不依赖组件实例的纯函数(agentChatEvents.js),直接喂事件、 * 断言 st 内容和副作用回调的调用次数即可。 */ import { describe, it, expect, vi } from 'vitest'; import { createAgentChatEventHandler, createTypewriterQueue, flushTypewriter } from './agentChatEvents.js'; function makeState() { return { messages: [], activeConvId: null, sending: false, streaming: false, streamContent: '', subAgents: {}, subLogList: [], acpLog: [], thinkingText: '', thinkingContent: '', showThinking: true, }; } describe('agentChatEvents · 串台加固', () => { it('切走之后,旧 agent 的事件继续更新它自己的 st,但不再触发全局副作用', () => { let current = 'agent-A'; const scrollToBottom = vi.fn(); const scrollAcpLog = vi.fn(); const onNewConversation = vi.fn(); const handleEvent = createAgentChatEventHandler({ getCurrentAgentId: () => current, onNewConversation, scrollToBottom, scrollAcpLog, scrollSubLogs: vi.fn(), pushChunk: (st, text) => { st.streamContent += text; }, // 跳过打字机定时器,测同步内容 }); const stA = makeState(); handleEvent(stA, 'agent-A', 'session', { conversationId: 'conv-A' }); handleEvent(stA, 'agent-A', 'agent_output', { message: '你好' }); expect(scrollToBottom).toHaveBeenCalledTimes(1); // 只有 agent_output 会调 scrollToBottom expect(onNewConversation).toHaveBeenCalledTimes(1); // session 走的是 onNewConversation // 用户切到另一个 agent current = 'agent-B'; scrollToBottom.mockClear(); scrollAcpLog.mockClear(); handleEvent(stA, 'agent-A', 'agent_output', { message: ',世界' }); handleEvent(stA, 'agent-A', 'tool_call', { tool: 'bash' }); // st 照常被写:内容没丢 expect(stA.streamContent).toBe('你好,世界'); expect(stA.acpLog).toHaveLength(1); // 但不再是当前 agent,全局副作用一次都不该触发 expect(scrollToBottom).not.toHaveBeenCalled(); expect(scrollAcpLog).not.toHaveBeenCalled(); // 切回来之后,新事件恢复触发副作用,且历史内容完好 current = 'agent-A'; handleEvent(stA, 'agent-A', 'tool_call', { tool: 'grep' }); expect(scrollAcpLog).toHaveBeenCalledTimes(1); expect(stA.acpLog).toHaveLength(2); }); it('两个 agent 同时在跑,事件不会互相串台到对方的 st 里', () => { let current = 'agent-A'; const handleEvent = createAgentChatEventHandler({ getCurrentAgentId: () => current, onNewConversation: vi.fn(), scrollToBottom: vi.fn(), scrollAcpLog: vi.fn(), scrollSubLogs: vi.fn(), pushChunk: (st, text) => { st.streamContent += text; }, }); const stA = makeState(); const stB = makeState(); // 交替喂 A、B 两条流的事件,模拟后台并发 handleEvent(stA, 'agent-A', 'agent_output', { message: 'A1' }); handleEvent(stB, 'agent-B', 'agent_output', { message: 'B1' }); current = 'agent-B'; // 用户切到 B handleEvent(stA, 'agent-A', 'agent_output', { message: 'A2' }); handleEvent(stB, 'agent-B', 'agent_output', { message: 'B2' }); expect(stA.streamContent).toBe('A1A2'); expect(stB.streamContent).toBe('B1B2'); }); }); describe('agentChatEvents · 打字机渲染缓冲', () => { it('文本按固定节奏匀速吐出,而不是一次性整段落地', () => { vi.useFakeTimers(); try { const st = makeState(); const pushChunk = createTypewriterQueue({ tickMs: 10, charsPerTick: 2 }); pushChunk(st, 'abcdef'); expect(st.streamContent).toBe(''); // 入队即刻不落地,等定时器吐 vi.advanceTimersByTime(10); expect(st.streamContent).toBe('ab'); vi.advanceTimersByTime(10); expect(st.streamContent).toBe('abcd'); vi.advanceTimersByTime(10); expect(st.streamContent).toBe('abcdef'); vi.advanceTimersByTime(10); // 队列空了,定时器该自己停 expect(st._typeTimer).toBeNull(); } finally { vi.useRealTimers(); } }); it('flushTypewriter 立刻吐光剩余队列(打断/结束时用)', () => { vi.useFakeTimers(); try { const st = makeState(); const pushChunk = createTypewriterQueue({ tickMs: 10, charsPerTick: 1 }); pushChunk(st, 'hello'); vi.advanceTimersByTime(10); // 只吐了 1 个字符 expect(st.streamContent).toBe('h'); flushTypewriter(st); expect(st.streamContent).toBe('hello'); expect(st._typeTimer).toBeNull(); expect(st._typeQueue).toBe(''); } finally { vi.useRealTimers(); } }); });