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) }) }) })