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' // F3-pending exemption: classColors symbols live under 06_annotations until // F3 moves the file. The 06_annotations barrel does not re-export them to // avoid a circular import (see src/features/annotations/index.ts). 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 modeSpy: ReturnType } function HarnessWrapper({ initialPhotoMode = 0, state, }: { initialPhotoMode?: number state: HarnessState }) { return ( { 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() // 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() 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() 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() 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() 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() // 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() // 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() 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) }) }) })