import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { renderWithProviders, waitFor } from './helpers/render' import CanvasEditor from '../src/features/annotations/CanvasEditor' import { AnnotationSource, AnnotationStatus, Affiliation, CombatReadiness, MediaType, MediaStatus, } from '../src/types' import type { Media, AnnotationListItem, Detection } from '../src/types' // AZ-462 — Overlay membership at the in-window edges // // AC-1 (FT-P-14, FT-P-15): annotation EXACTLY on `lowerBound` / `upperBound` // IS rendered (inclusive boundary). // AC-2 (FT-N-01, FT-N-02): annotation one frame interval beyond the bound is // NOT rendered (strict exclusion outside the window). // AC-3: assertion reads the canvas draw output, not React // internal state. We mock `HTMLCanvasElement.getContext` // to capture every `strokeRect` call — each rendered // detection produces one. This is the closest to "DOM // query" available for canvas-based rendering. // // Production drift (`src/features/annotations/CanvasEditor.tsx:215-220`): // `getTimeWindowDetections` filters with `Math.abs(annTime - timeTicks) < 2_000_000` // (strict `<`). The contract per AZ-462 is `<=` (inclusive). FT-P-14/15 are // recorded as `it.fails()` until production lifts the operator. // Tick rate: production uses 10_000_000 ticks per second (.NET DateTime ticks); // the overlay window is ±2_000_000 ticks (= ±0.2 s) around `currentTime`. const TICKS_PER_SECOND = 10_000_000 const HALF_WINDOW_TICKS = 2_000_000 const HALF_WINDOW_SECONDS = HALF_WINDOW_TICKS / TICKS_PER_SECOND // 0.2 s const ONE_FRAME_TICKS = 333_333 // ~30 fps; small step beyond the boundary function ticksToTimecode(ticks: number): string { // Mirror `formatTicks` in AnnotationsPage (HH:MM:SS.mmm) but accept ticks input. const totalSeconds = ticks / TICKS_PER_SECOND const h = Math.floor(totalSeconds / 3600) const m = Math.floor((totalSeconds % 3600) / 60) const wholeS = Math.floor(totalSeconds % 60) const ms = Math.floor((totalSeconds - Math.floor(totalSeconds)) * 1000) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(wholeS).padStart(2, '0')}.${String(ms).padStart(3, '0')}` } function makeDetection(idx: number): Detection { return { id: `det-${idx}`, classNum: 0, label: `class-${idx}`, confidence: 0.9, affiliation: Affiliation.Hostile, combatReadiness: CombatReadiness.NotReady, centerX: 0.5, centerY: 0.5, width: 0.1, height: 0.1, } } function makeAnnotation(id: string, atTicks: number): AnnotationListItem { return { id, mediaId: 'media-az462', time: ticksToTimecode(atTicks), createdDate: '2026-05-11T00:00:00Z', userId: 'user-az462', source: AnnotationSource.Manual, status: AnnotationStatus.Created, isSplit: false, splitTile: null, detections: [makeDetection(parseInt(id.split('-').pop() ?? '0', 10) || 0)], } } const videoMedia: Media = { id: 'media-az462', name: 'overlay-edge.mp4', path: '/media/overlay-edge.mp4', mediaType: MediaType.Video, mediaStatus: MediaStatus.New, duration: '00:00:30', annotationCount: 4, waypointId: null, userId: 'user-az462', } interface CanvasSpy { strokeRectCalls: number reset(): void } function installCanvasSpy(): CanvasSpy { const state: CanvasSpy = { strokeRectCalls: 0, reset() { this.strokeRectCalls = 0 }, } const stub: Partial = { clearRect: vi.fn(), save: vi.fn(), restore: vi.fn(), drawImage: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(() => { state.strokeRectCalls += 1 }), fillText: vi.fn(), measureText: vi.fn(() => ({ width: 10 } as TextMetrics)), arc: vi.fn(), beginPath: vi.fn(), fill: vi.fn(), setLineDash: vi.fn(), fillStyle: '', strokeStyle: '', lineWidth: 1, font: '', globalAlpha: 1, } // jsdom has no canvas implementation — getContext returns null by default. // We override it on the prototype so every mounted by CanvasEditor // resolves to our recording stub. HTMLCanvasElement.prototype.getContext = vi.fn(() => stub as CanvasRenderingContext2D) as unknown as typeof HTMLCanvasElement.prototype.getContext return state } function renderOverlay(annotations: AnnotationListItem[], currentTimeSeconds: number) { return renderWithProviders( {}} selectedClassNum={0} currentTime={currentTimeSeconds} annotations={annotations} />, ) } describe('AZ-462 — overlay membership at in-window edges', () => { let spy: CanvasSpy let originalRaf: typeof globalThis.requestAnimationFrame beforeEach(() => { spy = installCanvasSpy() // Force RAF to fire synchronously so the first draw lands before the // assertion runs (jsdom's RAF queues to a microtask which is fine, but // syncing avoids flakes when the test environment under-schedules it). originalRaf = globalThis.requestAnimationFrame globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { cb(performance.now()) return 0 }) as typeof globalThis.requestAnimationFrame }) afterEach(() => { globalThis.requestAnimationFrame = originalRaf }) describe('AC-1 — inclusive boundary (annotation exactly on bound IS rendered)', () => { it.fails( 'FT-P-14: annotation at the LOWER in-window edge is rendered', async () => { // Arrange — currentTime = 5s; lower bound = 5s − 0.2s = 4.8s. const currentTimeSeconds = 5 const lowerBoundTicks = (currentTimeSeconds - HALF_WINDOW_SECONDS) * TICKS_PER_SECOND const annOnLowerBound = makeAnnotation('ann-1', lowerBoundTicks) // Act renderOverlay([annOnLowerBound], currentTimeSeconds) // Assert — exactly one strokeRect (one detection, on bound). // Production uses strict `<` ⇒ boundary excluded ⇒ 0 strokeRect calls ⇒ this fails. await waitFor(() => expect(spy.strokeRectCalls).toBeGreaterThanOrEqual(1), { timeout: 1000, }) }, ) it.fails( 'FT-P-15: annotation at the UPPER in-window edge is rendered', async () => { const currentTimeSeconds = 5 const upperBoundTicks = (currentTimeSeconds + HALF_WINDOW_SECONDS) * TICKS_PER_SECOND const annOnUpperBound = makeAnnotation('ann-2', upperBoundTicks) renderOverlay([annOnUpperBound], currentTimeSeconds) await waitFor(() => expect(spy.strokeRectCalls).toBeGreaterThanOrEqual(1), { timeout: 1000, }) }, ) it('control: production uses strict `<`, so the EXACT boundary is excluded today', async () => { // This positive control pins the CURRENT (drift) behavior so a regression // that flips the operator to `<=` without lifting the AC drift gets caught. // When AC-1 is fixed, this test goes red and is removed alongside. const currentTimeSeconds = 5 const lowerBoundTicks = (currentTimeSeconds - HALF_WINDOW_SECONDS) * TICKS_PER_SECOND const annOnLowerBound = makeAnnotation('ann-3', lowerBoundTicks) renderOverlay([annOnLowerBound], currentTimeSeconds) // Wait for at least one tick so RAF would have fired if it were going to. await new Promise(r => setTimeout(r, 10)) expect(spy.strokeRectCalls).toBe(0) }) }) describe('AC-2 — strict exclusion (annotation outside the window NOT rendered)', () => { it('FT-N-01: annotation BEFORE the lower bound is not rendered', async () => { // Arrange — annotation at lowerBound − 1 frame. const currentTimeSeconds = 5 const beforeLowerTicks = (currentTimeSeconds - HALF_WINDOW_SECONDS) * TICKS_PER_SECOND - ONE_FRAME_TICKS const annBeforeLower = makeAnnotation('ann-4', beforeLowerTicks) // Act renderOverlay([annBeforeLower], currentTimeSeconds) // Assert — no strokeRect calls (annotation rejected by the time-window filter). await new Promise(r => setTimeout(r, 10)) expect(spy.strokeRectCalls).toBe(0) }) it('FT-N-02: annotation AFTER the upper bound is not rendered', async () => { const currentTimeSeconds = 5 const afterUpperTicks = (currentTimeSeconds + HALF_WINDOW_SECONDS) * TICKS_PER_SECOND + ONE_FRAME_TICKS const annAfterUpper = makeAnnotation('ann-5', afterUpperTicks) renderOverlay([annAfterUpper], currentTimeSeconds) await new Promise(r => setTimeout(r, 10)) expect(spy.strokeRectCalls).toBe(0) }) it('control: an annotation comfortably inside the window IS rendered', async () => { // Positive control — proves the test apparatus would observe a render // when the time-window filter accepts an annotation. Without this, a // canvas-stub failure would cause every assertion to vacuously pass. const currentTimeSeconds = 5 const insideTicks = currentTimeSeconds * TICKS_PER_SECOND const annInside = makeAnnotation('ann-6', insideTicks) renderOverlay([annInside], currentTimeSeconds) await waitFor(() => expect(spy.strokeRectCalls).toBeGreaterThanOrEqual(1), { timeout: 1000, }) }) }) })