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

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

Batch report: _docs/03_implementation/batch_13_cycle3_report.md

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

605 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(
<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.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)
})
})
})