[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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 04:15:01 +03:00
parent 2051088706
commit 1dd25edee3
20 changed files with 1812 additions and 32 deletions
+258
View File
@@ -0,0 +1,258 @@
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,
})
})
})
})