mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 09:41:11 +00:00
[AZ-461] [AZ-464] [AZ-470] [AZ-472] Batch 5 - detection/bulk-validate/panel-width/classes tests
ci/woodpecker/push/build-arm Pipeline was successful
ci/woodpecker/push/build-arm Pipeline was successful
- AZ-461 sync image detect URL canary (FT-P-11) PASS;
async-video QUARANTINE (FT-P-12) + X-Refresh-Token drift
(FT-P-13) recorded as it.fails() with controls.
- AZ-464 bulk-validate URL + UI sync (≤2 s) PASS;
body shape drift {annotationIds,status} vs contract
{ids,targetStatus:30} captured as it.fails().
- AZ-470 panel-width debounce + rehydration: entire task
is Phase-B target (useResizablePanel has no PUT writer
/ no rehydration); 3 ACs as it.fails() with controls.
- AZ-472 DetectionClasses load + click + fallback PASS;
hotkey arithmetic P=0 PASS, P=20/P=40 it.fails() for
classes[idx+P]-against-dense-array drift.
Code review: PASS (0 findings). Fast: 18/18 files,
102 passed / 13 skipped. Static: 21/21 PASS.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
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
|
||||
// `<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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user