Files
ui/tests/photo_mode.test.tsx
Oleksandr Bezdieniezhnykh 70fb452805 [AZ-510] Auth bootstrap: POST refresh + chained /users/me
Replace the broken `GET /api/admin/auth/refresh` (no `credentials:'include'`)
mount-time bootstrap with `POST /api/admin/auth/refresh` (with credentials)
chained to `GET /api/admin/users/me`. Returning users with a valid HttpOnly
refresh cookie no longer flash through `/login`. Closes Finding B3 / Vision P3.

- Add module-scoped `bootstrapInflight` guard (StrictMode double-mount safety)
  + test-only reset hook exported via the `src/auth` barrel; `tests/setup.ts`
  resets it in `afterEach` to prevent pending-promise leakage between tests.
- Defensive `hasPermission` against legacy `/users/me` payloads omitting
  `permissions`; default MSW handler now seeds `permissions` explicitly.
- Add `endpoints.admin.usersMe()` builder (STC-ARCH-02 forbids the literal).
- Bulk-swap 15 test files from `http.get` -> `http.post` for the refresh
  override so intentional bootstrap-fail tests still fail correctly.
- Update auth component description; mark B3 closed.
- Code review verdict PASS; static + fast suites green (231 / 13 skipped).

Batch report: _docs/03_implementation/batch_13_cycle3_report.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 02:59:31 +03:00

448 lines
17 KiB
TypeScript

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 `<PhotoModeContext>`;
// 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<typeof vi.fn>
modeSpy: ReturnType<typeof vi.fn>
}
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 (
<DetectionClasses
selectedClassNum={selectedClassNum}
onSelect={(id) => {
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(<HarnessWrapper initialPhotoMode={0} state={state} />)
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(<HarnessWrapper initialPhotoMode={0} state={state} />)
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(
<HarnessWrapper
initialPhotoMode={0}
initialSelectedClassNum={0}
state={state}
/>,
)
// 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(
<HarnessWrapper
initialPhotoMode={0}
initialSelectedClassNum={0}
state={state}
/>,
)
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(
<HarnessWrapper
initialPhotoMode={20}
initialSelectedClassNum={20}
state={state}
/>,
)
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(
<FlightProvider>
<FlightSeed>
<AnnotationsPage />
</FlightSeed>
</FlightProvider>,
)
// 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)
})
})
})