import type { UIMessage } from "ai"; import { act, renderHook } from "@testing-library/react"; import { describe, expect, mock, test } from "bun:test"; import { useChatScroll } from "../../../src/web/consoles/workbench/components/chat/use-chat-scroll"; interface MockedElement { addEventListener: ReturnType; clientHeight: number; removeEventListener: ReturnType; scrollHeight: number; scrollTo: ReturnType; scrollTop: number; } function asHTMLElement(el: MockedElement): HTMLElement { return el as unknown as HTMLElement; } function asRef(el: MockedElement): React.RefObject { return { current: asHTMLElement(el) }; } function createScrollElement(overrides: Partial = {}): MockedElement { return { addEventListener: mock(() => {}), clientHeight: 400, removeEventListener: mock(() => {}), scrollHeight: 1000, scrollTo: mock(() => {}), scrollTop: 600, ...overrides, }; } describe("useChatScroll", () => { test("在底部附近时自动滚动", () => { const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 }); const scrollRef = asRef(el); const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[]; const { rerender } = renderHook( ({ messages, scrollRef, status, }: { messages: UIMessage[]; scrollRef: React.RefObject; status: string; }) => useChatScroll({ messages, scrollRef, status }), { initialProps: { messages, scrollRef, status: "streaming" }, }, ); const newMessages: UIMessage[] = [ { id: "1", parts: [], role: "user" }, { id: "2", parts: [], role: "assistant" }, ] as UIMessage[]; rerender({ messages: newMessages, scrollRef, status: "streaming" }); expect(el.scrollTo).toHaveBeenCalled(); }); test("不在底部时不自动滚动", () => { const el = createScrollElement({ scrollHeight: 1000, scrollTop: 200 }); const scrollRef = asRef(el); const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[]; const { rerender } = renderHook( ({ messages, scrollRef, status, }: { messages: UIMessage[]; scrollRef: React.RefObject; status: string; }) => useChatScroll({ messages, scrollRef, status }), { initialProps: { messages, scrollRef, status: "streaming" }, }, ); const handlerCalls = el.addEventListener.mock.calls as unknown[][]; const scrollHandler = handlerCalls.find((c) => c[0] === "scroll")?.[1]; if (scrollHandler) { el.scrollTop = 200; (scrollHandler as () => void)(); } el.scrollTo.mockClear(); const newMessages: UIMessage[] = [ { id: "1", parts: [], role: "user" }, { id: "2", parts: [], role: "assistant" }, ] as UIMessage[]; rerender({ messages: newMessages, scrollRef, status: "streaming" }); const scrollToCalls = el.scrollTo.mock.calls as unknown[][]; expect(scrollToCalls.length).toBe(0); }); test("scrollToBottom 使用 smooth 滚动", () => { const el = createScrollElement({ scrollHeight: 1000, scrollTop: 200 }); const scrollRef = asRef(el); const messages: UIMessage[] = []; const { result } = renderHook(() => useChatScroll({ messages, scrollRef, status: "ready" })); act(() => { result.current.scrollToBottom(); }); expect(el.scrollTo).toHaveBeenCalledWith({ behavior: "smooth", top: 1000 }); }); test("流式时使用 instant 滚动", () => { const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 }); const scrollRef = asRef(el); const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[]; renderHook(() => useChatScroll({ messages, scrollRef, status: "streaming" })); const calls = el.scrollTo.mock.calls as unknown[][]; const instantCall = calls.find((call) => { const opts = call[0] as Record | undefined; return opts?.["behavior"] === "instant"; }); expect(instantCall).toBeTruthy(); }); });