Files
ui/tests/panel_width_persistence.test.tsx
T
Oleksandr Bezdieniezhnykh 23746ec61d [AZ-485] Add Public API barrels + STC-ARCH-01 (F4 close)
Closes architecture baseline finding F4. Every component now exposes
its Public API through `src/<component>/index.ts`; cross-component
imports go through the barrel. `scripts/check-arch-imports.mjs` plus
`STC-ARCH-01` in the static profile enforce the rule; tests in
`tests/architecture_imports.test.ts` cover AC-4/AC-5 + 2 exemption
cases. One F3-pending exemption (`classColors`) is documented in 5
places (barrel, consumer, script, doc, test) to avoid a circular
import.

Phase B cycle 1 batch 1 of 2 (epic AZ-447). Batch 2 is AZ-486
(endpoint builders) — blocked on this commit landing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:33:30 +03:00

253 lines
10 KiB
TypeScript

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'
import { AnnotationsPage } from '../src/features/annotations'
// 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
// `<UserSettings>`'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<string, unknown>
}
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<string, unknown>
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 `<div>` with `cursor-col-resize` — in <AnnotationsPage>
// 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<HTMLElement>('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(
<FlightProvider>
<AnnotationsPage />
</FlightProvider>,
)
// 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(
<FlightProvider>
<AnnotationsPage />
</FlightProvider>,
)
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(
<FlightProvider>
<AnnotationsPage />
</FlightProvider>,
)
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 `<AnnotationsPage>` 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(
<FlightProvider>
<AnnotationsPage />
</FlightProvider>,
)
// 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 `<div>`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<HTMLElement>('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(
<FlightProvider>
<AnnotationsPage />
</FlightProvider>,
)
await waitFor(() => expect(document.querySelector('div.cursor-col-resize')).toBeTruthy())
const panels = document.querySelectorAll<HTMLElement>('div.bg-az-panel.shrink-0')
const [leftPanel, rightPanel] = [panels[0], panels[panels.length - 1]]
// Constructor defaults from `<AnnotationsPage>`: 250 px (left), 200 px (right).
expect(parseFloat(leftPanel.style.width)).toBe(250)
expect(parseFloat(rightPanel.style.width)).toBe(200)
})
})
})