Files
kandev/apps/web/components/threads/thread-column-activation.test.tsx
T
Jérémy Coatelen 538813de02 feat(web): add Threads view for live agent conversations (#3112)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-03 08:07:16 +00:00

183 lines
5.8 KiB
TypeScript

import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCallback } from "react";
import type { ResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
const responsiveMocks = vi.hoisted(() => ({
useResponsiveBreakpoint: vi.fn(),
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => responsiveMocks);
import { useThreadColumnActivation } from "./use-thread-column-activation";
type ObserverRecord = {
callback: IntersectionObserverCallback;
instance: MockIntersectionObserver;
};
const observers: ObserverRecord[] = [];
const TASK_A = "a";
const TASK_B = "b";
const TASK_C = "c";
const TASK_D = "d";
const DETAIL_IDS = "detail-ids";
const PRELOAD_IDS = "preload-ids";
class MockIntersectionObserver implements IntersectionObserver {
readonly root: Element | Document | null = null;
readonly rootMargin = "";
readonly thresholds: readonly number[] = [];
readonly observed = new Set<Element>();
constructor(
private readonly callback: IntersectionObserverCallback,
_options?: IntersectionObserverInit,
) {
observers.push({ callback, instance: this });
}
observe(element: Element): void {
this.observed.add(element);
}
unobserve(element: Element): void {
this.observed.delete(element);
}
disconnect(): void {
this.observed.clear();
}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
emit(...entries: Array<Partial<IntersectionObserverEntry> & { target: Element }>): void {
act(() => {
this.callback(
entries.map((entry) => entry as IntersectionObserverEntry),
this as unknown as IntersectionObserver,
);
});
}
}
function desktopBreakpoint(): ResponsiveBreakpoint {
return { isMobile: false } as ResponsiveBreakpoint;
}
function mobileBreakpoint(): ResponsiveBreakpoint {
return { isMobile: true } as ResponsiveBreakpoint;
}
function ActivationColumn({
id,
registerColumn,
}: {
id: string;
registerColumn: (taskId: string, element: HTMLElement | null) => void;
}) {
const ref = useCallback(
(element: HTMLElement | null) => registerColumn(id, element),
[id, registerColumn],
);
return <div ref={ref} data-testid={`column-${id}`} />;
}
function ActivationFixture({ ids, focusedTaskId }: { ids: string[]; focusedTaskId?: string }) {
const activation = useThreadColumnActivation(ids, focusedTaskId);
return (
<div ref={activation.boardRef} data-testid="activation-board">
<output data-testid="preload-ids">{[...activation.preloadTaskIds].join(",")}</output>
<output data-testid="detail-ids">{[...activation.detailTaskIds].join(",")}</output>
{ids.map((id) => (
<ActivationColumn key={id} id={id} registerColumn={activation.registerColumn} />
))}
</div>
);
}
function ids(testId: string): string[] {
const value = screen.getByTestId(testId).textContent ?? "";
return value ? value.split(",") : [];
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
observers.length = 0;
});
describe("useThreadColumnActivation", () => {
beforeEach(() => {
responsiveMocks.useResponsiveBreakpoint.mockReturnValue(desktopBreakpoint());
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
});
it("preloads visible columns and one neighbor while detailing every visible desktop column", () => {
render(<ActivationFixture ids={[TASK_A, TASK_B, TASK_C, TASK_D]} />);
const observer = observers[0]?.instance;
const columnA = screen.getByTestId(`column-${TASK_A}`);
const columnB = screen.getByTestId(`column-${TASK_B}`);
const columnC = screen.getByTestId(`column-${TASK_C}`);
expect(observer?.observed).toEqual(
new Set([columnA, columnB, columnC, screen.getByTestId(`column-${TASK_D}`)]),
);
observer?.emit(
{ target: columnB, isIntersecting: true },
{ target: columnC, isIntersecting: true },
);
expect(ids(DETAIL_IDS)).toEqual([TASK_B, TASK_C]);
expect(ids(PRELOAD_IDS)).toEqual([TASK_A, TASK_B, TASK_C, TASK_D]);
observer?.emit({ target: columnB, isIntersecting: false });
expect(ids(DETAIL_IDS)).toEqual([TASK_C]);
expect(ids(PRELOAD_IDS)).toEqual([TASK_B, TASK_C, TASK_D]);
});
it("keeps a focused deep-link task in the conservative fallback window", () => {
vi.stubGlobal("IntersectionObserver", undefined);
render(<ActivationFixture ids={[TASK_A, TASK_B, TASK_C, TASK_D]} focusedTaskId={TASK_C} />);
expect(ids(DETAIL_IDS)).toEqual([TASK_C]);
expect(ids(PRELOAD_IDS)).toEqual([TASK_B, TASK_C, TASK_D]);
});
it("gives phone only the nearest visible snap column as detail owner", () => {
responsiveMocks.useResponsiveBreakpoint.mockReturnValue(mobileBreakpoint());
render(<ActivationFixture ids={[TASK_A, TASK_B, TASK_C]} />);
const board = screen.getByTestId("activation-board");
const columnA = screen.getByTestId(`column-${TASK_A}`);
const columnB = screen.getByTestId(`column-${TASK_B}`);
const columnC = screen.getByTestId(`column-${TASK_C}`);
vi.spyOn(board, "getBoundingClientRect").mockReturnValue({ left: 0, right: 300 } as DOMRect);
vi.spyOn(columnA, "getBoundingClientRect").mockReturnValue({
left: -120,
right: 80,
} as DOMRect);
vi.spyOn(columnB, "getBoundingClientRect").mockReturnValue({
left: 100,
right: 300,
} as DOMRect);
vi.spyOn(columnC, "getBoundingClientRect").mockReturnValue({
left: 320,
right: 520,
} as DOMRect);
observers[0]?.instance.emit(
{ target: columnA, isIntersecting: true },
{ target: columnB, isIntersecting: true },
{ target: columnC, isIntersecting: false },
);
expect(ids(DETAIL_IDS)).toEqual([TASK_B]);
expect(ids("detail-ids")).toHaveLength(1);
expect(ids(PRELOAD_IDS)).toEqual([TASK_A, TASK_B, TASK_C]);
});
});