[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

- 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:
Oleksandr Bezdieniezhnykh
2026-05-11 04:38:22 +03:00
parent 1dd25edee3
commit 6d03643c2c
15 changed files with 1644 additions and 4 deletions
+289
View File
@@ -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)
})
})
})