mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 08:01:10 +00:00
[AZ-471] [AZ-473] [AZ-478] [AZ-479] Batch 7 - canvas/photo-mode/network/perf tests
ci/woodpecker/push/build-arm Pipeline was successful
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user