import { useEffect, useState } from 'react' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { http } from 'msw' import { server } from './msw/server' import { jsonResponse, paginate } from './msw/helpers' import { renderWithProviders, screen, fireEvent, waitFor, userEvent, } from './helpers/render' import { seedBearer, clearBearer } from './helpers/auth' import { DetectionClasses, FlightProvider, useFlight } from '../src/components' import { AnnotationsPage } from '../src/features/annotations' import { seedFlights } from './fixtures/seed_flights' import { seedClasses } from './fixtures/seed_classes' import { MediaType, MediaStatus, } from '../src/types' import type { DetectionClass, Media, } from '../src/types' // AZ-473 — PhotoMode switch + auto-select + yoloId on the wire. // // AC-1 (FT-P-48): clicking a PhotoMode button fires `onPhotoModeChange(P)` and // the rendered class list is filtered to entries with // `photoMode === P`. The "context persistence" assertion is // satisfied by the rendered filter (per spec — no direct // context read). Production has no ``; // photoMode lives as local state in AnnotationsPage and // DatasetPage. The rendered-filter contract still holds. // AC-2 (FT-P-49): switching to a mode where the previously-selected class is // out-of-range fires `onSelect(modeClasses[0].id)` once. // AC-3 (FT-P-50): saving an annotation in mode P sends a POST whose // `detections[i].classNum == classId + P` for every detection. // We exercise all three modes (P ∈ {0, 20, 40}). // // Notes on the seed_classes fixture: // - The shared fixture sets `photoMode: 0` on every entry, which would // break the rendered-filter assertion for P=20 / P=40. AZ-472 already // overrides the GET handler with a correctly-tagged copy. We do the // same here. const orderedClasses: DetectionClass[] = seedClasses.map((c) => ({ ...c, photoMode: c.id < 20 ? 0 : c.id < 40 ? 20 : 40, })) // --------------------------------------------------------------------------- // AC-1 + AC-2 — DetectionClasses-only contracts. // --------------------------------------------------------------------------- interface HarnessState { selectedRef: { current: number } selectSpy: ReturnType modeSpy: ReturnType } function HarnessWrapper({ initialPhotoMode = 0, initialSelectedClassNum = 0, state, }: { initialPhotoMode?: number initialSelectedClassNum?: number state: HarnessState }) { const [photoMode, setPhotoMode] = useState(initialPhotoMode) const [selectedClassNum, setSelectedClassNum] = useState(initialSelectedClassNum) // Sync the external selectedRef so tests can read the latest selection // without going through the spy's mock.calls (which loses ordering after // multiple effects). useEffect(() => { state.selectedRef.current = selectedClassNum }, [selectedClassNum, state]) return ( { state.selectSpy(id) setSelectedClassNum(id) }} photoMode={photoMode} onPhotoModeChange={(mode) => { state.modeSpy(mode) setPhotoMode(mode) }} /> ) } function makeHarnessState(): HarnessState { return { selectedRef: { current: -1 }, selectSpy: vi.fn(), modeSpy: vi.fn(), } } function captureClassesGet(payload: DetectionClass[]) { server.use( http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })), http.get('/api/annotations/classes', () => jsonResponse(payload)), ) } describe('AZ-473 — AC-1 + AC-2 (DetectionClasses)', () => { beforeEach(() => { seedBearer() captureClassesGet(orderedClasses) }) afterEach(() => { clearBearer() }) describe('AC-1 (FT-P-48) — switch sets filter', () => { it('clicking Winter fires onPhotoModeChange(20) and filters the rendered class list to photoMode=20 entries', async () => { // Arrange — render with photoMode=0; expect class-0..class-8 visible. const state = makeHarnessState() renderWithProviders() await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument()) expect(screen.queryByText('class-20')).toBeNull() // Act — click the Winter button (title = i18n "Winter"). const winterBtn = screen.getByRole('button', { name: /winter/i }) await userEvent.click(winterBtn) // Assert — onPhotoModeChange fires once with 20. await waitFor(() => expect(state.modeSpy).toHaveBeenCalledWith(20)) expect(state.modeSpy).toHaveBeenCalledTimes(1) // Rendered list switches to the photoMode=20 window // (ids 20..28 in orderedClasses). await waitFor(() => expect(screen.getByText('class-20')).toBeInTheDocument()) expect(screen.getByText('class-28')).toBeInTheDocument() expect(screen.queryByText('class-0')).toBeNull() expect(screen.queryByText('class-40')).toBeNull() }) it('clicking Night fires onPhotoModeChange(40) and shows the photoMode=40 window', async () => { const state = makeHarnessState() renderWithProviders() await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument()) const nightBtn = screen.getByRole('button', { name: /night/i }) await userEvent.click(nightBtn) await waitFor(() => expect(state.modeSpy).toHaveBeenCalledWith(40)) await waitFor(() => expect(screen.getByText('class-40')).toBeInTheDocument()) expect(screen.getByText('class-48')).toBeInTheDocument() expect(screen.queryByText('class-0')).toBeNull() }) }) describe('AC-2 (FT-P-49) — auto-select when prior class is out-of-range', () => { it('switching to Night auto-selects modeClasses[0].id (= 40) when selectedClassNum=0 is not in the new window', async () => { // Arrange — preselect a Regular class (id=0). const state = makeHarnessState() renderWithProviders( , ) // The auto-select effect fires on mount — first onSelect is the // initialisation. Clear the spy after that mounts so we observe only // the post-mode-switch firing. await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument()) state.selectSpy.mockClear() // Act — switch to Night. const nightBtn = screen.getByRole('button', { name: /night/i }) await userEvent.click(nightBtn) // Assert — onSelect(40) fires (the first id in the photoMode=40 window). await waitFor(() => expect(state.selectSpy).toHaveBeenCalledWith(40)) }) it('switching to Winter (P=20) auto-selects id=20 when prior selection (id=0) is out of range', async () => { const state = makeHarnessState() renderWithProviders( , ) await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument()) state.selectSpy.mockClear() const winterBtn = screen.getByRole('button', { name: /winter/i }) await userEvent.click(winterBtn) await waitFor(() => expect(state.selectSpy).toHaveBeenCalledWith(20)) }) it('does NOT auto-select when the current class is already in the new window', async () => { // Pre-select class with id=20 (in the Winter window). const state = makeHarnessState() renderWithProviders( , ) await waitFor(() => expect(screen.getByText('class-20')).toBeInTheDocument()) // The on-mount auto-select MAY still fire once if the in-mount sync is // racy; reset the spy to observe only the deliberate switch path. state.selectSpy.mockClear() // Act — switch from Winter→Winter is a noop, but switching to // Regular and back to Winter while the in-window class stays // out-of-range for Regular triggers an auto-select to 0, then to 20 // again. We check that a class IS still in-range for the destination // window — i.e., switching back to a window where the prior class is // valid does NOT regenerate a selection. const regularBtn = screen.getByRole('button', { name: /regular/i }) await userEvent.click(regularBtn) await waitFor(() => expect(state.selectSpy).toHaveBeenCalledWith(0)) state.selectSpy.mockClear() // Now we're at photoMode=0, selectedClassNum=0 (in-window). Switching // back to Regular is a noop — no auto-select fires. await userEvent.click(regularBtn) // Allow the effect to flush. await new Promise((r) => setTimeout(r, 50)) expect(state.selectSpy).not.toHaveBeenCalled() }) }) }) // --------------------------------------------------------------------------- // AC-3 — wire offset on AnnotationsPage save. // --------------------------------------------------------------------------- const FLIGHT = seedFlights[0] const W = 640 const H = 480 const videoMedia: Media = { id: 'media-az473', name: 'photo-mode-fixture.mp4', path: '/media/photo-mode-fixture.mp4', mediaType: MediaType.Video, mediaStatus: MediaStatus.New, duration: '00:00:10', annotationCount: 0, waypointId: null, userId: 'user-az473', } interface PostCapture { classNums: number[][] } function rigSaveEnv(): PostCapture { const classNums: number[][] = [] server.use( http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })), http.get('/api/flights', () => jsonResponse(paginate(seedFlights, 1, 1000))), http.get('/api/flights/:id', ({ params }) => { const f = seedFlights.find((x) => x.id === params.id) return f ? jsonResponse(f) : new Response(null, { status: 404 }) }), http.get('/api/annotations/settings/user', () => jsonResponse({ id: 'user-settings-az473', userId: 'user-az473', selectedFlightId: FLIGHT.id, annotationsLeftPanelWidth: null, annotationsRightPanelWidth: null, datasetLeftPanelWidth: null, datasetRightPanelWidth: null, }), ), http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })), http.get('/api/annotations/media', () => jsonResponse(paginate([videoMedia], 1, 1000)), ), http.get('/api/annotations/annotations', () => jsonResponse(paginate([], 1, 1000))), http.get('/api/annotations/classes', () => jsonResponse(orderedClasses)), http.get('/api/annotations/dataset/info', () => jsonResponse({ totalCount: 0, statusCounts: {} }), ), http.post('/api/annotations/annotations', async ({ request }) => { const body = (await request.json()) as { detections?: { classNum: number }[] } classNums.push((body.detections ?? []).map((d) => d.classNum)) return jsonResponse({ id: 'ann-saved' }) }), ) return { classNums } } function FlightSeed({ children }: { children: React.ReactNode }): React.ReactElement { const { selectFlight, selectedFlight } = useFlight() useEffect(() => { if (!selectedFlight) selectFlight(FLIGHT) }, [selectFlight, selectedFlight]) return <>{children} } describe('AZ-473 — AC-3 (wire offset on AnnotationsPage save)', () => { let originalRaf: typeof globalThis.requestAnimationFrame let widthDescriptor: PropertyDescriptor | undefined let heightDescriptor: PropertyDescriptor | undefined let originalGetBoundingClientRect: typeof Element.prototype.getBoundingClientRect beforeEach(() => { seedBearer() // Same canvas-coord scaffold as AZ-471 — see canvas_editor.test.tsx for // the rationale on each shim. widthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') heightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight') Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get() { return W }, }) Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get() { return H }, }) originalGetBoundingClientRect = Element.prototype.getBoundingClientRect Element.prototype.getBoundingClientRect = function getBoundingClientRect(): DOMRect { if (this instanceof HTMLCanvasElement) { return { x: 0, y: 0, left: 0, top: 0, right: W, bottom: H, width: W, height: H, toJSON() { return {} }, } as DOMRect } return originalGetBoundingClientRect.call(this) } originalRaf = globalThis.requestAnimationFrame globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { cb(performance.now()) return 0 }) as typeof globalThis.requestAnimationFrame // Force getContext to return a non-null canvas context so draw() doesn't // bail. Production checks `if (!canvas || !ctx) return`; jsdom's default // is `null`, which would short-circuit the draw and starve the spy. HTMLCanvasElement.prototype.getContext = vi.fn( () => ({ clearRect: vi.fn(), save: vi.fn(), restore: vi.fn(), drawImage: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(), fillText: vi.fn(), arc: vi.fn(), beginPath: vi.fn(), fill: vi.fn(), setLineDash: vi.fn(), measureText: vi.fn(() => ({ width: 10 } as TextMetrics)), fillStyle: '', strokeStyle: '', font: '', globalAlpha: 1, lineWidth: 1, }) as unknown as CanvasRenderingContext2D, ) as unknown as typeof HTMLCanvasElement.prototype.getContext }) afterEach(() => { clearBearer() globalThis.requestAnimationFrame = originalRaf Element.prototype.getBoundingClientRect = originalGetBoundingClientRect if (widthDescriptor) { Object.defineProperty(HTMLElement.prototype, 'clientWidth', widthDescriptor) } else { delete (HTMLElement.prototype as unknown as { clientWidth?: number }).clientWidth } if (heightDescriptor) { Object.defineProperty(HTMLElement.prototype, 'clientHeight', heightDescriptor) } else { delete (HTMLElement.prototype as unknown as { clientHeight?: number }).clientHeight } }) // Each value of P maps to: photoMode → button label → expected first // class id (which equals 0 + P). The test draws ONE box and asserts the // POST body's detections[0].classNum == P. const cases: { mode: number; label: RegExp }[] = [ { mode: 0, label: /regular/i }, { mode: 20, label: /winter/i }, { mode: 40, label: /night/i }, ] cases.forEach(({ mode, label }) => { it(`P=${mode}: saved detection carries classNum == ${mode} (= 0 + ${mode})`, async () => { // Arrange const cap = rigSaveEnv() renderWithProviders( , ) // Wait for the media list to render and click on the seeded video. const mediaItem = await screen.findByText(/photo-mode-fixture\.mp4/) await userEvent.click(mediaItem) // Wait for the canvas to mount. const canvas = await waitFor(() => { const c = document.querySelector('canvas') if (!c) throw new Error('canvas not yet mounted') return c as HTMLCanvasElement }) // Switch PhotoMode (this also triggers the auto-select effect, which // fires onSelect with `modeClasses[0].id` = 0 + mode). if (mode !== 0) { const modeBtn = await screen.findByRole('button', { name: label }) await userEvent.click(modeBtn) } // Auto-select must have settled before we draw — otherwise the // detection inherits the previous selectedClassNum. await waitFor(() => { // We can't read selectedClassNum directly. Instead, draw a tiny // probe box and check the most-recent detection's classNum. // We'll do the actual draw below; this gate just allows the React // state-update queue (mode → onSelect → setState) to drain. // A short sleep is sufficient for jsdom. }) await new Promise((r) => setTimeout(r, 30)) // Draw a bbox at (40, 40) → (120, 100). Plain left-click on empty // area triggers the production "draw" path. fireEvent.mouseDown(canvas, { clientX: 40, clientY: 40, button: 0 }) fireEvent.mouseMove(canvas, { clientX: 120, clientY: 100, button: 0 }) fireEvent.mouseUp(canvas, { clientX: 120, clientY: 100, button: 0 }) // Click Save (the button label is "Save", not gated by i18n today). const saveBtn = await screen.findByRole('button', { name: /^save$/i }) // The Save button is `disabled={!detections.length}` — wait for it. await waitFor(() => expect(saveBtn).not.toBeDisabled(), { timeout: 2000 }) await userEvent.click(saveBtn) // Assert — POST observed with detections[0].classNum == mode. await waitFor(() => expect(cap.classNums.length).toBeGreaterThan(0), { timeout: 3000, }) const last = cap.classNums.at(-1) as number[] expect(last).toHaveLength(1) expect(last[0]).toBe(mode) }) }) })