158 lines
6.7 KiB
TypeScript
158 lines
6.7 KiB
TypeScript
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(<ModelActivityPanel onClose={vi.fn()} />);
|
|
|
|
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(<ModelActivityPanel onClose={vi.fn()} />);
|
|
|
|
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(<ModelActivityPanel onClose={vi.fn()} />);
|
|
|
|
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(<ModelActivityPanel onClose={onClose} />);
|
|
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(<ModelActivityPanel onClose={vi.fn()} />);
|
|
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(<ModelActivityPanel onClose={vi.fn()} />);
|
|
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(<ModelActivityPanel onClose={vi.fn()} />);
|
|
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);
|
|
});
|