Files
ui/tests/overlay_membership.test.tsx
T
Oleksandr Bezdieniezhnykh 1dd25edee3 [AZ-460] [AZ-462] [AZ-466] [AZ-475] Batch 4 - destructive UX/forms/overlay/save
AZ-466 — Destructive UX policy + ConfirmDialog a11y + no-alert (4pts):
  src/components/ConfirmDialog.test.tsx (8 fast),
  tests/destructive_ux.test.tsx (4 fast, AdminPage class-delete drift),
  e2e/tests/destructive_ux.e2e.ts. New static checks STC-SEC7 (alert
  allowlist) + STC-SEC8 (destructive-surfaces gated/drift) wired through
  scripts/check-banned-deps.mjs reading tests/security/banned-deps.json.

AZ-475 — Numeric form input rejection (2pts):
  tests/form_hygiene.test.tsx (3 fast). Documents two SettingsPage drifts:
  silent zero coercion via parseInt(v)||0 and labels missing htmlFor.

AZ-462 — Overlay membership at in-window edges (2pts):
  tests/overlay_membership.test.tsx (6 fast). Documents getTimeWindowDetections
  strict < drift; AC-1 boundary tests are it.fails(); AC-2 / control PASS.
  Mocks HTMLCanvasElement.getContext to capture strokeRect.

AZ-460 — Annotation save URL + payload contract (2pts):
  tests/annotations_endpoint.test.tsx (6 fast),
  e2e/tests/annotations_endpoint.e2e.ts. AC-1 URL canary PASSes; AC-2
  payload missing 4 fields documented as it.fails(); AC-3 manual-draw
  PASS, AI-suggestion-accept + bulk-edit-save QUARANTINE skip.

Test infrastructure:
  - tests/setup.ts: NoopResizeObserver + NoopEventSource JSDOM polyfills.
  - tests/msw/handlers/annotations.ts: doubly-prefixed paths matching
    production calls (e.g. /api/annotations/annotations).
  - tests/msw/handlers/flights.ts: plural /aircrafts paths.

Verification: bun run test:fast → 80 passed, 13 skipped (14 files).
scripts/run-tests.sh --static-only → 24/24 PASS (was 22; +STC-SEC7/SEC8).
Per-batch self-review verdict: PASS_WITH_WARNINGS. Cumulative review
of batches 04-06 due after batch 6 per implement/SKILL.md Step 14.5.
Report: _docs/03_implementation/batch_04_report.md.

Also includes the previously-untracked
_docs/03_implementation/cumulative_review_batches_01-03_report.md
generated at the start of this session before batch 4 began.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 04:15:01 +03:00

259 lines
9.3 KiB
TypeScript
Raw 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 { 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<CanvasRenderingContext2D> = {
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 <canvas> 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(
<CanvasEditor
media={videoMedia}
annotation={null}
detections={[]}
onDetectionsChange={() => {}}
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,
})
})
})
})