import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { http } from 'msw' import { server } from './msw/server' import { jsonResponse, paginate } from './msw/helpers' import { renderWithProviders, screen, fireEvent, waitFor, act } from './helpers/render' import { seedBearer, clearBearer } from './helpers/auth' import { FlightProvider } from '../src/components/FlightContext' import AnnotationsPage from '../src/features/annotations/AnnotationsPage' // AZ-470 — Panel-width debounced PUT + rehydration. // // AC-1 (FT-P-37 + NFT-PERF-08): multiple resize events within 1 s yield // exactly ONE outbound PUT (debounce window). // AC-2 (FT-P-37 body): the PUT body carries the `panelWidths` key. // AC-3 (FT-P-38): after reload with `seed_user_settings.panelWidths` // set, the rendered panel widths match the seed. // // Documented drift (entire task is a Phase-B-target group): // `useResizablePanel` today (`src/hooks/useResizablePanel.ts`) only // manages local state — no `useDebounce`-driven PUT on resize-end, no // rehydration from `/api/annotations/settings/user`. All three ACs are // `it.fails()`. They flip green when `useResizablePanel` is wired to // ``'s save path. // // Each `it.fails()` is paired with a control that pins the CURRENT (no-PUT, // no-rehydration) behavior so a regression that, e.g., starts emitting // duplicate PUTs is visible even before the AC flips green. const SEED_LEFT = 280 const SEED_RIGHT = 320 interface CapturedPut { url: string pathname: string body: Record } interface PanelRig { puts: CapturedPut[] divider: () => HTMLElement } function rigPanelEnv(opts?: { seedSettings?: boolean }): { puts: CapturedPut[] } { const puts: CapturedPut[] = [] server.use( http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })), http.get('/api/flights', () => jsonResponse(paginate([], 1, 1000))), // The user settings GET — when seedSettings is true, return a payload // that includes both the legacy per-page width fields AND a `panelWidths` // object as defined by the FT-P-37/38 contract. Production today does // not consume either, but a future rehydration implementation could read // either shape; AC-3 asserts the rendered widths equal the seed values // regardless of which shape carries them. http.get('/api/annotations/settings/user', () => { if (opts?.seedSettings) { return jsonResponse({ id: 'user-settings-az470', userId: 'user-az470', selectedFlightId: null, annotationsLeftPanelWidth: SEED_LEFT, annotationsRightPanelWidth: SEED_RIGHT, datasetLeftPanelWidth: null, datasetRightPanelWidth: null, panelWidths: { annotationsLeft: SEED_LEFT, annotationsRight: SEED_RIGHT, }, }) } return new Response(null, { status: 404 }) }), http.put('/api/annotations/settings/user', async ({ request }) => { const body = (await request.json()) as Record puts.push({ url: request.url, pathname: new URL(request.url).pathname, body, }) return jsonResponse({ id: 'user-settings-az470', ...body }) }), http.get('/api/annotations/media', () => jsonResponse(paginate([], 1, 1000))), http.get('/api/annotations/annotations', () => jsonResponse(paginate([], 1, 1000))), http.get('/api/annotations/classes', () => jsonResponse([])), ) return { puts } } function findDivider(): HTMLElement { // The divider is the `
` with `cursor-col-resize` — in // there are two: between left panel ↔ center, and center ↔ right panel. // We use the first one for AC-1 / AC-2 (the left divider). const dividers = document.querySelectorAll('div.cursor-col-resize') if (!dividers.length) throw new Error('No resizable divider found in DOM') return dividers[0] } function simulateDrag(divider: HTMLElement, dx: number): void { // Production's `useResizablePanel.onMouseDown` sets `dragging.current=true` // and snapshots `clientX`. The window-level `mousemove` handler updates // width, the window-level `mouseup` handler clears `dragging.current`. fireEvent.mouseDown(divider, { clientX: 100 }) fireEvent.mouseMove(window, { clientX: 100 + dx }) fireEvent.mouseUp(window, { clientX: 100 + dx }) } describe('AZ-470 — panel-width debounced PUT + rehydration', () => { beforeEach(() => { seedBearer() vi.useFakeTimers({ shouldAdvanceTime: true }) }) afterEach(() => { vi.useRealTimers() clearBearer() }) describe('AC-1 (FT-P-37 + NFT-PERF-08) — debounce window', () => { it.fails( 'multiple resize events within 1 s yield exactly ONE outbound PUT (drift — production never PUTs)', async () => { // Production today emits ZERO PUTs during a resize because // `useResizablePanel` has no settings writer. The assertion below // expects exactly one PUT and therefore fails until Phase B lands the // writer. When the writer arrives, this test flips green automatically. const { puts } = rigPanelEnv() renderWithProviders( , ) // Wait for the page to render and the divider to appear. await waitFor(() => expect(document.querySelector('div.cursor-col-resize')).toBeTruthy()) // Act — three back-to-back drag-ends (200 ms apart) within the 1 s // debounce window. const divider = findDivider() await act(async () => { simulateDrag(divider, 30) vi.advanceTimersByTime(200) simulateDrag(divider, 50) vi.advanceTimersByTime(200) simulateDrag(divider, 70) // Push past the debounce ceiling so any debounced PUT has had a // chance to fire. vi.advanceTimersByTime(1100) }) // Assert — exactly one PUT against the user-settings endpoint. await waitFor(() => expect(puts).toHaveLength(1), { timeout: 1000 }) expect(puts[0].pathname).toBe('/api/annotations/settings/user') }, ) it('control: production emits ZERO PUTs during a resize today', async () => { // Pin the current (no-writer) behavior so a regression that, e.g., // starts firing on every mousemove is visible immediately. const { puts } = rigPanelEnv() renderWithProviders( , ) await waitFor(() => expect(document.querySelector('div.cursor-col-resize')).toBeTruthy()) const divider = findDivider() await act(async () => { simulateDrag(divider, 50) vi.advanceTimersByTime(2000) }) // No writer wired in production → zero PUTs is the pinned drift. expect(puts).toHaveLength(0) }) }) describe('AC-2 (FT-P-37) — PUT body carries `panelWidths` field', () => { it.fails( 'the captured PUT body carries the `panelWidths` field per contract', async () => { // Same drift as AC-1: production never PUTs, so `puts[0].body` does // not exist and the property assertion below throws. The test flips // green when (a) production starts PUTting AND (b) the body contains // `panelWidths`. const { puts } = rigPanelEnv() renderWithProviders( , ) await waitFor(() => expect(document.querySelector('div.cursor-col-resize')).toBeTruthy()) const divider = findDivider() await act(async () => { simulateDrag(divider, 40) vi.advanceTimersByTime(1100) }) await waitFor(() => expect(puts).toHaveLength(1), { timeout: 1000 }) expect(puts[0].body).toHaveProperty('panelWidths') }, ) }) describe('AC-3 (FT-P-38) — rehydration on reload', () => { it.fails( 'after boot with a seeded `UserSettings.panelWidths`, the rendered widths match the seed', async () => { // Production's `` calls `useResizablePanel(250, ...)` // and `useResizablePanel(200, ...)` — the constructor args are the // ONLY width seed. There is no `useEffect` that reads // `/api/annotations/settings/user` and calls `setWidth(seed)`. With // the seed at 280 / 320, the rendered widths therefore stay 250 / 200 // until Phase B wires the rehydration. rigPanelEnv({ seedSettings: true }) renderWithProviders( , ) // Wait for the page to settle (auth refresh + flights bootstrap). await waitFor(() => expect(document.querySelector('div.cursor-col-resize')).toBeTruthy()) // Read the live `style.width` of each panel container. The two // outer panel `
`s sit on either side of the dividers; we // identify them by their distinctive `flex flex-col shrink-0` // class chain. const panels = document.querySelectorAll('div.bg-az-panel.shrink-0') expect(panels.length).toBeGreaterThanOrEqual(2) const [leftPanel, rightPanel] = [panels[0], panels[panels.length - 1]] // Spec: widths equal seed within ±1 px. const leftWidth = parseFloat(leftPanel.style.width) const rightWidth = parseFloat(rightPanel.style.width) expect(Math.abs(leftWidth - SEED_LEFT)).toBeLessThanOrEqual(1) expect(Math.abs(rightWidth - SEED_RIGHT)).toBeLessThanOrEqual(1) }, ) it('control: production renders panels at constructor-arg defaults (250 / 200) ignoring seeded settings', async () => { rigPanelEnv({ seedSettings: true }) renderWithProviders( , ) await waitFor(() => expect(document.querySelector('div.cursor-col-resize')).toBeTruthy()) const panels = document.querySelectorAll('div.bg-az-panel.shrink-0') const [leftPanel, rightPanel] = [panels[0], panels[panels.length - 1]] // Constructor defaults from ``: 250 px (left), 200 px (right). expect(parseFloat(leftPanel.style.width)).toBe(250) expect(parseFloat(rightPanel.style.width)).toBe(200) }) }) })