mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 16:01:11 +00:00
70fb452805
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>
605 lines
25 KiB
TypeScript
605 lines
25 KiB
TypeScript
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)
|
||
})
|
||
})
|
||
})
|