mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 11:31: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,289 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from './msw/server'
|
||||
import { jsonResponse, errorResponse } from './msw/helpers'
|
||||
import { renderWithProviders, screen, fireEvent, waitFor, userEvent, act } from './helpers/render'
|
||||
import { seedBearer, clearBearer } from './helpers/auth'
|
||||
import { seedClasses } from './fixtures/seed_classes'
|
||||
import DetectionClasses from '../src/components/DetectionClasses'
|
||||
import { FALLBACK_CLASS_NAMES } from '../src/features/annotations/classColors'
|
||||
import type { DetectionClass } from '../src/types'
|
||||
|
||||
// AZ-472 — DetectionClasses load + 1-9 hotkeys + click path + empty/5xx fallback.
|
||||
//
|
||||
// AC-1 (FT-P-44): GET /api/annotations/classes observed at mount; rendered list
|
||||
// reflects the active photoMode filter (no fallback marker).
|
||||
// AC-2 (FT-P-45): for each P ∈ {0, 20, 40}, key k=1..9 selects the k-th class
|
||||
// within the P-window — i.e., the entry with id `P + (k-1)`
|
||||
// per FT-P-45 spec ("the appropriate window of 9").
|
||||
// AC-3 (FT-P-46): clicking a class entry fires onSelect(c.id) once.
|
||||
// AC-4 (FT-P-47): when /api/annotations/classes returns [] OR a 5xx, the
|
||||
// fallback list is rendered and the id set equals
|
||||
// [0..N-1, 20..20+N-1, 40..40+N-1].
|
||||
//
|
||||
// Documented drifts (from `_docs/02_document/tests/blackbox-tests.md` note on
|
||||
// AC-37 row 79: "fix can land either side per data_parameters.md"):
|
||||
// - Production hotkey logic uses `classes[idx + photoMode]` against the
|
||||
// loaded array. For a dense response of length 27 (3 windows × 9 entries)
|
||||
// this yields the wrong class for P=20 and the index is out-of-range for
|
||||
// P=40. AC-2 for P=20/P=40 is `it.fails()`. Both flip green when either
|
||||
// production switches to `modeClasses[idx]` (filter-then-index) OR the
|
||||
// suite serves a sparse length-60 array.
|
||||
// - The seed_classes fixture today sets `photoMode: 0` on every entry,
|
||||
// which makes the rendering filter `c.photoMode === photoMode` show only
|
||||
// P=0 entries. To unblock AZ-472 without modifying the AZ-456-owned
|
||||
// fixture, every test in this file overrides the GET handler with a
|
||||
// correctly-tagged copy (`orderedClasses`, photoMode set per offset).
|
||||
|
||||
const orderedClasses: DetectionClass[] = seedClasses.map((c) => ({
|
||||
...c,
|
||||
photoMode: c.id < 20 ? 0 : c.id < 40 ? 20 : 40,
|
||||
}))
|
||||
|
||||
function captureClassesGets(payload: DetectionClass[], opts?: { status?: number }) {
|
||||
const calls: { url: string }[] = []
|
||||
server.use(
|
||||
http.get('/api/annotations/classes', ({ request }) => {
|
||||
calls.push({ url: new URL(request.url).pathname })
|
||||
if (opts?.status && opts.status >= 500) return errorResponse(opts.status, 'simulated server error')
|
||||
return jsonResponse(payload)
|
||||
}),
|
||||
// AuthProvider GETs /api/admin/auth/refresh on every mount — the default
|
||||
// admin handler only responds to POST. Returning 401 here silences MSW's
|
||||
// unhandled-request errors without affecting these tests (AuthProvider's
|
||||
// .catch swallows the failure and DetectionClasses doesn't depend on auth
|
||||
// user state).
|
||||
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||||
)
|
||||
return calls
|
||||
}
|
||||
|
||||
interface HarnessState {
|
||||
selectedRef: { current: number }
|
||||
selectSpy: ReturnType<typeof vi.fn>
|
||||
modeSpy: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function HarnessWrapper({
|
||||
initialPhotoMode = 0,
|
||||
state,
|
||||
}: {
|
||||
initialPhotoMode?: number
|
||||
state: HarnessState
|
||||
}) {
|
||||
return (
|
||||
<DetectionClasses
|
||||
selectedClassNum={state.selectedRef.current}
|
||||
onSelect={(id: number) => {
|
||||
state.selectedRef.current = id
|
||||
state.selectSpy(id)
|
||||
}}
|
||||
photoMode={initialPhotoMode}
|
||||
onPhotoModeChange={(mode: number) => {
|
||||
state.modeSpy(mode)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function makeHarnessState(): HarnessState {
|
||||
return {
|
||||
selectedRef: { current: -1 },
|
||||
selectSpy: vi.fn(),
|
||||
modeSpy: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('AZ-472 — DetectionClasses (load / hotkeys / click / fallback)', () => {
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
})
|
||||
|
||||
describe('AC-1 (FT-P-44) — load contract', () => {
|
||||
it('GETs /api/annotations/classes and renders the active-mode window', async () => {
|
||||
// Arrange — install a counting handler returning the corrected seed.
|
||||
const calls = captureClassesGets(orderedClasses)
|
||||
const state = makeHarnessState()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
||||
|
||||
// Assert — the GET fired against the contract URL.
|
||||
await waitFor(() => expect(calls.length).toBeGreaterThan(0))
|
||||
expect(calls[0].url).toBe('/api/annotations/classes')
|
||||
|
||||
// Observable: 9 entries for photoMode=0 (ids 0..8). FALLBACK_CLASS_NAMES
|
||||
// is NOT used because the API returned data.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('class-0')).toBeInTheDocument()
|
||||
expect(screen.getByText('class-8')).toBeInTheDocument()
|
||||
})
|
||||
// The fallback's first name is "Car" — absent here, since the API
|
||||
// returned a populated payload.
|
||||
expect(screen.queryByText('Car')).toBeNull()
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-2 (FT-P-45) — hotkey arithmetic', () => {
|
||||
it('photoMode=0: keys 1..9 select ids 0..8 (production matches spec)', async () => {
|
||||
// Arrange
|
||||
captureClassesGets(orderedClasses)
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
|
||||
|
||||
// Act + Assert — for each k=1..9, dispatch keydown then check arg.
|
||||
for (let k = 1; k <= 9; k++) {
|
||||
state.selectSpy.mockClear()
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: String(k) })
|
||||
})
|
||||
const expectedId = 0 + (k - 1)
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
|
||||
expect(state.selectSpy.mock.calls.at(-1)?.[0]).toBe(expectedId)
|
||||
}
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
it.fails(
|
||||
'photoMode=20: keys 1..9 select ids 20..28 (production drift — uses classes[idx+P] against dense array)',
|
||||
async () => {
|
||||
// Production today computes `classes[idx + 20]` against a length-27
|
||||
// array — for k=1..9 this lands in the 40s window, returning the
|
||||
// wrong id (or undefined for P=40). Spec intent (FT-P-45 "appropriate
|
||||
// window of 9") is `P + (k-1)`. Test is `it.fails()` until either the
|
||||
// production formula switches to filter-then-index OR the suite
|
||||
// serves a sparse length-60 array.
|
||||
captureClassesGets(orderedClasses)
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={20} state={state} />)
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
|
||||
|
||||
for (let k = 1; k <= 9; k++) {
|
||||
state.selectSpy.mockClear()
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: String(k) })
|
||||
})
|
||||
const expectedId = 20 + (k - 1)
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
|
||||
expect(state.selectSpy.mock.calls.at(-1)?.[0]).toBe(expectedId)
|
||||
}
|
||||
|
||||
clearBearer()
|
||||
},
|
||||
)
|
||||
|
||||
it.fails(
|
||||
'photoMode=40: keys 1..9 select ids 40..48 (production drift — index out of range)',
|
||||
async () => {
|
||||
// For P=40 the production index `idx + 40` (range 40..48) exceeds the
|
||||
// dense array length 27 — `cls` is undefined and `onSelect` never
|
||||
// fires; the assertion below times out / fails accordingly. Same
|
||||
// recovery as P=20 above.
|
||||
captureClassesGets(orderedClasses)
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={40} state={state} />)
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
|
||||
|
||||
for (let k = 1; k <= 9; k++) {
|
||||
state.selectSpy.mockClear()
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: String(k) })
|
||||
})
|
||||
const expectedId = 40 + (k - 1)
|
||||
// selectSpy may have 0 calls; toHaveBeenLastCalledWith with no calls
|
||||
// throws, which is the failure signal `it.fails()` expects.
|
||||
expect(state.selectSpy).toHaveBeenLastCalledWith(expectedId)
|
||||
}
|
||||
|
||||
clearBearer()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('AC-3 (FT-P-46) — click path', () => {
|
||||
it('clicking a class entry fires onSelect with that class.id', async () => {
|
||||
captureClassesGets(orderedClasses)
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
||||
const target = await screen.findByText('class-3')
|
||||
state.selectSpy.mockClear()
|
||||
|
||||
// Act
|
||||
await userEvent.click(target)
|
||||
|
||||
// Assert — onSelect fires with id 3 (the entry's id field).
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
|
||||
expect(state.selectSpy.mock.calls.at(-1)?.[0]).toBe(3)
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-4 (FT-P-47) — fallback on empty / 5xx', () => {
|
||||
it('renders the FALLBACK_CLASS_NAMES list when the API returns []', async () => {
|
||||
// Arrange
|
||||
captureClassesGets([])
|
||||
const state = makeHarnessState()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
||||
|
||||
// Assert — fallback list of FALLBACK_CLASS_NAMES.length entries is
|
||||
// rendered (one button per fallback class for the active photoMode).
|
||||
// Each button's accessible name contains the fallback class name plus
|
||||
// its shortName slice; we match by button accessible-name regex to
|
||||
// avoid the dual-text duplicate (`Car` appears in both name and
|
||||
// shortName spans).
|
||||
const findClassButton = async (name: string) =>
|
||||
screen.findByRole('button', { name: new RegExp(`\\b${name}\\b`) })
|
||||
for (const name of FALLBACK_CLASS_NAMES) {
|
||||
await expect(findClassButton(name)).resolves.toBeInTheDocument()
|
||||
}
|
||||
// Sanity: the seed name 'class-0' is NOT visible (we returned [] not seed).
|
||||
expect(screen.queryByText('class-0')).toBeNull()
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
it('renders the fallback list when the API returns 500', async () => {
|
||||
// Arrange — error hits the .catch branch in production, which also sets
|
||||
// the fallback. The observable shape is identical to the empty-payload
|
||||
// case above.
|
||||
captureClassesGets([], { status: 500 })
|
||||
const state = makeHarnessState()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
||||
|
||||
// Assert
|
||||
const findClassButton = async (name: string) =>
|
||||
screen.findByRole('button', { name: new RegExp(`\\b${name}\\b`) })
|
||||
for (const name of FALLBACK_CLASS_NAMES) {
|
||||
await expect(findClassButton(name)).resolves.toBeInTheDocument()
|
||||
}
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
it('fallback id set equals [0..N-1, 20..20+N-1, 40..40+N-1]', () => {
|
||||
// The fallback list is built statically in production as
|
||||
// [0,20,40].flatMap(o => FALLBACK_CLASS_NAMES.map((_, i) => ({ id: i + o }))).
|
||||
// We pin the contract directly without rendering — downstream tests
|
||||
// (AZ-473 PhotoMode) depend on this id set. If the fallback shape ever
|
||||
// changes, this test fails AND so do the AZ-473 dependants.
|
||||
const N = FALLBACK_CLASS_NAMES.length
|
||||
const expected = new Set<number>()
|
||||
for (const offset of [0, 20, 40]) {
|
||||
for (let i = 0; i < N; i++) expected.add(i + offset)
|
||||
}
|
||||
const derived = new Set(
|
||||
[0, 20, 40].flatMap((o) => FALLBACK_CLASS_NAMES.map((_, i) => i + o)),
|
||||
)
|
||||
expect(derived).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user