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'
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(
,
)
function rerenderWithDetections(dets: Detection[]): void {
currentDets = dets
rerender(
,
)
}
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.post('/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)
})
})
})