// @vitest-environment happy-dom import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ActionButton, IconButton } from "../design-system/components.js"; import { defineDesignSystem } from "../design-system/definition.js"; import { ToolkitProvider } from "../provider.js"; import type { ChatHistoryItem } from "./ChatHistoryList.js"; import { ChatHistoryRail } from "./ChatHistoryRail.js"; const railLabels = { newChat: "New chat", showMore: "Show more chats", showLess: "Show fewer chats", }; function makeItems(count: number): ChatHistoryItem[] { return Array.from({ length: count }, (_, index) => ({ id: `thread-${index + 1}`, title: `Chat ${index + 1}`, })); } describe("ChatHistoryRail", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it("shows five recent chats before progressively disclosing up to fifteen", () => { act(() => { root.render( {}} onNewChat={() => {}} railLabels={railLabels} />, ); }); expect(container.querySelectorAll(".an-chat-history-row")).toHaveLength(5); const disclosure = container.querySelector( ".an-chat-history-rail__disclosure", ); expect(disclosure?.getAttribute("aria-label")).toBe("Show more chats"); act(() => disclosure?.click()); expect(container.querySelectorAll(".an-chat-history-row")).toHaveLength(15); expect(disclosure?.getAttribute("aria-label")).toBe("Show fewer chats"); act(() => disclosure?.click()); expect(container.querySelectorAll(".an-chat-history-row")).toHaveLength(5); }); it("keeps the disclosure to the right of new chat and calls its handler", () => { const onNewChat = vi.fn(); act(() => { root.render( {}} onNewChat={onNewChat} railLabels={railLabels} />, ); }); const disclosure = container.querySelector( ".an-chat-history-rail__disclosure", ); const newChat = container.querySelector( ".an-chat-history-rail__new-chat", ); expect(newChat?.textContent).toBe("New chat"); expect(newChat?.nextElementSibling).toBe(disclosure); expect(newChat?.parentElement?.lastElementChild).toBe(disclosure); act(() => newChat?.click()); expect(onNewChat).toHaveBeenCalledOnce(); }); it("lets new chat fill the footer when there are no more chats", () => { act(() => { root.render( {}} onNewChat={() => {}} railLabels={railLabels} />, ); }); expect( container.querySelector(".an-chat-history-rail__disclosure"), ).toBeNull(); expect( container.querySelector(".an-chat-history-rail__new-chat"), ).not.toBeNull(); }); it("preserves list actions for progressively disclosed rows", () => { const onSelect = vi.fn(); act(() => { root.render( {}} railLabels={railLabels} />, ); }); act(() => { container .querySelector(".an-chat-history-rail__disclosure") ?.click(); }); const rows = container.querySelectorAll( ".an-chat-history-row__button", ); act(() => rows[5]?.click()); expect(onSelect).toHaveBeenCalledWith("thread-6"); }); it("lets a design system replace the whole view without replacing its controller", () => { const onNewChat = vi.fn(); const CustomActionButton = vi.fn( ({ children, onPress }: Parameters[0]) => ( ), ); const designSystem = defineDesignSystem({ name: "Acme", components: { ActionButton: CustomActionButton }, }); act(() => { root.render( {}} onNewChat={onNewChat} railLabels={railLabels} renderRail={({ controller }) => (
{controller.visibleItems.length} {controller.newChatLabel} {controller.disclosureLabel}
)} />
, ); }); const actions = container.querySelectorAll("[data-acme-action]"); expect(container.querySelector("[data-acme-rail]")).not.toBeNull(); expect(container.querySelector("output")?.textContent).toBe("5"); expect(actions[1]?.textContent).toBe("Show more chats"); act(() => actions[1]?.click()); expect(container.querySelector("output")?.textContent).toBe("15"); expect(actions[1]?.textContent).toBe("Show fewer chats"); act(() => actions[0]?.click()); expect(onNewChat).toHaveBeenCalledOnce(); expect(CustomActionButton).toHaveBeenCalled(); }); it("routes the default footer through the semantic action bridge", () => { const onNewChat = vi.fn(); const CustomActionButton = vi.fn( ({ children, onPress }: Parameters[0]) => ( ), ); const CustomIconButton = vi.fn( ({ icon, label, onPress }: Parameters[0]) => ( ), ); const designSystem = defineDesignSystem({ name: "Acme", components: { ActionButton: CustomActionButton, IconButton: CustomIconButton, }, }); act(() => { root.render( {}} onNewChat={onNewChat} railLabels={railLabels} /> , ); }); const newChat = container.querySelector( "[data-semantic-action]", ); const disclosure = container.querySelector( "[data-semantic-icon]", ); expect(newChat).not.toBeNull(); expect(disclosure?.getAttribute("aria-label")).toBe("Show more chats"); expect(CustomActionButton.mock.calls[0]?.[0]).toMatchObject({ emphasis: "ghost", size: "compact", leadingIcon: expect.anything(), }); expect(CustomIconButton.mock.calls[0]?.[0]).toMatchObject({ size: "compact", "aria-expanded": false, label: "Show more chats", }); act(() => newChat?.click()); expect(onNewChat).toHaveBeenCalledOnce(); }); it("keeps native button semantics and focus in the default view", () => { const onNewChat = vi.fn(); act(() => { root.render( {}} onNewChat={onNewChat} railLabels={railLabels} />, ); }); const newChat = container.querySelector( ".an-chat-history-rail__new-chat", ); newChat?.focus(); act(() => newChat?.click()); expect(newChat?.type).toBe("button"); expect(document.activeElement).toBe(newChat); expect(onNewChat).toHaveBeenCalledOnce(); }); it("falls back to the default rail when a product renderer fails", () => { vi.spyOn(console, "error").mockImplementation(() => {}); act(() => { root.render( {}} onNewChat={() => {}} railLabels={railLabels} renderRail={() => { throw new Error("broken company rail"); }} />, ); }); expect( container.querySelector(".an-chat-history-rail__new-chat"), ).not.toBeNull(); }); });