mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 13:41:12 +00:00
23746ec61d
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>
253 lines
10 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|