mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 21:21:11 +00:00
23746ec61d
Closes architecture baseline finding F4. Every component now exposes its Public API through `src/<component>/index.ts`; cross-component imports go through the barrel. `scripts/check-arch-imports.mjs` plus `STC-ARCH-01` in the static profile enforce the rule; tests in `tests/architecture_imports.test.ts` cover AC-4/AC-5 + 2 exemption cases. One F3-pending exemption (`classColors`) is documented in 5 places (barrel, consumer, script, doc, test) to avoid a circular import. Phase B cycle 1 batch 1 of 2 (epic AZ-447). Batch 2 is AZ-486 (endpoint builders) — blocked on this commit landing. 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'
|
||
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,
|
||
})
|
||
})
|
||
})
|
||
})
|