mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 22:11:10 +00:00
1dd25edee3
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>
259 lines
9.3 KiB
TypeScript
259 lines
9.3 KiB
TypeScript
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,
|
||
})
|
||
})
|
||
})
|
||
})
|