import { act, fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, vi } from "vitest"; import type { ActivityEntry, ActivityKind } from "../api/types"; import { useSessionStore } from "../store/sessionStore"; import { isNearBottom } from "./activityScroll"; import * as activityPanelModule from "./ModelActivityPanel"; import { ModelActivityPanel } from "./ModelActivityPanel"; beforeEach(() => useSessionStore.getState().resetSession()); test("renders question, reasoning, and response in chronological order", () => { const store = useSessionStore.getState(); store.setPhase("F1"); store.setLastUserEntry({ kind: "input", text: "How many **patients**?" }); store.applyEvent({ type: "info", level: "warning", text: "Retrying schema lookup" }); store.applyEvent({ type: "activity_delta", text: "Inspecting **schema**." }); store.applyEvent({ type: "text_delta", text: "I found *42* patients." }); store.applyEvent({ type: "activity_event", activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" }, }); store.applyEvent({ type: "ui_request", ui_request: { id: "gate-1", widget: "select", phase: "F1_review", title: "Confirm cohort" }, }); render(); const rows = screen.getAllByRole("article"); expect(rows).toHaveLength(3); expect(within(rows[0]).getByText("Question")).toBeInTheDocument(); expect(within(rows[0]).getByText("patients").tagName).toBe("STRONG"); expect(within(rows[1]).getByText("Reasoning")).toBeInTheDocument(); expect(within(rows[1]).getByText("schema").tagName).toBe("STRONG"); expect(within(rows[2]).getByText("Response")).toBeInTheDocument(); expect(within(rows[2]).getByText("42").tagName).toBe("EM"); expect(screen.queryByText("Retrying schema lookup")).not.toBeInTheDocument(); expect(screen.queryByText("Confirm cohort")).not.toBeInTheDocument(); expect(screen.queryByText("bash")).not.toBeInTheDocument(); }); test("uses an explicit default-deny activity-kind allowlist", () => { const predicate = (activityPanelModule as unknown as { isVisibleModelActivity?: (entry: ActivityEntry) => boolean; }).isVisibleModelActivity; expect(predicate).toBeTypeOf("function"); if (!predicate) return; for (const kind of ["prompt", "thinking", "assistant"] satisfies ActivityKind[]) { expect(predicate({ kind, phase: "F1", text: kind })).toBe(true); } for (const kind of ["status", "gate", "tool", "lifecycle"] satisfies ActivityKind[]) { expect(predicate({ kind, phase: "F1", text: kind })).toBe(false); } expect(predicate({ kind: "future-kind" as ActivityKind, phase: "F1", text: "future" })).toBe(false); }); test("shows the empty state when the raw log contains only hidden entries", () => { useSessionStore.setState({ activityLog: [ { kind: "status", phase: "F1", text: "Status" }, { kind: "gate", phase: "F1", text: "Confirm cohort" }, { kind: "tool", phase: "F1", text: "bash", toolCallId: "tool-1", status: "completed" }, { kind: "lifecycle", phase: "F1", text: "Turn end" }, ], }); render(); expect(screen.getByText("No activity yet.")).toBeInTheDocument(); expect(screen.queryAllByRole("article")).toHaveLength(0); }); test("keeps all timeline entries visible without an expansion control", () => { const activityLog: ActivityEntry[] = Array.from({ length: 8 }, (_, index) => ({ kind: "thinking", phase: `F${index + 1}`, text: `timeline-${index + 1}`, })); useSessionStore.setState({ activityLog }); render(); for (const entry of activityLog) expect(screen.getByText(entry.text)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /show (more|less)/i })).not.toBeInTheDocument(); }); test("close calls onClose without mutating the activity log", async () => { const onClose = vi.fn(); const activityLog: ActivityEntry[] = [ { kind: "thinking", phase: "F1", text: "Keep this row" }, ]; useSessionStore.setState({ activityLog }); render(); const expectedLog = useSessionStore.getState().activityLog.map((entry) => ({ ...entry })); await userEvent.click(screen.getByRole("button", { name: /close model activity/i })); expect(onClose).toHaveBeenCalledOnce(); expect(useSessionStore.getState().activityLog).toEqual(expectedLog); }); test("isNearBottom includes the 48px boundary", () => { expect(isNearBottom({ scrollHeight: 500, clientHeight: 100, scrollTop: 352 })).toBe(true); expect(isNearBottom({ scrollHeight: 500, clientHeight: 100, scrollTop: 351 })).toBe(false); expect(isNearBottom({ scrollHeight: 500, clientHeight: 100, scrollTop: 400 })).toBe(true); }); function setScrollGeometry( element: HTMLElement, values: { scrollHeight: number; clientHeight: number; scrollTop: number }, ) { Object.defineProperties(element, { scrollHeight: { configurable: true, value: values.scrollHeight }, clientHeight: { configurable: true, value: values.clientHeight }, scrollTop: { configurable: true, writable: true, value: values.scrollTop }, }); } test("follows appended visible response while near the bottom", () => { useSessionStore.setState({ activityLog: [{ kind: "thinking", phase: "F1", text: "First" }], }); render(); const viewport = screen.getByTestId("activity-scroll"); setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 }); act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "Second" })); expect(viewport.scrollTop).toBe(400); }); test("preserves manual scroll position when visible activity arrives", () => { useSessionStore.setState({ activityLog: [{ kind: "thinking", phase: "F1", text: "First" }], }); render(); const viewport = screen.getByTestId("activity-scroll"); setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 100 }); fireEvent.scroll(viewport); act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "Second" })); expect(viewport.scrollTop).toBe(100); }); test("does not bottom-follow when only a hidden status arrives", () => { useSessionStore.setState({ activityLog: [{ kind: "thinking", phase: "F1", text: "Visible reasoning" }], }); render(); const viewport = screen.getByTestId("activity-scroll"); setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 }); act(() => useSessionStore.getState().applyEvent({ type: "info", text: "Hidden status" })); expect(viewport.scrollTop).toBe(300); });