[AZ-471] [AZ-473] [AZ-478] [AZ-479] Batch 7 - canvas/photo-mode/network/perf tests
ci/woodpecker/push/build-arm Pipeline was successful

- AZ-471 CanvasEditor draw + 8-handle resize PASS (FT-P-39 fast +
  e2e + FT-P-40 8 sub-tests). Three drifts pinned via it.fails():
  Ctrl+click multi-select (FT-P-41), Ctrl+wheel zoom-around-cursor
  (FT-P-42), Ctrl+drag empty-canvas pan (FT-P-43) — all rooted in
  handleMouseDown's early Ctrl-gate and handleWheel's
  pan-not-adjusted bug.
- AZ-473 PhotoMode 3 ACs all PASS in fast + e2e (FT-P-48 switch
  filter, FT-P-49 auto-select, FT-P-50 yoloId wire across modes
  P=0/20/40 — outbound classNum == classId + photoModeOffset).
- AZ-478 fast 7 + e2e 2: AC-1 user-visible offline indicator,
  AC-2 tainted-canvas fallback, AC-3 SSE disconnect banner —
  all drift today (it.fails fast + test.fail e2e + control
  PASS for each). Service-worker negative check passes.
- AZ-479 AC-1 (bundle <= 2 MB gzipped) promoted from
  on-demand perf script to per-commit static profile via new
  STC-PERF01 row + static_check_bundle_size in run-tests.sh.
  AC-2 (mission-planner exclusion) already covered by STC-S5.
  AC-3 FCP /flights <= 3 s median (chromium suite-e2e) and
  AC-4 30-min annotation soak (RUN_LONG_RUNNING=1, chromium)
  scaffolded as e2e tests.

