|
| 1 | +/** |
| 2 | + * useChatStream.test — 帧级批量渲染契约(REQ-275)。 |
| 3 | + * AAA:高频 chunk 同帧合并为单次 setView;终态/切会话立即 flush 不残留; |
| 4 | + * 单会话单流与终态清理回归。 |
| 5 | + * @vitest-environment jsdom(renderHook 需 document) |
| 6 | + */ |
| 7 | +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; |
| 8 | +import { renderHook, act } from "@testing-library/react"; |
| 9 | +import type { ChatStreamEvent } from "../types"; |
| 10 | +import useChatStream from "./useChatStream"; |
| 11 | + |
| 12 | +/** 受控 rAF 桩(jsdom 无 rAF——手动推进帧) */ |
| 13 | +let rafQueue: { id: number; cb: FrameRequestCallback }[] = []; |
| 14 | +let rafSeq = 1; |
| 15 | +function advanceOneFrame() { |
| 16 | + const item = rafQueue.shift(); |
| 17 | + if (item) item.cb(0); |
| 18 | +} |
| 19 | +function pendingFrames(): number { |
| 20 | + return rafQueue.length; |
| 21 | +} |
| 22 | + |
| 23 | +/** Channel mock:捕获实例以便手动触发 onmessage */ |
| 24 | +let lastChannel: { onmessage: ((ev: ChatStreamEvent) => void) | null } | null = null; |
| 25 | +const invokeMock = vi.fn(async () => undefined); |
| 26 | +vi.mock("@tauri-apps/api/core", () => ({ |
| 27 | + Channel: class { |
| 28 | + onmessage: ((ev: ChatStreamEvent) => void) | null = null; |
| 29 | + constructor() { |
| 30 | + lastChannel = this; |
| 31 | + } |
| 32 | + }, |
| 33 | + invoke: () => invokeMock(), |
| 34 | +})); |
| 35 | + |
| 36 | +beforeEach(() => { |
| 37 | + rafQueue = []; |
| 38 | + rafSeq = 1; |
| 39 | + lastChannel = null; |
| 40 | + invokeMock.mockClear(); |
| 41 | + globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { |
| 42 | + rafQueue.push({ id: rafSeq, cb }); |
| 43 | + return rafSeq++; |
| 44 | + }; |
| 45 | + globalThis.cancelAnimationFrame = (id: number) => { |
| 46 | + rafQueue = rafQueue.filter((i) => i.id !== id); |
| 47 | + }; |
| 48 | +}); |
| 49 | + |
| 50 | +afterEach(() => { |
| 51 | + vi.restoreAllMocks(); |
| 52 | +}); |
| 53 | + |
| 54 | +async function startStream(hook: ReturnType<typeof renderHook<ReturnType<typeof useChatStream>, unknown>>["result"]) { |
| 55 | + await act(async () => { |
| 56 | + expect(await hook.current.launch(1, "chat_send", {})).toBe(true); |
| 57 | + }); |
| 58 | + expect(lastChannel).not.toBeNull(); |
| 59 | +} |
| 60 | + |
| 61 | +const chunk = (delta: string): ChatStreamEvent => ({ kind: "chunk", delta }); |
| 62 | +const doneEv: ChatStreamEvent = { kind: "done", content: "", usageJson: null }; |
| 63 | + |
| 64 | +describe("useChatStream 帧级批量渲染(REQ-275)", () => { |
| 65 | + it("高频 chunk 同帧合并为一次 setView(先排帧后落视图)", async () => { |
| 66 | + const settled = vi.fn(); |
| 67 | + const { result } = renderHook(() => useChatStream(settled)); |
| 68 | + await startStream(result); |
| 69 | + act(() => { result.current.setActive(1); }); |
| 70 | + act(() => { |
| 71 | + lastChannel!.onmessage!(chunk("你")); |
| 72 | + lastChannel!.onmessage!(chunk("好")); |
| 73 | + }); |
| 74 | + // 帧未推进:文本仍为空(chunk 已累积在 accs,等待合帧) |
| 75 | + expect(result.current.view?.text).toBeUndefined(); |
| 76 | + expect(pendingFrames()).toBe(1); // 多 chunk 只挂一帧 |
| 77 | + act(() => advanceOneFrame()); |
| 78 | + expect(result.current.view?.text).toBe("你好"); |
| 79 | + }); |
| 80 | + |
| 81 | + it("帧挂起期间的后续 chunk 合并进同一帧", async () => { |
| 82 | + const { result } = renderHook(() => useChatStream(vi.fn())); |
| 83 | + await startStream(result); |
| 84 | + act(() => result.current.setActive(1)); |
| 85 | + act(() => lastChannel!.onmessage!(chunk("A"))); |
| 86 | + expect(pendingFrames()).toBe(1); |
| 87 | + act(() => lastChannel!.onmessage!(chunk("B"))); |
| 88 | + expect(pendingFrames()).toBe(1); // 未新增帧 |
| 89 | + act(() => advanceOneFrame()); |
| 90 | + expect(result.current.view?.text).toBe("AB"); |
| 91 | + // 下一帧可再次调度 |
| 92 | + act(() => lastChannel!.onmessage!(chunk("C"))); |
| 93 | + expect(pendingFrames()).toBe(1); |
| 94 | + act(() => advanceOneFrame()); |
| 95 | + expect(result.current.view?.text).toBe("ABC"); |
| 96 | + }); |
| 97 | + |
| 98 | + it("终态立即 flush 残留帧并清流(不丢最后一段)", async () => { |
| 99 | + const settled = vi.fn(); |
| 100 | + const { result } = renderHook(() => useChatStream(settled)); |
| 101 | + await startStream(result); |
| 102 | + act(() => result.current.setActive(1)); |
| 103 | + act(() => lastChannel!.onmessage!(chunk("尾"))); |
| 104 | + expect(pendingFrames()).toBe(1); |
| 105 | + act(() => lastChannel!.onmessage!(doneEv)); |
| 106 | + expect(pendingFrames()).toBe(0); // 残留帧已取消 |
| 107 | + expect(result.current.view).toBeNull(); |
| 108 | + expect(settled).toHaveBeenCalledWith(1, doneEv); |
| 109 | + expect(result.current.isStreaming(1)).toBe(false); |
| 110 | + }); |
| 111 | + |
| 112 | + it("切会话前立即 flush 当前会话残留(视图不串台)", async () => { |
| 113 | + const { result } = renderHook(() => useChatStream(vi.fn())); |
| 114 | + await startStream(result); |
| 115 | + act(() => result.current.setActive(1)); |
| 116 | + act(() => lastChannel!.onmessage!(chunk("旧会话末字"))); |
| 117 | + expect(pendingFrames()).toBe(1); |
| 118 | + act(() => result.current.setActive(2)); |
| 119 | + expect(pendingFrames()).toBe(0); // flush 取消残留帧 |
| 120 | + expect(result.current.view?.text ?? "").not.toContain("旧会话末字"); |
| 121 | + }); |
| 122 | + |
| 123 | + it("invoke 前置失败清理流槽且无残留帧", async () => { |
| 124 | + invokeMock.mockRejectedValueOnce(new Error("gate")); |
| 125 | + const { result } = renderHook(() => useChatStream(vi.fn())); |
| 126 | + let err: unknown = null; |
| 127 | + await act(async () => { |
| 128 | + try { |
| 129 | + await result.current.launch(1, "chat_send", {}); |
| 130 | + } catch (e) { err = e; } |
| 131 | + }); |
| 132 | + expect(err).not.toBeNull(); |
| 133 | + expect(result.current.isStreaming(1)).toBe(false); |
| 134 | + expect(pendingFrames()).toBe(0); |
| 135 | + }); |
| 136 | +}); |
0 commit comments