Files
kandev/apps/web/components/threads/thread-column-activation.test.tsx
T

398 lines
14 KiB
TypeScript

import { act, cleanup, fireEvent, 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";
const MOBILE_TASK_ID = "mobile-task";
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,
layout = "columns",
}: {
ids: string[];
focusedTaskId?: string;
layout?: string;
}) {
const activation = useThreadColumnActivation(ids, focusedTaskId, layout);
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>
<output data-testid={MOBILE_TASK_ID}>{activation.mobileTaskId}</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.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
observers.length = 0;
});
function mobileGeometry() {
const board = screen.getByTestId("activation-board");
const a = screen.getByTestId(`column-${TASK_A}`);
const b = screen.getByTestId(`column-${TASK_B}`);
vi.spyOn(board, "getBoundingClientRect").mockReturnValue({ left: 0, right: 300 } as DOMRect);
const rectA = vi.spyOn(a, "getBoundingClientRect");
const rectB = vi.spyOn(b, "getBoundingClientRect");
function move(offset: number) {
rectA.mockReturnValue({ left: -offset, right: 300 - offset } as DOMRect);
rectB.mockReturnValue({ left: 300 - offset, right: 600 - offset } as DOMRect);
fireEvent.scroll(board);
}
return { board, a, b, move };
}
describe("phone position feedback", () => {
let resize: ResizeObserverCallback;
const disconnectResize = vi.fn();
beforeEach(() => {
responsiveMocks.useResponsiveBreakpoint.mockReturnValue(mobileBreakpoint());
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] });
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
resize = callback;
}
observe() {}
disconnect = disconnectResize;
},
);
disconnectResize.mockClear();
});
function frame() {
act(() => vi.advanceTimersToNextFrame());
}
// @covers AC-UI-THREADS-DECK-003.13
it("updates mobile position across the midpoint without changing intersecting membership", () => {
render(<ActivationFixture ids={[TASK_A, TASK_B]} />);
const { a, b, move } = mobileGeometry();
move(30);
observers[0].instance.emit(
{ target: a, isIntersecting: true, intersectionRatio: 0.9 },
{ target: b, isIntersecting: true, intersectionRatio: 0.1 },
);
frame();
expect(ids(DETAIL_IDS)).toEqual([TASK_A]);
move(180);
observers[0].instance.emit(
{ target: a, isIntersecting: true, intersectionRatio: 0.4 },
{ target: b, isIntersecting: true, intersectionRatio: 0.6 },
);
frame();
expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_B);
expect(ids(DETAIL_IDS)).toEqual([TASK_B]);
move(90);
frame();
expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_A);
});
it("reconciles resize, removal, and an empty deck without waiting for visibility", () => {
const view = render(<ActivationFixture ids={[TASK_A, TASK_B]} />);
const { board, move } = mobileGeometry();
move(180);
frame();
expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_B);
vi.mocked(board.getBoundingClientRect).mockReturnValue({ left: 0, right: 50 } as DOMRect);
act(() => resize([], {} as ResizeObserver));
frame();
expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_A);
view.rerender(<ActivationFixture ids={[TASK_B]} />);
expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_B);
view.rerender(<ActivationFixture ids={[]} />);
expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe("");
});
it("uses deep-link fallback before geometry is available and clears on desktop", () => {
const view = render(<ActivationFixture ids={[TASK_A, TASK_B]} focusedTaskId={TASK_B} />);
expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_B);
responsiveMocks.useResponsiveBreakpoint.mockReturnValue(desktopBreakpoint());
view.rerender(<ActivationFixture ids={[TASK_A, TASK_B]} focusedTaskId={TASK_B} />);
expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe("");
expect(disconnectResize).toHaveBeenCalled();
});
it("coalesces scroll work and cancels pending frames on unmount", () => {
const view = render(<ActivationFixture ids={[TASK_A, TASK_B]} />);
const { board, move } = mobileGeometry();
move(180);
move(190);
expect(vi.getTimerCount()).toBe(1);
view.unmount();
expect(vi.getTimerCount()).toBe(0);
expect(disconnectResize).toHaveBeenCalledTimes(1);
fireEvent.scroll(board);
expect(vi.getTimerCount()).toBe(0);
});
});
function observeDesktopColumns() {
responsiveMocks.useResponsiveBreakpoint.mockReturnValue(desktopBreakpoint());
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
}
describe("column activation during reflow", () => {
beforeEach(observeDesktopColumns);
it("publishes current geometry before a replacement observer reports after reflow", () => {
const taskIds = [TASK_A, TASK_B, TASK_C, TASK_D];
const view = render(<ActivationFixture ids={taskIds} />);
const board = screen.getByTestId("activation-board");
const [a, b, c, d] = taskIds.map((id) => screen.getByTestId(`column-${id}`));
const oldObserver = observers.at(-1)!.instance;
oldObserver.emit({ target: a, isIntersecting: true }, { target: b, isIntersecting: true });
vi.spyOn(board, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 360, 612));
vi.spyOn(a, "getBoundingClientRect").mockReturnValue(new DOMRect(-372, 0, 360, 300));
vi.spyOn(b, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 360, 300));
vi.spyOn(c, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 312, 360, 300));
vi.spyOn(d, "getBoundingClientRect").mockReturnValue(new DOMRect(372, 0, 360, 300));
view.rerender(<ActivationFixture ids={taskIds} layout="grid:360" />);
expect(ids(DETAIL_IDS)).toEqual([TASK_B, TASK_C]);
expect(ids(PRELOAD_IDS)).toEqual(taskIds);
oldObserver.emit({ target: a, isIntersecting: true }, { target: d, isIntersecting: true });
expect(ids(DETAIL_IDS)).toEqual([TASK_B, TASK_C]);
observers.at(-1)!.instance.emit({ target: b, isIntersecting: false });
expect(ids(DETAIL_IDS)).toEqual([TASK_C]);
});
});
describe("useThreadColumnActivation", () => {
beforeEach(observeDesktopColumns);
// @covers AC-UI-THREADS-DECK-004.5, AC-UI-THREADS-DECK-004.6
it("refreshes a same-membership layout and ignores stale observer callbacks", () => {
const taskIds = [TASK_A, TASK_B, TASK_C, TASK_D, "e", "f"];
const view = render(<ActivationFixture ids={taskIds} />);
const oldObserver = observers.at(-1)!.instance;
const a = screen.getByTestId(`column-${TASK_A}`);
const b = screen.getByTestId(`column-${TASK_B}`);
oldObserver.emit({ target: a, isIntersecting: true });
view.rerender(<ActivationFixture ids={taskIds} layout="grid" />);
const gridObserver = observers.at(-1)!.instance;
gridObserver.emit({ target: a, isIntersecting: true }, { target: b, isIntersecting: true });
oldObserver.emit({ target: b, isIntersecting: false });
expect(ids(DETAIL_IDS)).toEqual([TASK_A, TASK_B]);
expect(ids(PRELOAD_IDS)).toEqual([TASK_A, TASK_B, TASK_C]);
expect(gridObserver).not.toBe(oldObserver);
});
// @covers AC-UI-THREADS-DECK-004.5
it("bounds thirty grid shells to visible rows plus one adjacent task on each side", () => {
const taskIds = Array.from({ length: 30 }, (_, index) => `task-${index}`);
render(<ActivationFixture ids={taskIds} layout="grid" />);
const observer = observers.at(-1)!.instance;
const visibleIds = taskIds.slice(2, 6);
observer.emit(
...visibleIds.map((id) => ({
target: screen.getByTestId(`column-${id}`),
isIntersecting: true,
})),
);
expect(ids(DETAIL_IDS)).toEqual(visibleIds);
expect(ids(PRELOAD_IDS)).toEqual(taskIds.slice(1, 7));
observer.emit(
...visibleIds.map((id) => ({
target: screen.getByTestId(`column-${id}`),
isIntersecting: false,
})),
{ target: screen.getByTestId("column-task-29"), isIntersecting: true },
);
expect(ids(DETAIL_IDS)).toEqual(["task-29"]);
expect(ids(PRELOAD_IDS)).toEqual(["task-28", "task-29"]);
});
});
describe("column activation windows", () => {
beforeEach(observeDesktopColumns);
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]);
});
// @covers AC-TASKS-THREADS-ACTIONS-003.5, AC-TASKS-THREADS-ACTIONS-003.6
it.each([
{ change: "removal", nextIds: [TASK_A, TASK_B] },
{ change: "readmission", nextIds: [TASK_A, TASK_B, TASK_C, TASK_D] },
])("retains the surviving visible chat through $change", ({ nextIds }) => {
const view = render(<ActivationFixture ids={[TASK_A, TASK_B, TASK_C]} />);
observers[0].instance.emit({
target: screen.getByTestId(`column-${TASK_B}`),
isIntersecting: true,
});
expect(ids(DETAIL_IDS)).toEqual([TASK_B]);
view.rerender(<ActivationFixture ids={nextIds} />);
expect(ids(DETAIL_IDS)).toEqual([TASK_B]);
expect(observers[0].instance.observed).toEqual(
new Set(nextIds.map((id) => screen.getByTestId(`column-${id}`))),
);
});
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]);
});
});