Files
ui/tests/detection_classes.test.tsx
Oleksandr Bezdieniezhnykh c368f60853 [AZ-511] classColors carve-out to src/class-colors/ (closes F3)
Move src/features/annotations/classColors.ts to its own component directory
src/class-colors/ with a proper barrel; update the 4 consumer imports to go
through the barrel; remove the F3-pending exemption from STC-ARCH-01 and from
the architecture test fixture; clean up the 5 coupled doc/script touchpoints.
Closes baseline finding F3 and retires the 5-coupled-places carry-over surface
logged in LESSONS.md 2026-05-12.

- Add `class-colors` to scripts/check-arch-imports.mjs COMPONENT_DIRS so deep
  imports past the new barrel are caught symmetric to every other component.
- Replace the architecture test "exemption WORKS" fixture with the stronger
  "deep import into class-colors NOW FAILS" assertion (Risk 4 mitigation).
- module-layout.md: Layout Rules + Per-Component Mapping (11_class-colors,
  06_annotations, 03_shared-ui) + Verification Needed #1 + shared/class-colors
  block all updated to reflect the new home.
- 11_class-colors/description.md: Caveats §7 + Module Inventory updated.
- architecture_compliance_baseline.md: F3 marked CLOSED with full pre-resolution
  context preserved (mirrors AZ-485/F4 + AZ-486/F7 pattern); F4 carry-forward
  exemption note retired.
- 04_verification_log.md: open questions #1 + #8 marked RESOLVED.
- Build passes with no circular-import warnings (AC-4); fast suite 231/13
  skipped green (AC-5); static profile green (AC-3 — zero exemptions remain).

Batch report: _docs/03_implementation/batch_14_cycle3_report.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 03:08:36 +03:00

290 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import { FALLBACK_CLASS_NAMES } from '../src/class-colors'
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.post('/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)
})
})
})