Code review: PASS (0 findings). Fast: 25/25 files, 150 passed
/ 13 skipped. Static: 25/25 PASS (incl. new STC-PERF01).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 05:58:55 +03:00
parent 73e2cfb1eb
commit cdebfccada
16 changed files with 2422 additions and 1 deletions
+604
View File
@@ -0,0 +1,604 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http } from 'msw'
import { server } from './msw/server'
import { renderWithProviders, fireEvent, waitFor } from './helpers/render'
import CanvasEditor from '../src/features/annotations/CanvasEditor'
import {
Affiliation,
CombatReadiness,
MediaType,
MediaStatus,
} from '../src/types'
import type { Media, Detection } from '../src/types'
// AZ-471 — CanvasEditor: manual draw + 8-handle resize + Ctrl+click multi-select
// + Ctrl+wheel zoom-around-cursor + Ctrl+drag pan.
//
// AC-1 (FT-P-39): manual bbox draw — pointer drag commits a normalised
// detection within ±0.5 px of the drawn rect.
// AC-2 (FT-P-40): each of the 8 resize handles moves only its adjacent edges;
// the opposite anchor (corner / midpoint) is invariant.
// AC-3 (FT-P-41): Ctrl+click on a second bbox toggles it INTO the selection
// set; selection ring is rendered for both.
// AC-4 (FT-P-42): Ctrl+wheel at (cx, cy) keeps the canvas pixel under the
// cursor invariant (within ±0.5 px) before/after zoom.
// AC-5 (FT-P-43): Ctrl+drag on empty canvas pans the viewport by (dx, dy);
// detection canvas-coords are unchanged.
//
// Documented production drifts (`src/features/annotations/CanvasEditor.tsx`):
// * AC-3: `handleMouseDown` returns early on `e.ctrlKey && e.button === 0`
// (the "draw" gate), making the Ctrl+click multi-select branch
// unreachable. `it.fails()` until the Ctrl-handler order is fixed.
// * AC-4: `handleWheel` updates `zoom` only — `pan` is not adjusted, so
// the pixel under the cursor shifts by `Δzoom · cursor_offset`.
// `it.fails()` until pan is corrected on every wheel.
// * AC-5: same Ctrl+button-0 early-return as AC-3 — Ctrl+drag enters
// "draw" mode, not "pan", so a bounding-box gets created and pan
// never moves. `it.fails()` until empty-canvas pan is wired.
//
// Render strategy — production CanvasEditor depends on:
// 1. `containerRef.current.clientWidth/Height` to drive `imgSize` (video path).
// 2. `canvas.getBoundingClientRect()` to translate `e.clientX/Y` → canvas px.
// 3. `requestAnimationFrame` to run `draw()` after state updates.
// Each is stubbed at the prototype level so the math is deterministic.
const W = 640
const H = 480
const videoMedia: Media = {
id: 'media-az471',
name: 'canvas-fixture.mp4',
path: '/media/canvas-fixture.mp4',
mediaType: MediaType.Video,
mediaStatus: MediaStatus.New,
duration: '00:00:10',
annotationCount: 0,
waypointId: null,
userId: 'user-az471',
}
interface CanvasSpy {
strokeRectCalls: { x: number; y: number; w: number; h: number; lineWidth: number }[]
reset(): void
}
function installCanvasSpy(): CanvasSpy {
const state: CanvasSpy = {
strokeRectCalls: [],
reset() {
this.strokeRectCalls = []
},
}
// The stub is a closure over `lineWidth` — ctx setters in production assign
// `ctx.lineWidth = isSelected ? 2 : 1` before `strokeRect`, so we capture
// the line width at the moment of each stroke. This lets AC-3 distinguish
// "selected" boxes (lineWidth=2) from "unselected" (lineWidth=1) without
// also counting handle outlines or label fills.
let currentLineWidth = 1
const stub = {
clearRect: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn((x: number, y: number, w: number, h: number) => {
state.strokeRectCalls.push({ x, y, w, h, lineWidth: currentLineWidth })
}),
fillText: vi.fn(),
measureText: vi.fn(() => ({ width: 10 } as TextMetrics)),
arc: vi.fn(),
beginPath: vi.fn(),
fill: vi.fn(),
setLineDash: vi.fn(),
fillStyle: '',
strokeStyle: '',
font: '',
globalAlpha: 1,
}
Object.defineProperty(stub, 'lineWidth', {
get() { return currentLineWidth },
set(v: number) { currentLineWidth = v },
})
HTMLCanvasElement.prototype.getContext = vi.fn(
() => stub as unknown as CanvasRenderingContext2D,
) as unknown as typeof HTMLCanvasElement.prototype.getContext
return state
}
function makeDetection(
centerX: number,
centerY: number,
width: number,
height: number,
classNum = 0,
): Detection {
return {
id: `det-${classNum}-${centerX}-${centerY}`,
classNum,
label: '',
confidence: 1,
affiliation: Affiliation.Hostile,
combatReadiness: CombatReadiness.NotReady,
centerX,
centerY,
width,
height,
}
}
interface Harness {
spy: CanvasSpy
changes: Detection[][]
rerenderWithDetections(dets: Detection[]): void
unmount(): void
getCanvas(): HTMLCanvasElement
}
function renderHarness(initialDetections: Detection[] = []): Harness {
const spy = installCanvasSpy()
const changes: Detection[][] = []
let currentDets = initialDetections
const onChange = (next: Detection[]) => {
currentDets = next
changes.push(next)
}
const { rerender, unmount, container } = renderWithProviders(
<CanvasEditor
media={videoMedia}
annotation={null}
detections={currentDets}
onDetectionsChange={onChange}
selectedClassNum={0}
currentTime={0}
annotations={[]}
/>,
)
function rerenderWithDetections(dets: Detection[]): void {
currentDets = dets
rerender(
<CanvasEditor
media={videoMedia}
annotation={null}
detections={currentDets}
onDetectionsChange={onChange}
selectedClassNum={0}
currentTime={0}
annotations={[]}
/>,
)
}
const canvas = container.querySelector('canvas') as HTMLCanvasElement
if (!canvas) throw new Error('canvas not mounted')
return { spy, changes, rerenderWithDetections, unmount, getCanvas: () => canvas }
}
describe('AZ-471 — CanvasEditor (draw / resize / multi-select / zoom / pan)', () => {
let originalRaf: typeof globalThis.requestAnimationFrame
let widthDescriptor: PropertyDescriptor | undefined
let heightDescriptor: PropertyDescriptor | undefined
let originalGetBoundingClientRect: typeof Element.prototype.getBoundingClientRect
beforeEach(() => {
// The default `renderWithProviders` mounts AuthProvider which fires
// GET /api/admin/auth/refresh. CanvasEditor doesn't care about auth, but
// an unhandled request triggers MSW's onUnhandledRequest:'error'. A 401
// here keeps AuthProvider's `.catch` quiet (loading flips to false) and
// satisfies AC-3 of AZ-456.
server.use(http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })))
// Force the container's clientWidth/Height (jsdom default = 0) so the
// CanvasEditor's `useEffect(isVideo)` populates `imgSize` to 640×480.
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 },
})
// Pin the canvas's bounding rect so mouse events translate cleanly:
// mx = e.clientX - rect.left
// my = e.clientY - rect.top
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)
}
// Synchronous RAF — `draw()` runs in the same tick as the state update,
// so strokeRect spies see the result before the assertion fires.
originalRaf = globalThis.requestAnimationFrame
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
cb(performance.now())
return 0
}) as typeof globalThis.requestAnimationFrame
})
afterEach(() => {
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
}
})
describe('AC-1 (FT-P-39) — manual draw geometry', () => {
it('draws a bbox from (x1,y1)→(x2,y2); detection.x,y,w,h match within ±0.5 px', async () => {
// Arrange — empty canvas, no prior detections.
const h = renderHarness([])
const canvas = h.getCanvas()
// Act — mousedown(40, 40) → mousemove(120, 100) → mouseup. Plain
// left-click on empty area drives 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 })
// Assert — exactly one detection appended.
await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
const finalDets = h.changes.at(-1) ?? []
expect(finalDets).toHaveLength(1)
// Geometry — drawn rect width=80px, height=60px on a 640×480 canvas.
// Normalised: width=80/640=0.125, height=60/480=0.125, centerX=80/640=0.125,
// centerY=70/480≈0.1458 (midpoint of 40..100). Tolerance: ±0.5 px ⇒
// ±(0.5/640) on x and ±(0.5/480) on y.
const det = finalDets[0]
const xTol = 0.5 / W
const yTol = 0.5 / H
expect(det.width).toBeCloseTo(80 / W, 6)
expect(det.height).toBeCloseTo(60 / H, 6)
expect(Math.abs(det.centerX - 80 / W)).toBeLessThanOrEqual(xTol)
expect(Math.abs(det.centerY - 70 / H)).toBeLessThanOrEqual(yTol)
expect(det.classNum).toBe(0)
})
})
describe('AC-2 (FT-P-40) — 8-handle resize keeps the opposite anchor invariant', () => {
// Pre-existing bbox: centered at (0.5, 0.5), width/height 0.4 each.
// In canvas pixels (zoom=1, pan=(0,0), 640×480):
// x1 = 0.3 * 640 = 192 y1 = 0.3 * 480 = 144
// x2 = 0.7 * 640 = 448 y2 = 0.7 * 480 = 336
const initialBox = (): Detection[] => [makeDetection(0.5, 0.5, 0.4, 0.4)]
function selectFirstBox(h: Harness): void {
// Click inside the box (not Ctrl, no handle) selects index 0.
const canvas = h.getCanvas()
fireEvent.mouseDown(canvas, { clientX: 320, clientY: 240, button: 0 })
fireEvent.mouseUp(canvas, { clientX: 320, clientY: 240, button: 0 })
}
function dragHandle(
h: Harness,
from: { x: number; y: number },
to: { x: number; y: number },
): void {
const canvas = h.getCanvas()
fireEvent.mouseDown(canvas, { clientX: from.x, clientY: from.y, button: 0 })
fireEvent.mouseMove(canvas, { clientX: to.x, clientY: to.y, button: 0 })
fireEvent.mouseUp(canvas, { clientX: to.x, clientY: to.y, button: 0 })
}
function lastDet(h: Harness): Detection {
return (h.changes.at(-1) as Detection[])[0]
}
it('top-left handle: bottom-right corner is invariant', async () => {
const h = renderHarness(initialBox())
selectFirstBox(h)
// Drag TL handle from (192, 144) to (160, 120).
dragHandle(h, { x: 192, y: 144 }, { x: 160, y: 120 })
await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
const det = lastDet(h)
// BR corner = centerX + width/2, centerY + height/2 must equal old BR (0.7, 0.7).
expect(det.centerX + det.width / 2).toBeCloseTo(0.7, 5)
expect(det.centerY + det.height / 2).toBeCloseTo(0.7, 5)
// TL corner moved to normalised (160/640, 120/480) = (0.25, 0.25).
expect(det.centerX - det.width / 2).toBeCloseTo(160 / W, 5)
expect(det.centerY - det.height / 2).toBeCloseTo(120 / H, 5)
})
it('top-right handle: bottom-left corner is invariant', async () => {
const h = renderHarness(initialBox())
selectFirstBox(h)
dragHandle(h, { x: 448, y: 144 }, { x: 480, y: 120 })
await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
const det = lastDet(h)
// BL corner stays at (0.3, 0.7).
expect(det.centerX - det.width / 2).toBeCloseTo(0.3, 5)
expect(det.centerY + det.height / 2).toBeCloseTo(0.7, 5)
})
it('bottom-left handle: top-right corner is invariant', async () => {
const h = renderHarness(initialBox())
selectFirstBox(h)
dragHandle(h, { x: 192, y: 336 }, { x: 160, y: 360 })
await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
const det = lastDet(h)
expect(det.centerX + det.width / 2).toBeCloseTo(0.7, 5)
expect(det.centerY - det.height / 2).toBeCloseTo(0.3, 5)
})
it('bottom-right handle: top-left corner is invariant', async () => {
const h = renderHarness(initialBox())
selectFirstBox(h)
dragHandle(h, { x: 448, y: 336 }, { x: 500, y: 380 })
await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
const det = lastDet(h)
expect(det.centerX - det.width / 2).toBeCloseTo(0.3, 5)
expect(det.centerY - det.height / 2).toBeCloseTo(0.3, 5)
})
it('top-edge midpoint: bottom edge y is invariant', async () => {
const h = renderHarness(initialBox())
selectFirstBox(h)
// Top-edge midpoint at (320, 144). Drag y down to 100.
dragHandle(h, { x: 320, y: 144 }, { x: 320, y: 100 })
await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
const det = lastDet(h)
// Bottom edge invariant.
expect(det.centerY + det.height / 2).toBeCloseTo(0.7, 5)
// Top edge moved to 100/480.
expect(det.centerY - det.height / 2).toBeCloseTo(100 / H, 5)
})
it('right-edge midpoint: left edge x is invariant', async () => {
const h = renderHarness(initialBox())
selectFirstBox(h)
// Right-edge midpoint at (448, 240). Drag x to 500.
dragHandle(h, { x: 448, y: 240 }, { x: 500, y: 240 })
await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
const det = lastDet(h)
expect(det.centerX - det.width / 2).toBeCloseTo(0.3, 5)
expect(det.centerX + det.width / 2).toBeCloseTo(500 / W, 5)
})
it('bottom-edge midpoint: top edge y is invariant', async () => {
const h = renderHarness(initialBox())
selectFirstBox(h)
dragHandle(h, { x: 320, y: 336 }, { x: 320, y: 380 })
await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
const det = lastDet(h)
expect(det.centerY - det.height / 2).toBeCloseTo(0.3, 5)
expect(det.centerY + det.height / 2).toBeCloseTo(380 / H, 5)
})
it('left-edge midpoint: right edge x is invariant', async () => {
const h = renderHarness(initialBox())
selectFirstBox(h)
dragHandle(h, { x: 192, y: 240 }, { x: 160, y: 240 })
await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
const det = lastDet(h)
expect(det.centerX + det.width / 2).toBeCloseTo(0.7, 5)
expect(det.centerX - det.width / 2).toBeCloseTo(160 / W, 5)
})
})
describe('AC-3 (FT-P-41) — Ctrl+click multi-select', () => {
// Two boxes side-by-side:
// A: center (0.25, 0.5), w/h 0.2 → canvas (96..224, 192..288)
// B: center (0.75, 0.5), w/h 0.2 → canvas (416..544, 192..288)
const twoBoxes = (): Detection[] => [
makeDetection(0.25, 0.5, 0.2, 0.2, 0),
makeDetection(0.75, 0.5, 0.2, 0.2, 1),
]
it.fails(
'Ctrl+click on box B adds B to the selection (rendered as a second selected box)',
async () => {
// Drift: `handleMouseDown` short-circuits on `e.ctrlKey && e.button===0`
// and enters "draw" mode before the hit-test runs. The Ctrl+click
// multi-select branch (`if (e.ctrlKey)` inside the box-hit handler) is
// unreachable today. Test passes once the gate order is fixed.
const h = renderHarness(twoBoxes())
const canvas = h.getCanvas()
// Step 1 — plain click on A (no Ctrl) selects index 0.
fireEvent.mouseDown(canvas, { clientX: 160, clientY: 240, button: 0 })
fireEvent.mouseUp(canvas, { clientX: 160, clientY: 240, button: 0 })
// Reset spy so we count only the post-Ctrl-click draws.
h.spy.reset()
// Step 2 — Ctrl+click on B at center (480, 240).
fireEvent.mouseDown(canvas, { clientX: 480, clientY: 240, button: 0, ctrlKey: true })
fireEvent.mouseUp(canvas, { clientX: 480, clientY: 240, button: 0, ctrlKey: true })
// Re-render forces the spy to capture the post-state. Detections
// didn't actually change, but a new selection forces a re-draw via
// the useEffect on `selected`.
h.rerenderWithDetections(twoBoxes())
await waitFor(() => {
const lw2Boxes = h.spy.strokeRectCalls.filter((c) => c.lineWidth === 2)
// Each selected box renders 1 outer stroke (lineWidth=2) and 8
// handle outlines (lineWidth keeps the value 2 in the same draw
// pass, so the handles show up as lineWidth=2 too — the count we
// care about is "≥ 9 strokes/selected box" * 2 selected = 18).
expect(lw2Boxes.length).toBeGreaterThanOrEqual(18)
}, { timeout: 1500 })
},
)
it('control: production today keeps only the first-clicked box selected (drift snapshot)', async () => {
// Pins current behaviour. After a plain click on A then a Ctrl+click on
// B, only A reads as "selected" — exactly one box renders with the
// selected lineWidth. If a future change starts adding B to the
// selection, this test fails AND the AC-3 it.fails() goes green —
// both signals visible in CI.
const h = renderHarness(twoBoxes())
const canvas = h.getCanvas()
fireEvent.mouseDown(canvas, { clientX: 160, clientY: 240, button: 0 })
fireEvent.mouseUp(canvas, { clientX: 160, clientY: 240, button: 0 })
h.spy.reset()
fireEvent.mouseDown(canvas, { clientX: 480, clientY: 240, button: 0, ctrlKey: true })
fireEvent.mouseUp(canvas, { clientX: 480, clientY: 240, button: 0, ctrlKey: true })
h.rerenderWithDetections(twoBoxes())
await waitFor(() => {
// At least one stroke captured — the spy is wired.
expect(h.spy.strokeRectCalls.length).toBeGreaterThan(0)
})
const selectedStrokes = h.spy.strokeRectCalls.filter((c) => c.lineWidth === 2)
const unselectedStrokes = h.spy.strokeRectCalls.filter((c) => c.lineWidth === 1)
// Drift snapshot: at most ONE selected box (A); B remains unselected.
// Count by counting outer rects (the box outer stroke is the largest
// strokeRect in either selected or unselected case). A selected box
// produces 1 outer + 8 handle outlines = 9 strokeRects with lw=2; an
// unselected box produces 1 strokeRect with lw=1.
expect(selectedStrokes.length).toBe(9)
expect(unselectedStrokes.length).toBe(1)
})
})
describe('AC-4 (FT-P-42) — Ctrl+wheel zoom-around-cursor', () => {
it.fails(
'after Ctrl+wheel at (cx, cy), the canvas pixel under the cursor maps to the same world point (±0.5 px)',
async () => {
// Drift: `handleWheel` updates `zoom` only — `pan` stays at (0,0).
// The world point under (cx, cy) shifts proportionally to the zoom
// delta. Spec requires `new_pan = old_pan + (cursor - old_pan) * (1 - zoom_ratio)`.
// World point under cursor BEFORE: w0 = (cx - 0) / (640 * 1) = 0.5.
// Place the detection AT the cursor world coord so we can tell whether
// post-zoom rendering keeps it under (cx, cy).
const h = renderHarness([makeDetection(0.5, 0.5, 0.1, 0.1)])
const canvas = h.getCanvas()
const cx = 320
const cy = 240
// Reset spy BEFORE the wheel so we only see the post-zoom draw —
// otherwise we'd match the pre-zoom box (width=64, centreX=320) and
// the assertion would vacuously pass.
h.spy.reset()
fireEvent.wheel(canvas, { clientX: cx, clientY: cy, deltaY: -100, ctrlKey: true })
h.rerenderWithDetections([makeDetection(0.5, 0.5, 0.1, 0.1)])
// Find the post-zoom box (width = 0.1 * 640 * 1.1 = 70.4). Anything
// smaller is a stale pre-zoom render that slipped through the reset.
await waitFor(() =>
expect(
h.spy.strokeRectCalls.some((c) => Math.abs(c.w - 0.1 * W * 1.1) < 0.5),
).toBe(true),
)
const post = h.spy.strokeRectCalls.find((c) => Math.abs(c.w - 0.1 * W * 1.1) < 0.5)
expect(post).toBeDefined()
if (!post) return
// Box centre in canvas px should equal cursor pos after zoom-around-cursor
// (world (0.5, 0.5) → cursor (320, 240) is the invariant). Tolerance ±0.5 px.
const centreX = post.x + post.w / 2
const centreY = post.y + post.h / 2
expect(Math.abs(centreX - cx)).toBeLessThanOrEqual(0.5)
expect(Math.abs(centreY - cy)).toBeLessThanOrEqual(0.5)
},
)
it('control: zoom changes the rendered box width but keeps pan = 0 (drift snapshot)', async () => {
// Pins current behaviour: after Ctrl+wheel, the box width grows by 1.1×
// but the box's top-left x stays at `centerX - width/2` * imgSize.w * zoom
// (i.e. pan stays at 0). When AC-4 lands, this test fails because pan
// becomes non-zero — the drift goes away in lockstep with the fix.
const h = renderHarness([makeDetection(0.5, 0.5, 0.1, 0.1)])
const canvas = h.getCanvas()
h.spy.reset()
fireEvent.wheel(canvas, { clientX: 320, clientY: 240, deltaY: -100, ctrlKey: true })
h.rerenderWithDetections([makeDetection(0.5, 0.5, 0.1, 0.1)])
await waitFor(() => expect(h.spy.strokeRectCalls.length).toBeGreaterThan(0))
const post = h.spy.strokeRectCalls.find(
(c) => Math.abs(c.w - 0.1 * W * 1.1) < 0.5,
)
expect(post).toBeDefined()
if (!post) return
// Drift: the box's top-left x is exactly (0.5 - 0.05) * 640 * 1.1 = 316.8;
// pan=0, so x = 316.8. Centre would be at 316.8 + 70.4/2 = 352 — NOT 320.
expect(post.x).toBeCloseTo(316.8, 1)
})
})
describe('AC-5 (FT-P-43) — Ctrl+drag pan on empty canvas', () => {
it.fails(
'Ctrl+drag from (x1,y1) by (dx,dy) shifts pan by (dx, dy); detection canvas-coords stay invariant',
async () => {
// Drift: same Ctrl+button=0 early-return as AC-3. Ctrl+drag enters
// draw mode → either a new bounding box is created (if the drag
// exceeds MIN_BOX_SIZE) or nothing happens; pan never moves.
const h = renderHarness([makeDetection(0.5, 0.5, 0.1, 0.1)])
const canvas = h.getCanvas()
h.spy.reset()
const initialChangeCount = h.changes.length
fireEvent.mouseDown(canvas, { clientX: 100, clientY: 100, button: 0, ctrlKey: true })
fireEvent.mouseMove(canvas, { clientX: 150, clientY: 130, button: 0, ctrlKey: true })
fireEvent.mouseUp(canvas, { clientX: 150, clientY: 130, button: 0, ctrlKey: true })
h.rerenderWithDetections(h.changes.at(-1) ?? [makeDetection(0.5, 0.5, 0.1, 0.1)])
// Spec — no detection added/modified.
expect(h.changes.length).toBe(initialChangeCount)
// Spec — viewport panned by (50, 30). The pre-existing box centred at
// world (0.5, 0.5) was at canvas (320, 240); after pan it should be
// at (370, 270).
await waitFor(() => expect(h.spy.strokeRectCalls.length).toBeGreaterThan(0))
const box = h.spy.strokeRectCalls.find(
(c) => Math.abs(c.w - 0.1 * W) < 0.5,
)
expect(box).toBeDefined()
if (!box) return
const centreX = box.x + box.w / 2
const centreY = box.y + box.h / 2
expect(Math.abs(centreX - 370)).toBeLessThanOrEqual(0.5)
expect(Math.abs(centreY - 270)).toBeLessThanOrEqual(0.5)
},
)
it('control: Ctrl+drag today triggers draw mode and may append a detection (drift snapshot)', async () => {
// The Ctrl+drag on an empty area today goes through the draw branch.
// The 50×30 drag exceeds MIN_BOX_SIZE on width but not on height
// (30 ≥ 12 ✓ — both pass). A detection IS appended. When AC-5 lands
// and Ctrl+drag becomes pan, no detection is appended and this test
// fails — the drift snapshot goes away with the fix.
const h = renderHarness([])
const canvas = h.getCanvas()
h.spy.reset()
fireEvent.mouseDown(canvas, { clientX: 100, clientY: 100, button: 0, ctrlKey: true })
fireEvent.mouseMove(canvas, { clientX: 150, clientY: 130, button: 0, ctrlKey: true })
fireEvent.mouseUp(canvas, { clientX: 150, clientY: 130, button: 0, ctrlKey: true })
await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
const last = h.changes.at(-1) as Detection[]
expect(last).toHaveLength(1)
})
})
})
+575
View File
@@ -0,0 +1,575 @@
import { useEffect, useState } from 'react'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse } 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 { createSSE } from '../src/api/sse'
import App from '../src/App'
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
import { FlightProvider, useFlight } from '../src/components/FlightContext'
import { seedFlights } from './fixtures/seed_flights'
import {
AnnotationSource,
AnnotationStatus,
Affiliation,
CombatReadiness,
MediaType,
MediaStatus,
} from '../src/types'
import type { Media, AnnotationListItem } from '../src/types'
// AZ-478 — network-resilience contracts.
//
// AC-1 (NFT-RES-03): all `/api/*` requests fail with network errors at boot.
// The SPA must:
// a) render an error state (not silently degrade), and
// b) NOT register a service worker / offline cache.
// Today (drift): the AuthProvider's refresh fails →
// `user` stays null → ProtectedRoute redirects to /login;
// the LoginPage form renders, NOT a network-error
// indicator. (b) is enforced statically (STC-N3) AND
// asserted at runtime here for defence in depth.
// AC-2 (NFT-RES-09): annotation download via `canvas.toBlob` on a tainted
// canvas throws SecurityError. The page must NOT crash;
// a user-visible fallback (alternative download path or
// an in-DOM error) must be rendered.
// Today (drift): `AnnotationsPage.handleDownload` calls
// `canvas.toBlob` without a try/catch — the SecurityError
// escapes as an unhandled rejection from the async
// handleDownload. No fallback UI is rendered.
// AC-3 (NFT-RES-10): when an SSE EventSource fires `error` with
// `readyState === 2` (CLOSED), within 2 s a
// connection-lost indicator must appear in the DOM with
// an i18n-keyed text.
// Today (drift): `src/api/sse.ts` calls `onError?.(e)`
// but no consumer renders any user-visible indicator,
// and there is no `connection-lost` i18n key.
// ---------------------------------------------------------------------------
// AC-1 — offline at boot.
// ---------------------------------------------------------------------------
describe('AZ-478 — AC-1 (NFT-RES-03): network offline at boot', () => {
let originalServiceWorker: PropertyDescriptor | undefined
let unhandledHandler: ((reason: unknown) => void) | null = null
const swallowed: unknown[] = []
beforeEach(() => {
// Every /api/* request errors at the network layer (DNS/conn refused).
// This drives AuthProvider's refresh down its `.catch` branch.
server.use(
http.all('/api/*', () => HttpResponse.error()),
)
// Provide a minimal `navigator.serviceWorker` so we can assert
// registrations stays empty. JSDOM has no SW by default.
originalServiceWorker = Object.getOwnPropertyDescriptor(navigator, 'serviceWorker')
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
get() {
return {
getRegistrations: async () => [],
register: vi.fn(),
}
},
})
// Catch the deliberate refresh failure so vitest doesn't error on the
// unhandled rejection.
swallowed.length = 0
unhandledHandler = (reason: unknown) => {
swallowed.push(reason)
}
process.on('unhandledRejection', unhandledHandler)
})
afterEach(() => {
if (unhandledHandler) {
process.off('unhandledRejection', unhandledHandler)
unhandledHandler = null
}
if (originalServiceWorker) {
Object.defineProperty(navigator, 'serviceWorker', originalServiceWorker)
} else {
// navigator.serviceWorker is non-configurable in some envs; deletion
// may silently no-op — that's fine for cleanup.
try {
delete (navigator as unknown as { serviceWorker?: unknown }).serviceWorker
} catch {
// ignore
}
}
})
it('SPA does NOT register a service worker (defence in depth, also enforced statically as STC-N3)', async () => {
// Arrange / Act — boot the app at "/".
renderWithProviders(<App />, { withoutAuth: true, initialEntries: ['/'] })
// Allow the AuthProvider's refresh promise to reject.
await new Promise((r) => setTimeout(r, 50))
// Assert — no SW was registered. STC-N3 already gates this at the source
// tree, but the runtime check catches a future regression where the
// registration is moved to a dynamically-imported module that grep
// misses.
const regs = await navigator.serviceWorker.getRegistrations()
expect(regs).toEqual([])
})
it.fails(
'SPA renders a user-visible network-error indicator when boot APIs are offline',
async () => {
// Drift: today the fall-through behaviour is "redirect to /login".
// The LoginPage form renders; no error banner / offline indicator
// exists. Spec requires an in-DOM indicator (e.g., role="alert" with
// an i18n-keyed message such as "common.networkError").
renderWithProviders(<App />, { withoutAuth: true, initialEntries: ['/'] })
const banner = await screen.findByRole('alert', {}, { timeout: 2000 })
expect(banner.textContent ?? '').toMatch(/offline|network|connection/i)
},
)
it('control: today the SPA falls through to /login (drift snapshot)', async () => {
// Pins current behaviour. When AC-1 lands and the SPA shows a network
// banner instead, this test becomes flaky — the redirect may not happen
// — and the snapshot has to be updated alongside the AC fix.
renderWithProviders(<App />, { withoutAuth: true, initialEntries: ['/'] })
// The login form's i18n header text is "AZAION".
await waitFor(() => expect(screen.getByText('AZAION')).toBeInTheDocument(), {
timeout: 2000,
})
// The login form is rendered (Sign In submit button is the i18n key
// login.submit). LoginPage doesn't wire htmlFor between <label> and
// <input>, so getByLabelText doesn't resolve — match via the submit
// button text instead.
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// AC-2 — tainted-canvas fallback.
// ---------------------------------------------------------------------------
const FLIGHT = seedFlights[0]
const imageMedia: Media = {
id: 'media-az478',
name: 'tainted-fixture.jpg',
path: '/media/tainted-fixture.jpg',
mediaType: MediaType.Image,
mediaStatus: MediaStatus.New,
duration: null,
annotationCount: 1,
waypointId: null,
userId: 'user-az478',
}
const seedAnnotation: AnnotationListItem = {
id: 'ann-az478',
mediaId: imageMedia.id,
time: null,
createdDate: '2026-05-11T00:00:00Z',
userId: 'user-az478',
source: AnnotationSource.Manual,
status: AnnotationStatus.Created,
isSplit: false,
splitTile: null,
detections: [
{
id: 'det-az478',
classNum: 0,
label: 'class-0',
confidence: 0.9,
affiliation: Affiliation.Hostile,
combatReadiness: CombatReadiness.NotReady,
centerX: 0.5,
centerY: 0.5,
width: 0.1,
height: 0.1,
},
],
}
function rigDownloadEnv() {
server.use(
http.get('/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-az478',
userId: 'user-az478',
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([imageMedia], 1, 1000))),
http.get('/api/annotations/annotations', () =>
jsonResponse(paginate([seedAnnotation], 1, 1000)),
),
http.get('/api/annotations/classes', () => jsonResponse([])),
http.get('/api/annotations/dataset/info', () =>
jsonResponse({ totalCount: 0, statusCounts: {} }),
),
)
}
function FlightSeed({ children }: { children: React.ReactNode }): React.ReactElement {
const { selectFlight, selectedFlight } = useFlight()
useEffect(() => {
if (!selectedFlight) selectFlight(FLIGHT)
}, [selectFlight, selectedFlight])
return <>{children}</>
}
describe('AZ-478 — AC-2 (NFT-RES-09): tainted-canvas annotation download fallback', () => {
let originalToBlob: typeof HTMLCanvasElement.prototype.toBlob
let originalImage: typeof globalThis.Image
let toBlobCalls: number
let unhandledHandler: ((reason: unknown) => void) | null = null
const swallowed: unknown[] = []
let originalCreateObjectURL: typeof URL.createObjectURL | undefined
let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined
beforeEach(() => {
seedBearer()
rigDownloadEnv()
toBlobCalls = 0
// JSDOM lacks URL.createObjectURL / revokeObjectURL; AnnotationsPage's
// download path uses both for the .txt blob that's emitted BEFORE the
// canvas-to-PNG path. Patch the methods on the URL constructor directly
// (see _docs/LESSONS.md "Don't replace URL via vi.stubGlobal('URL', ...)"
// for why).
originalCreateObjectURL = (URL as unknown as { createObjectURL?: typeof URL.createObjectURL })
.createObjectURL
originalRevokeObjectURL = (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL })
.revokeObjectURL
;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
((blob: Blob) => `blob:az478-${(blob as { type?: string }).type ?? 'unknown'}`) as unknown as typeof URL.createObjectURL
;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
(() => {/* noop */ }) as unknown as typeof URL.revokeObjectURL
// Stub `Image` so handleDownload's `await new Promise(res => { img.onload = res })`
// resolves synchronously with a 640×480 frame. Production code requires
// `naturalWidth` / `naturalHeight` to be populated for `drawImage` to fire.
originalImage = globalThis.Image
globalThis.Image = class FakeImage extends EventTarget {
onload: ((e: Event) => unknown) | null = null
onerror: ((e: Event) => unknown) | null = null
crossOrigin: string | null = null
naturalWidth = 640
naturalHeight = 480
private _src = ''
get src(): string { return this._src }
set src(v: string) {
this._src = v
// Fire onload on next microtask so the await Promise sees a resolution.
queueMicrotask(() => this.onload?.(new Event('load')))
}
} as unknown as typeof globalThis.Image
// Make canvas.getContext return a working stub so handleDownload reaches
// the `canvas.toBlob` line. JSDOM's default returns null, which would
// short-circuit the function before the SecurityError path is exercised.
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
// Force `toBlob` to throw SecurityError — this simulates the canvas
// having been tainted by a cross-origin draw without CORS headers
// (browsers throw `SecurityError` synchronously on `toBlob` /
// `toDataURL` against a tainted canvas).
originalToBlob = HTMLCanvasElement.prototype.toBlob
HTMLCanvasElement.prototype.toBlob = function tainted(): void {
toBlobCalls += 1
throw new DOMException(
'The canvas has been tainted by cross-origin data',
'SecurityError',
)
}
// Capture the resulting unhandled rejection (production lacks try/catch
// around toBlob — see AnnotationsPage.tsx:139). Without this, vitest
// exits non-zero even when test assertions pass.
swallowed.length = 0
unhandledHandler = (reason: unknown) => {
const msg = (reason instanceof Error ? reason.message : String(reason)) ?? ''
if (/tainted|SecurityError/i.test(msg)) {
swallowed.push(reason)
return
}
// Re-throw anything we didn't expect.
throw reason
}
process.on('unhandledRejection', unhandledHandler)
})
afterEach(() => {
clearBearer()
if (unhandledHandler) {
process.off('unhandledRejection', unhandledHandler)
unhandledHandler = null
}
HTMLCanvasElement.prototype.toBlob = originalToBlob
globalThis.Image = originalImage
if (originalCreateObjectURL === undefined) {
delete (URL as unknown as { createObjectURL?: typeof URL.createObjectURL }).createObjectURL
} else {
;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
originalCreateObjectURL
}
if (originalRevokeObjectURL === undefined) {
delete (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL }).revokeObjectURL
} else {
;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
originalRevokeObjectURL
}
})
it.fails(
'tainted-canvas download surfaces an in-DOM fallback (alt download path or role="alert")',
async () => {
// Drift: today, `canvas.toBlob` throws and the error escapes the async
// handleDownload. No alert / fallback link is rendered. Test passes
// once production wires a try/catch around toBlob and renders either:
// - an `<a download="...txt">` fallback, OR
// - a `role="alert"` carrying an i18n-keyed message (e.g.,
// "annotations.downloadTaintedCanvas").
renderWithProviders(
<FlightProvider>
<FlightSeed>
<AnnotationsPage />
</FlightSeed>
</FlightProvider>,
)
const mediaItem = await screen.findByText(/tainted-fixture\.jpg/)
await userEvent.click(mediaItem)
// Wait for the annotation to render in the right sidebar, then click it
// (only a selected annotation enables the download button).
const annRow = await waitFor(() => {
const rows = screen.getAllByText('—')
if (rows.length === 0) throw new Error('annotation row not yet visible')
return rows[0]
})
await userEvent.click(annRow)
const downloadBtn = await screen.findByTitle(/download/i)
await waitFor(() => expect(downloadBtn).not.toBeDisabled())
await userEvent.click(downloadBtn)
// Assert — `toBlob` was hit (we reached the tainted-canvas branch).
await waitFor(() => expect(toBlobCalls).toBeGreaterThan(0), { timeout: 2000 })
// Assert — fallback UI rendered.
const banner = await screen.findByRole('alert', {}, { timeout: 2000 })
expect(banner.textContent ?? '').toMatch(/download|tainted|cross.?origin/i)
},
)
it('control: page does NOT crash even though toBlob throws (drift snapshot)', async () => {
// Pins the current behaviour: the SecurityError propagates as an
// unhandled rejection, captured by our process listener; the React tree
// stays mounted (the alternative — a thrown SecurityError taking down
// the page — would be a critical regression and would surface as an
// uncaught error in the test runner).
renderWithProviders(
<FlightProvider>
<FlightSeed>
<AnnotationsPage />
</FlightSeed>
</FlightProvider>,
)
const mediaItem = await screen.findByText(/tainted-fixture\.jpg/)
await userEvent.click(mediaItem)
const annRow = await waitFor(() => {
const rows = screen.getAllByText('—')
if (rows.length === 0) throw new Error('annotation row not yet visible')
return rows[0]
})
await userEvent.click(annRow)
const downloadBtn = await screen.findByTitle(/download/i)
await waitFor(() => expect(downloadBtn).not.toBeDisabled())
await userEvent.click(downloadBtn)
await waitFor(() => expect(toBlobCalls).toBeGreaterThan(0), { timeout: 2000 })
// Tree still mounted — the media list header (i18n key annotations.title
// → "Annotations") is still present.
await waitFor(() => {
// Any element still under document.body proves the page didn't crash.
expect(document.body.contains(mediaItem)).toBe(true)
})
// The unhandled-rejection listener captured exactly one SecurityError.
expect(swallowed.length).toBeGreaterThan(0)
expect(String(swallowed[0])).toMatch(/SecurityError|tainted/i)
})
})
// ---------------------------------------------------------------------------
// AC-3 — SSE disconnect indicator.
// ---------------------------------------------------------------------------
interface FakeES extends EventTarget {
url: string
readyState: 0 | 1 | 2
close(): void
fireError(): void
}
let constructedEs: FakeES[] = []
function installFakeEventSource(): () => void {
const origCtor = (globalThis as { EventSource?: typeof EventSource }).EventSource
constructedEs = []
class FakeEventSource extends EventTarget {
public url: string
public readyState: 0 | 1 | 2 = 0
public onmessage: ((e: MessageEvent) => void) | null = null
public onerror: ((e: Event) => void) | null = null
public onopen: ((e: Event) => void) | null = null
constructor(url: string) {
super()
this.url = url
this.readyState = 1
const fakeRef = this as unknown as FakeES
fakeRef.fireError = () => {
this.readyState = 2 // CLOSED
const ev = new Event('error')
// Production SSE wiring uses `source.onerror` directly (not addEventListener).
this.onerror?.(ev)
this.dispatchEvent(ev)
}
constructedEs.push(fakeRef)
}
close(): void {
this.readyState = 2
}
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSED = 2
}
;(globalThis as { EventSource?: unknown }).EventSource = FakeEventSource as unknown as typeof EventSource
return () => {
if (origCtor) {
;(globalThis as { EventSource?: typeof EventSource }).EventSource = origCtor
} else {
delete (globalThis as { EventSource?: typeof EventSource }).EventSource
}
}
}
// Minimal consumer mirroring `AnnotationsSidebar`'s production SSE pattern
// (createSSE → onMessage → no error UI). This is the most direct boundary
// for asserting "no connection-lost indicator is rendered today".
function SseProbe(): React.ReactElement {
const [errored, setErrored] = useState(false)
useEffect(() => {
return createSSE(
'/api/annotations/annotations/events',
() => { /* drop */ },
() => setErrored(true),
)
}, [])
// The probe deliberately renders only the error TEST hook — production
// does NOT render any user-visible "connection-lost" banner today. The
// AC-3 it.fails() asserts on a banner; this probe's `errored` flag
// proves the SSE error path fired (control: spy hit), so the it.fails()
// is failing on UI, not on event plumbing.
return <div data-testid="sse-probe-errored">{errored ? 'errored' : 'open'}</div>
}
describe('AZ-478 — AC-3 (NFT-RES-10): SSE disconnect surfaces a connection-lost indicator', () => {
let restoreEs: (() => void) | null = null
beforeEach(() => {
restoreEs = installFakeEventSource()
server.use(
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
)
})
afterEach(() => {
restoreEs?.()
restoreEs = null
})
it.fails(
'within 2s of SSE error+CLOSED, a user-visible connection-lost indicator with i18n-keyed text is rendered',
async () => {
// Drift: production has no consumer that maps `onError` → user UI.
// `src/api/sse.ts` calls `onError?.(e)` and individual consumers (today
// only AnnotationsSidebar / FlightsPage) ignore that callback. There is
// no `connection-lost` i18n key (parity sweep returned 0 hits).
// Test passes when production wires a `<ConnectionStatus>` (or any
// component) that surfaces the disconnected state.
renderWithProviders(<SseProbe />)
// Wait for the SSE to be constructed (production opens it on mount).
await waitFor(() => expect(constructedEs.length).toBeGreaterThan(0))
// Trigger the disconnect — error fires with readyState=CLOSED.
const es = constructedEs[0]
es.fireError()
// Spec: indicator must appear within 2 s with an i18n-keyed text.
const banner = await screen.findByRole('alert', {}, { timeout: 2000 })
expect(banner.textContent ?? '').toMatch(/connection|disconnect|offline/i)
},
)
it('control: SSE error path fires (probe records errored=true) but no banner is rendered today', async () => {
// Pins the current behaviour: the SSE consumer correctly observes the
// error/CLOSED transition (the probe's local state flips), but no DOM
// node carries an i18n-keyed connection-lost message. Removing this
// test alongside the AC-3 fix is the migration path.
renderWithProviders(<SseProbe />)
await waitFor(() => expect(constructedEs.length).toBeGreaterThan(0))
const es = constructedEs[0]
// Sanity — before the error, the probe renders "open".
expect(screen.getByTestId('sse-probe-errored').textContent).toBe('open')
// Fire the disconnect, then yield React's rerender.
fireEvent.error(window) // no-op event so the next microtask fires
es.fireError()
await waitFor(() =>
expect(screen.getByTestId('sse-probe-errored').textContent).toBe('errored'),
)
// Drift: no role="alert" exists.
expect(screen.queryByRole('alert')).toBeNull()
})
})
+448
View File
@@ -0,0 +1,448 @@
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 from '../src/components/DetectionClasses'
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
import { FlightProvider, useFlight } from '../src/components/FlightContext'
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.get('/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.get('/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)
})
})
})