mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 09:51:11 +00:00
[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:
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user