mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 11:51:10 +00:00
6d03643c2c
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>
290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
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)
|
||
})
|
||
})
|
||
})
|