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(); 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 & { 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
; } function ActivationFixture({ ids, focusedTaskId, layout = "columns", }: { ids: string[]; focusedTaskId?: string; layout?: string; }) { const activation = useThreadColumnActivation(ids, focusedTaskId, layout); return (
{[...activation.preloadTaskIds].join(",")} {[...activation.detailTaskIds].join(",")} {activation.mobileTaskId} {ids.map((id) => ( ))}
); } 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(); 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(); 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(); expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_B); view.rerender(); expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(""); }); it("uses deep-link fallback before geometry is available and clears on desktop", () => { const view = render(); expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_B); responsiveMocks.useResponsiveBreakpoint.mockReturnValue(desktopBreakpoint()); view.rerender(); expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(""); expect(disconnectResize).toHaveBeenCalled(); }); it("coalesces scroll work and cancels pending frames on unmount", () => { const view = render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); observers[0].instance.emit({ target: screen.getByTestId(`column-${TASK_B}`), isIntersecting: true, }); expect(ids(DETAIL_IDS)).toEqual([TASK_B]); view.rerender(); 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(); 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]); }); });