mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 09:21:10 +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,267 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from './msw/server'
|
||||
import { jsonResponse, paginate } from './msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||
import { seedBearer, clearBearer } from './helpers/auth'
|
||||
import { FlightProvider } from '../src/components/FlightContext'
|
||||
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
|
||||
import { AnnotationSource, AnnotationStatus, MediaType, MediaStatus, Affiliation, CombatReadiness } from '../src/types'
|
||||
import type { Media, AnnotationListItem, Detection } from '../src/types'
|
||||
|
||||
// AZ-460 — Annotation save URL + payload contract
|
||||
//
|
||||
// AC-1 (FT-P-07): outbound URL is the doubly-prefixed canary
|
||||
// `/api/annotations/annotations` (gateway prefix + service base).
|
||||
// AC-2 (FT-P-08): outbound body contains all required fields:
|
||||
// Source, WaypointId, videoTime, mediaId, detections, status.
|
||||
// AC-3: the required-fields check runs for at least three save entry
|
||||
// points (AI suggestion accept, manual draw, bulk-edit save).
|
||||
// Production today only exposes ONE save path (`<AnnotationsPage>`'s
|
||||
// Save button); AI-suggestion-accept and bulk-edit-save are not yet
|
||||
// wired in production. Those two scenarios are recorded as
|
||||
// `it.skip` QUARANTINE entries until Phase B lands them.
|
||||
|
||||
const seedDetection: Detection = {
|
||||
id: 'det-existing',
|
||||
classNum: 0,
|
||||
label: 'class-0',
|
||||
confidence: 0.92,
|
||||
affiliation: Affiliation.Hostile,
|
||||
combatReadiness: CombatReadiness.Ready,
|
||||
centerX: 0.4,
|
||||
centerY: 0.5,
|
||||
width: 0.1,
|
||||
height: 0.15,
|
||||
}
|
||||
|
||||
const seedMediaItem: Media = {
|
||||
id: 'media-az460',
|
||||
name: 'az460.jpg',
|
||||
path: '/media/az460.jpg',
|
||||
mediaType: MediaType.Image,
|
||||
mediaStatus: MediaStatus.New,
|
||||
duration: null,
|
||||
annotationCount: 1,
|
||||
waypointId: 'wp-az460',
|
||||
userId: 'user-az460',
|
||||
}
|
||||
|
||||
const seedAnn: AnnotationListItem = {
|
||||
id: 'ann-az460',
|
||||
mediaId: seedMediaItem.id,
|
||||
time: null,
|
||||
createdDate: '2026-05-11T00:00:00Z',
|
||||
userId: seedMediaItem.userId,
|
||||
source: AnnotationSource.Manual,
|
||||
status: AnnotationStatus.Created,
|
||||
isSplit: false,
|
||||
splitTile: null,
|
||||
detections: [seedDetection],
|
||||
}
|
||||
|
||||
interface CapturedSave {
|
||||
url: string
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
function captureSavePost(): { saves: CapturedSave[] } {
|
||||
// Arrange — capture every POST to the doubly-prefixed annotation save endpoint.
|
||||
const saves: CapturedSave[] = []
|
||||
server.use(
|
||||
http.post('/api/annotations/annotations', async ({ request }) => {
|
||||
saves.push({
|
||||
url: new URL(request.url).pathname,
|
||||
body: (await request.json()) as Record<string, unknown>,
|
||||
})
|
||||
return jsonResponse(
|
||||
{ id: 'ann-saved', createdDate: new Date().toISOString() },
|
||||
{ status: 201 },
|
||||
)
|
||||
}),
|
||||
// Background bootstrap — FlightContext + DetectionClasses + initial AnnotationsPage mount.
|
||||
http.get('/api/flights', () => jsonResponse(paginate([], 1, 1000))),
|
||||
http.get('/api/annotations/settings/user', () => new Response(null, { status: 404 })),
|
||||
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
|
||||
// MediaList fetch + per-selection annotation list reload.
|
||||
http.get('/api/annotations/media', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const page = Number(url.searchParams.get('page') ?? '1')
|
||||
const pageSize = Number(url.searchParams.get('pageSize') ?? '1000')
|
||||
return jsonResponse(paginate([seedMediaItem], page, pageSize))
|
||||
}),
|
||||
http.get('/api/annotations/annotations', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const mediaId = url.searchParams.get('mediaId')
|
||||
const items = mediaId === seedMediaItem.id ? [seedAnn] : []
|
||||
const page = Number(url.searchParams.get('page') ?? '1')
|
||||
const pageSize = Number(url.searchParams.get('pageSize') ?? '1000')
|
||||
return jsonResponse(paginate(items, page, pageSize))
|
||||
}),
|
||||
http.get('/api/annotations/classes', () => jsonResponse([])),
|
||||
http.get('/api/annotations/dataset/info', () => jsonResponse({ totalCount: 1, statusCounts: {} })),
|
||||
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||||
)
|
||||
return { saves }
|
||||
}
|
||||
|
||||
async function selectMediaAndAnnotation(): Promise<void> {
|
||||
// Wait for media to load and click it. (`findByText` returns the inner
|
||||
// <span>; click bubbles to the row's onClick handler.)
|
||||
const mediaItem = await screen.findByText('az460.jpg')
|
||||
await userEvent.click(mediaItem)
|
||||
// After click, MediaList GETs `/api/annotations/annotations?mediaId=...` and
|
||||
// calls `onAnnotationsLoaded`. AnnotationsSidebar then renders an annotation
|
||||
// row showing `'—'` (no time) and the first detection's label (`class-0`).
|
||||
// Clicking that label fires `handleAnnotationSelect`, which seeds detections
|
||||
// and enables the Save button. Wait up to 3 s for the row to appear.
|
||||
const detectionLabel = await screen.findByText('class-0', undefined, { timeout: 3000 })
|
||||
await userEvent.click(detectionLabel)
|
||||
}
|
||||
|
||||
describe('AZ-460 — annotation save URL + payload contract', () => {
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
})
|
||||
|
||||
describe('AC-1 (FT-P-07) — URL canary', () => {
|
||||
it('issues the save POST against the doubly-prefixed `/api/annotations/annotations` path', async () => {
|
||||
// Arrange
|
||||
const { saves } = captureSavePost()
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
// Act
|
||||
await selectMediaAndAnnotation()
|
||||
// Wait for the side-effect: clicking annotation populates detections.
|
||||
await waitFor(() => {
|
||||
const saveBtn = screen.getByRole('button', { name: /^Save$/i })
|
||||
expect(saveBtn).not.toBeDisabled()
|
||||
}, { timeout: 3000 })
|
||||
const saveBtn = screen.getByRole('button', { name: /^Save$/i })
|
||||
await userEvent.click(saveBtn)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(saves).toHaveLength(1), { timeout: 2000 })
|
||||
expect(saves[0].url).toBe('/api/annotations/annotations')
|
||||
// Negative canary — single-prefix would silently match if the URL
|
||||
// regressed; the equality above is the gating assertion.
|
||||
expect(saves[0].url).not.toBe('/api/annotations')
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-2 (FT-P-08) — required-fields presence', () => {
|
||||
it.fails(
|
||||
'includes ALL of {Source, WaypointId, videoTime, mediaId, detections, status} in the save body',
|
||||
async () => {
|
||||
// Arrange — production today sends only {mediaId, time, detections}
|
||||
// (see `src/features/annotations/AnnotationsPage.tsx:32-44`). The other
|
||||
// four fields are missing. This drift is documented as `it.fails()`
|
||||
// until Phase B lifts the body shape to match the wire contract.
|
||||
const { saves } = captureSavePost()
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
// Act
|
||||
await selectMediaAndAnnotation()
|
||||
await waitFor(() => {
|
||||
const saveBtn = screen.getByRole('button', { name: /^Save$/i })
|
||||
expect(saveBtn).not.toBeDisabled()
|
||||
}, { timeout: 3000 })
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }))
|
||||
|
||||
// Assert — every required field present.
|
||||
await waitFor(() => expect(saves).toHaveLength(1))
|
||||
const body = saves[0].body
|
||||
expect(body).toHaveProperty('mediaId', seedMediaItem.id)
|
||||
expect(body).toHaveProperty('detections')
|
||||
expect(Array.isArray(body.detections)).toBe(true)
|
||||
// The four drift fields — the assertion `it.fails()` flips green when these land.
|
||||
expect(body).toHaveProperty('Source')
|
||||
expect(['AI', 'Manual']).toContain(body.Source)
|
||||
expect(body).toHaveProperty('WaypointId')
|
||||
expect(body).toHaveProperty('videoTime')
|
||||
expect(body).toHaveProperty('status')
|
||||
clearBearer()
|
||||
},
|
||||
)
|
||||
|
||||
it('asserts the partial body shape that production currently emits (control)', async () => {
|
||||
// This control test pins the CURRENT (drift) shape so a regression that
|
||||
// drops `mediaId` or `detections` is caught even before AC-2 flips green.
|
||||
// Once production lands the full contract, this test stays green; the
|
||||
// `it.fails()` above starts passing and the migration is observable.
|
||||
const { saves } = captureSavePost()
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
await selectMediaAndAnnotation()
|
||||
await waitFor(() => {
|
||||
const saveBtn = screen.getByRole('button', { name: /^Save$/i })
|
||||
expect(saveBtn).not.toBeDisabled()
|
||||
}, { timeout: 3000 })
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }))
|
||||
|
||||
await waitFor(() => expect(saves).toHaveLength(1))
|
||||
const body = saves[0].body
|
||||
expect(body.mediaId).toBe(seedMediaItem.id)
|
||||
expect(Array.isArray(body.detections)).toBe(true)
|
||||
expect((body.detections as unknown[]).length).toBeGreaterThan(0)
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-3 — multiple save entry points', () => {
|
||||
it('exercises the manual-draw / select-existing save entry point', async () => {
|
||||
// The covered case from AC-1 / AC-2 above. Recorded here as a separate
|
||||
// assertion so the AC-3 entry-point list is explicit in the report.
|
||||
const { saves } = captureSavePost()
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
await selectMediaAndAnnotation()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /^Save$/i })).not.toBeDisabled()
|
||||
}, { timeout: 3000 })
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }))
|
||||
await waitFor(() => expect(saves.length).toBeGreaterThan(0))
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
it.skip(
|
||||
'QUARANTINE — AI-suggestion-accept save entry point not yet wired in production',
|
||||
async () => {
|
||||
// Production has no "accept AI suggestion" button that fires a save —
|
||||
// AI suggestions arrive via the detect/* services and are merged into
|
||||
// detections, but the user accepts via the same `Save` button as manual
|
||||
// draw. The intended distinct UX where accepting an AI suggestion
|
||||
// issues its own save (with `Source: 'AI'`) lands in Phase B.
|
||||
// When the path lands, flip this test to a real assertion that issues
|
||||
// the AI-flavored save and captures `Source === 'AI'`.
|
||||
},
|
||||
)
|
||||
|
||||
it.skip(
|
||||
'QUARANTINE — bulk-edit save entry point not yet wired in production',
|
||||
async () => {
|
||||
// Production has no bulk-edit save path. Bulk operations exist via the
|
||||
// dataset bulk-status endpoint (`/api/annotations/dataset/bulk-status`)
|
||||
// but that does not issue an annotation save per item. When a true
|
||||
// bulk-edit save lands, this test issues it and asserts each save
|
||||
// body matches the AC-2 contract.
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from './msw/server'
|
||||
import { jsonResponse, noContent } from './msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||
import { seedBearer, clearBearer } from './helpers/auth'
|
||||
import AdminPage from '../src/features/admin/AdminPage'
|
||||
|
||||
// AZ-466 — Destructive UX policy (cross-component half)
|
||||
//
|
||||
// AC-1 (FT-P-26): clicking Delete on a class → confirming → DELETE fires AFTER confirm.
|
||||
// AC-2 (FT-N-07): clicking Delete → Cancel → NO DELETE fires.
|
||||
// AC-4 (FT-P-27 / NFT-SEC-08): static check enumerates every destructive surface
|
||||
// and asserts each one mounts a `<ConfirmDialog>`.
|
||||
// The static side lives in `scripts/run-tests.sh` /
|
||||
// `scripts/check-banned-deps.mjs` (`STC-SEC8`).
|
||||
// The runtime mirror is one of the cases below.
|
||||
// AC-5 (NFT-SEC-07): no `alert()` in `src/`. Static side enforces this; runtime
|
||||
// side here only documents the current allowlist. The runtime
|
||||
// test would require renderring every component that calls
|
||||
// alert — out of black-box scope. Static check `STC-SEC7`
|
||||
// handles enforcement.
|
||||
//
|
||||
// Production drift (`src/features/admin/AdminPage.tsx:30-33` and table row
|
||||
// line 76):
|
||||
// `handleDeleteClass` directly calls `api.delete` without gating through
|
||||
// `<ConfirmDialog>`. The class-delete row's `<button onClick=...>` triggers
|
||||
// the network mutation immediately. FT-P-26 + FT-N-07 are recorded as
|
||||
// `it.fails()` until production wraps `handleDeleteClass` behind ConfirmDialog
|
||||
// (Phase B feature task).
|
||||
|
||||
const SEED_CLASSES = [
|
||||
{ id: 1, name: 'class-a', shortName: 'a', color: '#ff0000', maxSizeM: 7 },
|
||||
{ id: 2, name: 'class-b', shortName: 'b', color: '#00ff00', maxSizeM: 5 },
|
||||
]
|
||||
|
||||
interface CapturedDelete {
|
||||
url: string
|
||||
classId: string
|
||||
}
|
||||
|
||||
function captureClassDelete(): { deletes: CapturedDelete[] } {
|
||||
const deletes: CapturedDelete[] = []
|
||||
server.use(
|
||||
http.delete('/api/admin/classes/:id', ({ request, params }) => {
|
||||
deletes.push({ url: new URL(request.url).pathname, classId: String(params.id) })
|
||||
return noContent()
|
||||
}),
|
||||
// AdminPage bootstrap: classes (annotations service), aircrafts, users.
|
||||
// NOTE: `AdminPage` reads `/api/admin/users` as a flat User[]
|
||||
// (`api.get<User[]>` then `users.map`) — but the suite-default MSW
|
||||
// wraps `seedUsers` in `paginate(...)`. That's a documented
|
||||
// production-vs-suite drift (admin handler should expose flat in dev).
|
||||
// For this destructive-UX test we only care about class-delete
|
||||
// wiring, so the override returns a flat empty array to keep
|
||||
// AdminPage from crashing on `users.map`.
|
||||
http.get('/api/annotations/classes', () => jsonResponse(SEED_CLASSES)),
|
||||
http.get('/api/flights/aircrafts', () => jsonResponse([])),
|
||||
http.get('/api/admin/users', () => jsonResponse([])),
|
||||
// AuthContext bootstraps with GET /api/admin/auth/refresh; tests using
|
||||
// <ProtectedRoute>-less render still mount AuthProvider. Return 401 so
|
||||
// the unauth path resolves quickly and bootstrap finishes.
|
||||
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||||
)
|
||||
return { deletes }
|
||||
}
|
||||
|
||||
describe('AZ-466 — Destructive UX policy (class-delete cross-component test)', () => {
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
})
|
||||
|
||||
describe('AC-1 (FT-P-26) — happy path: Delete → Confirm → DELETE fires', () => {
|
||||
it.fails(
|
||||
'class-delete prompts a `<ConfirmDialog>` BEFORE issuing the DELETE',
|
||||
async () => {
|
||||
// Arrange
|
||||
const { deletes } = captureClassDelete()
|
||||
renderWithProviders(<AdminPage />)
|
||||
// Wait for the class table to populate.
|
||||
await screen.findByText('class-a')
|
||||
|
||||
// Act — find the delete button on the first class row.
|
||||
const rows = screen.getAllByText(/^class-/i)
|
||||
const firstRow = rows[0].closest('tr')!
|
||||
const deleteBtn = firstRow.querySelector('button')!
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// Assert — a ConfirmDialog must appear before any DELETE fires.
|
||||
// Drift: AdminPage's `handleDeleteClass` issues api.delete directly
|
||||
// (no ConfirmDialog wired). The DELETE fires immediately and the
|
||||
// dialog never appears.
|
||||
const dialog = await screen.findByRole('dialog', undefined, { timeout: 1000 })
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(deletes).toHaveLength(0)
|
||||
|
||||
// Confirm via the dialog → DELETE fires now.
|
||||
const confirm = screen.getAllByRole('button').find(b => /confirm/i.test(b.textContent ?? ''))!
|
||||
await userEvent.click(confirm)
|
||||
await waitFor(() => expect(deletes).toHaveLength(1), { timeout: 1000 })
|
||||
clearBearer()
|
||||
},
|
||||
)
|
||||
|
||||
it('control: production today bypasses ConfirmDialog and deletes immediately', async () => {
|
||||
// Pin the current (drift) one-click delete behavior. When AC-1 lands,
|
||||
// this control flips red and is removed.
|
||||
const { deletes } = captureClassDelete()
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByText('class-a')
|
||||
|
||||
const rows = screen.getAllByText(/^class-/i)
|
||||
const firstRow = rows[0].closest('tr')!
|
||||
const deleteBtn = firstRow.querySelector('button')!
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
await waitFor(() => expect(deletes).toHaveLength(1), { timeout: 1000 })
|
||||
expect(deletes[0].url).toMatch(/\/api\/admin\/classes\/\d+/)
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-2 (FT-N-07) — cancel path: Delete → Cancel → NO DELETE fires', () => {
|
||||
it.fails(
|
||||
'class-delete with Cancel via the ConfirmDialog suppresses the DELETE entirely',
|
||||
async () => {
|
||||
// Arrange
|
||||
const { deletes } = captureClassDelete()
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByText('class-a')
|
||||
|
||||
// Act — click delete, then Cancel on the dialog.
|
||||
const rows = screen.getAllByText(/^class-/i)
|
||||
const firstRow = rows[0].closest('tr')!
|
||||
await userEvent.click(firstRow.querySelector('button')!)
|
||||
|
||||
// Drift: the dialog never appears today. The find call fails first
|
||||
// (no `role="dialog"` ever mounts), but even if it did, cancel would
|
||||
// need to suppress a DELETE that today already fired synchronously.
|
||||
const dialog = await screen.findByRole('dialog', undefined, { timeout: 1000 })
|
||||
expect(dialog).toBeInTheDocument()
|
||||
const cancel = screen.getAllByRole('button').find(b => /cancel/i.test(b.textContent ?? ''))!
|
||||
await userEvent.click(cancel)
|
||||
|
||||
// Assert — NO DELETE was issued.
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(deletes).toHaveLength(0)
|
||||
clearBearer()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('AC-4 (FT-P-27 / NFT-SEC-08) — destructive surfaces enumeration', () => {
|
||||
// The runtime side of FT-P-27 / NFT-SEC-08 is a multi-component static
|
||||
// walk. Implementing it as a Vitest test would require rendering every
|
||||
// production page and asserting every destructive surface mounts a
|
||||
// ConfirmDialog. That is the static check's job (`STC-SEC8` in
|
||||
// `scripts/run-tests.sh` calling `check-banned-deps.mjs --kind=destructive_unguarded`).
|
||||
// We pin one runtime example here (AdminPage's class-delete) above to
|
||||
// catch regressions on a known-current drift surface.
|
||||
it.skip(
|
||||
'QUARANTINE — full enumeration is enforced by STC-SEC8 (static check); per-surface runtime tests follow per-feature in Phase B',
|
||||
() => {},
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from './msw/server'
|
||||
import { jsonResponse } from './msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||
import { seedBearer, clearBearer } from './helpers/auth'
|
||||
import SettingsPage from '../src/features/settings/SettingsPage'
|
||||
|
||||
// AZ-475 — Numeric form input — empty / non-numeric rejection
|
||||
//
|
||||
// AC-1 (FT-N-11): clearing a numeric field MUST surface a validation error
|
||||
// and prevent the PUT from firing. Silent zero is a regression.
|
||||
// AC-2 (FT-N-12): typing a non-numeric value MUST surface a validation error
|
||||
// and prevent the PUT from firing.
|
||||
//
|
||||
// Production drift (`src/features/settings/SettingsPage.tsx:38-48, 59-60`):
|
||||
// 1. `<label>` carries no `htmlFor` — labels are not programmatically
|
||||
// associated with their inputs (a separate a11y drift surfaced by this
|
||||
// test's setup; the test works around it via DOM traversal). Phase B
|
||||
// task should add `id`/`htmlFor` so `getByLabelText` works directly
|
||||
// and screen readers can navigate the form.
|
||||
// 2. `parseInt(v) || 0` and `parseFloat(v) || 0` silently coerce empty
|
||||
// input to 0 with no validation, then the save handler PUTs the
|
||||
// zeroed payload. FT-N-11 / FT-N-12 are recorded as `it.fails()`
|
||||
// until production lands a `useNumericField` validator (or equivalent)
|
||||
// that blocks save on invalid input.
|
||||
|
||||
function inputForLabel(labelText: RegExp | string): HTMLInputElement {
|
||||
// SettingsPage's `<label>` is a sibling of the `<input>` inside a wrapper
|
||||
// `<div>` (no `htmlFor`). Find the label, walk to its parent, then to the
|
||||
// input. Once production lands `htmlFor` (drift #1 above), tests can use
|
||||
// `screen.findByLabelText` directly.
|
||||
const label = screen.getByText(labelText, { selector: 'label' })
|
||||
const wrapper = label.parentElement
|
||||
if (!wrapper) throw new Error(`label "${String(labelText)}" has no parent`)
|
||||
const input = wrapper.querySelector('input')
|
||||
if (!input) throw new Error(`no input next to label "${String(labelText)}"`)
|
||||
return input as HTMLInputElement
|
||||
}
|
||||
|
||||
interface CapturedPut {
|
||||
url: string
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
function captureSettingsPut(): { puts: CapturedPut[] } {
|
||||
const puts: CapturedPut[] = []
|
||||
server.use(
|
||||
http.put('/api/annotations/settings/system', async ({ request }) => {
|
||||
puts.push({
|
||||
url: new URL(request.url).pathname,
|
||||
body: (await request.json()) as Record<string, unknown>,
|
||||
})
|
||||
return jsonResponse({ ok: true })
|
||||
}),
|
||||
// Settings page bootstraps three GETs.
|
||||
http.get('/api/annotations/settings/system', () =>
|
||||
jsonResponse({
|
||||
id: 'sys-az475',
|
||||
name: 'AZ-475 system',
|
||||
militaryUnit: null,
|
||||
defaultCameraWidth: 1920,
|
||||
defaultCameraFoV: 60,
|
||||
}),
|
||||
),
|
||||
http.get('/api/annotations/settings/directories', () =>
|
||||
jsonResponse({
|
||||
id: 'dirs-az475',
|
||||
videosDir: '/srv/v',
|
||||
imagesDir: '/srv/i',
|
||||
labelsDir: '/srv/l',
|
||||
resultsDir: '/srv/r',
|
||||
thumbnailsDir: '/srv/t',
|
||||
gpsSatDir: '/srv/gs',
|
||||
gpsRouteDir: '/srv/gr',
|
||||
}),
|
||||
),
|
||||
http.get('/api/flights/aircrafts', () => jsonResponse([])),
|
||||
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||||
)
|
||||
return { puts }
|
||||
}
|
||||
|
||||
describe('AZ-475 — numeric form input rejection', () => {
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
})
|
||||
|
||||
describe('AC-1 (FT-N-11) — empty numeric input', () => {
|
||||
it.fails(
|
||||
'shows a validation error and DOES NOT issue the PUT when the field is cleared',
|
||||
async () => {
|
||||
// Arrange
|
||||
const { puts } = captureSettingsPut()
|
||||
renderWithProviders(<SettingsPage />)
|
||||
await screen.findByText(/Default Camera Width/i)
|
||||
const widthInput = inputForLabel(/Default Camera Width/i)
|
||||
expect(widthInput).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
await userEvent.clear(widthInput)
|
||||
// Find the matching Save button (first Save in tenant config block).
|
||||
const saveButtons = await screen.findAllByRole('button', { name: /Save/i })
|
||||
await userEvent.click(saveButtons[0])
|
||||
|
||||
// Assert — validation message present, no PUT issued.
|
||||
// Drift today: SettingsPage uses `parseInt(v) || 0` (silent zero) AND
|
||||
// issues the PUT regardless. Both halves of this assertion fail.
|
||||
const error = await screen.findByText(/required|invalid|must be a number/i, undefined, {
|
||||
timeout: 1000,
|
||||
})
|
||||
expect(error).toBeInTheDocument()
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(puts).toHaveLength(0)
|
||||
clearBearer()
|
||||
},
|
||||
)
|
||||
|
||||
it('control: production today silently coerces empty input to 0 and PUTs', async () => {
|
||||
// Pin current behavior so a regression that, e.g., starts crashing on
|
||||
// empty input is caught even before AC-1 is fixed. When AC-1 lands,
|
||||
// this control flips red and is removed.
|
||||
const { puts } = captureSettingsPut()
|
||||
renderWithProviders(<SettingsPage />)
|
||||
await screen.findByText(/Default Camera Width/i)
|
||||
const widthInput = inputForLabel(/Default Camera Width/i)
|
||||
|
||||
await userEvent.clear(widthInput)
|
||||
const saveButtons = await screen.findAllByRole('button', { name: /Save/i })
|
||||
await userEvent.click(saveButtons[0])
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1), { timeout: 1000 })
|
||||
expect(puts[0].body).toMatchObject({ defaultCameraWidth: 0 })
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-2 (FT-N-12) — non-numeric input', () => {
|
||||
it.fails(
|
||||
'shows a validation error and DOES NOT issue the PUT when input is non-numeric',
|
||||
async () => {
|
||||
// Arrange
|
||||
const { puts } = captureSettingsPut()
|
||||
renderWithProviders(<SettingsPage />)
|
||||
await screen.findByText(/Default Camera Width/i)
|
||||
const widthInput = inputForLabel(/Default Camera Width/i)
|
||||
|
||||
// Act — `<input type="number">` ignores non-numeric typed chars in browsers,
|
||||
// BUT user-event still fires onChange events. To force a non-numeric value
|
||||
// through the React state we set the value directly via fireEvent on
|
||||
// input. (`userEvent.type` would no-op on a number input for "abc".)
|
||||
await userEvent.clear(widthInput)
|
||||
widthInput.value = 'abc'
|
||||
widthInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
widthInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
const saveButtons = await screen.findAllByRole('button', { name: /Save/i })
|
||||
await userEvent.click(saveButtons[0])
|
||||
|
||||
// Assert — validation error visible; no PUT.
|
||||
const error = await screen.findByText(/invalid|must be a number/i, undefined, {
|
||||
timeout: 1000,
|
||||
})
|
||||
expect(error).toBeInTheDocument()
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(puts).toHaveLength(0)
|
||||
clearBearer()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -27,18 +27,41 @@ export const annotationsHandlers = [
|
||||
jsonResponse(seedAnnotations.filter((a) => a.mediaId === params.id)),
|
||||
),
|
||||
|
||||
http.get('/api/annotations', () => jsonResponse(seedAnnotations)),
|
||||
// Production routes use the doubly-prefixed canary `/api/annotations/annotations/*`
|
||||
// — gateway prefix `/api/annotations/` + service base `/annotations/`. AZ-460 AC-1
|
||||
// pins this path; the static check would catch a single-prefix regression.
|
||||
http.get('/api/annotations/annotations', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const mediaId = url.searchParams.get('mediaId')
|
||||
const items = mediaId ? seedAnnotations.filter((a) => a.mediaId === mediaId) : seedAnnotations
|
||||
const page = Number(url.searchParams.get('page') ?? '1')
|
||||
const pageSize = Number(url.searchParams.get('pageSize') ?? String(items.length))
|
||||
return jsonResponse(paginate(items, page, pageSize))
|
||||
}),
|
||||
|
||||
http.post('/api/annotations', async ({ request }) => {
|
||||
http.post('/api/annotations/annotations', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'ann-new', createdDate: new Date().toISOString(), ...body }, { status: 201 })
|
||||
}),
|
||||
|
||||
http.patch('/api/annotations/:id/status', async ({ request, params }) => {
|
||||
http.patch('/api/annotations/annotations/:id/status', async ({ request, params }) => {
|
||||
const body = (await request.json()) as { status?: number }
|
||||
return jsonResponse({ id: params.id, status: body.status ?? 10 })
|
||||
}),
|
||||
|
||||
http.delete('/api/annotations/annotations/:id', () => noContent()),
|
||||
|
||||
// Single-prefix variants kept for backward compatibility with existing tests
|
||||
// that may rely on them. Production uses doubly-prefixed (above).
|
||||
http.get('/api/annotations', () => jsonResponse(seedAnnotations)),
|
||||
http.post('/api/annotations', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'ann-new', createdDate: new Date().toISOString(), ...body }, { status: 201 })
|
||||
}),
|
||||
http.patch('/api/annotations/:id/status', async ({ request, params }) => {
|
||||
const body = (await request.json()) as { status?: number }
|
||||
return jsonResponse({ id: params.id, status: body.status ?? 10 })
|
||||
}),
|
||||
http.delete('/api/annotations/:id', () => noContent()),
|
||||
|
||||
http.get('/api/annotations/dataset', () =>
|
||||
@@ -87,4 +110,41 @@ export const annotationsHandlers = [
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'user-settings-1', userId: params.userId, ...body })
|
||||
}),
|
||||
|
||||
// System / directory settings — used by `<SettingsPage>` (production paths).
|
||||
http.get('/api/annotations/settings/system', () =>
|
||||
jsonResponse({
|
||||
id: 'sys-settings-1',
|
||||
name: 'Test System',
|
||||
militaryUnit: null,
|
||||
defaultCameraWidth: 1920,
|
||||
defaultCameraFoV: 60,
|
||||
}),
|
||||
),
|
||||
http.put('/api/annotations/settings/system', async ({ request }) =>
|
||||
jsonResponse(await request.json()),
|
||||
),
|
||||
http.get('/api/annotations/settings/directories', () =>
|
||||
jsonResponse({
|
||||
id: 'dirs-1',
|
||||
videosDir: '/srv/videos',
|
||||
imagesDir: '/srv/images',
|
||||
labelsDir: '/srv/labels',
|
||||
resultsDir: '/srv/results',
|
||||
thumbnailsDir: '/srv/thumbs',
|
||||
gpsSatDir: '/srv/gps-sat',
|
||||
gpsRouteDir: '/srv/gps-route',
|
||||
}),
|
||||
),
|
||||
http.put('/api/annotations/settings/directories', async ({ request }) =>
|
||||
jsonResponse(await request.json()),
|
||||
),
|
||||
|
||||
// Used by AdminPage when listing detection classes for the editor.
|
||||
http.get('/api/annotations/classes', () =>
|
||||
jsonResponse([
|
||||
{ id: 1, name: 'class-a', shortName: 'a', color: '#ff0000', maxSizeM: 7 },
|
||||
{ id: 2, name: 'class-b', shortName: 'b', color: '#00ff00', maxSizeM: 5 },
|
||||
]),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -54,8 +54,16 @@ export const flightsHandlers = [
|
||||
]),
|
||||
),
|
||||
|
||||
// Production uses the plural path `/api/flights/aircrafts`. Singular alias kept
|
||||
// for any future test that follows REST-singular conventions; production paths win.
|
||||
http.get('/api/flights/aircrafts', () => jsonResponse(seedAircraft)),
|
||||
http.get('/api/flights/aircraft', () => jsonResponse(seedAircraft)),
|
||||
|
||||
http.patch('/api/flights/aircrafts/:id', async ({ request, params }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: params.id, ...body })
|
||||
}),
|
||||
|
||||
http.post('/api/flights/aircraft', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'aircraft-new', ...body }, { status: 201 })
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -81,5 +81,38 @@
|
||||
"patterns": [
|
||||
"335799082893fad97fa36118b131f919"
|
||||
]
|
||||
},
|
||||
"alert_calls": {
|
||||
"ac": "NFT-SEC-07 (AZ-466 AC-5) — no alert() in production source",
|
||||
"scope": "src/ and mission-planner/ (production sources; tests excluded)",
|
||||
"match": "ripgrep-pattern",
|
||||
"patterns": [
|
||||
"\\balert\\s*\\("
|
||||
],
|
||||
"$allowlist_comment": "Snapshot of currently-allowed alert() locations. Phase B feature tasks should drain this list one entry at a time. New alerts are blocked by the static check; removing an entry is a code-review-visible improvement.",
|
||||
"allowlist": [
|
||||
"src/features/annotations/MediaList.tsx",
|
||||
"src/features/flights/FlightsPage.tsx",
|
||||
"mission-planner/src/flightPlanning/JsonEditorDialog.tsx",
|
||||
"mission-planner/src/flightPlanning/flightPlan.tsx"
|
||||
]
|
||||
},
|
||||
"destructive_surfaces": {
|
||||
"ac": "NFT-SEC-08 (AZ-466 AC-4) — every destructive surface is reviewed and either gated by ConfirmDialog or recorded as a known drift",
|
||||
"scope": "src/ files that call api.delete( or destructive api.patch(",
|
||||
"match": "file-level: a file containing a destructive call MUST be listed below; new destructive surfaces FAIL the check",
|
||||
"patterns": [
|
||||
"api\\.delete\\(",
|
||||
"api\\.patch\\([^,]+,\\s*\\{\\s*isActive\\s*:"
|
||||
],
|
||||
"$gated_comment": "Files that perform destructive mutations AND wire ConfirmDialog around them. Code review checks the wiring per file.",
|
||||
"gated": [
|
||||
"src/features/annotations/MediaList.tsx",
|
||||
"src/features/flights/FlightsPage.tsx"
|
||||
],
|
||||
"$drift_comment": "Files that perform destructive mutations WITHOUT a ConfirmDialog gate today. Phase B follow-up tasks land the gate and move each entry to `gated`. Adding a new entry here requires a code-review reason.",
|
||||
"drift": [
|
||||
"src/features/admin/AdminPage.tsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,41 @@ import { cleanup } from '@testing-library/react'
|
||||
import { server } from './msw/server'
|
||||
import { setToken, setNavigateToLogin } from '../src/api/client'
|
||||
|
||||
// JSDOM polyfills for browser APIs production code touches at mount time.
|
||||
// These are no-op stubs — tests that exercise the actual behavior install
|
||||
// richer fakes per-suite (e.g. `tests/sse_lifecycle.test.tsx` overrides
|
||||
// `globalThis.EventSource` and restores it; that pattern still works).
|
||||
class NoopResizeObserver {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
class NoopEventSource extends EventTarget {
|
||||
url: string
|
||||
readyState: 0 | 1 | 2 = 0
|
||||
onopen: ((e: Event) => void) | null = null
|
||||
onmessage: ((e: MessageEvent) => void) | null = null
|
||||
onerror: ((e: Event) => void) | null = null
|
||||
constructor(url: string | URL) {
|
||||
super()
|
||||
this.url = String(url)
|
||||
}
|
||||
close(): void {
|
||||
this.readyState = 2
|
||||
}
|
||||
static readonly CONNECTING = 0
|
||||
static readonly OPEN = 1
|
||||
static readonly CLOSED = 2
|
||||
}
|
||||
|
||||
const g = globalThis as unknown as {
|
||||
ResizeObserver?: typeof NoopResizeObserver
|
||||
EventSource?: typeof NoopEventSource
|
||||
}
|
||||
if (!g.ResizeObserver) g.ResizeObserver = NoopResizeObserver
|
||||
if (!g.EventSource) g.EventSource = NoopEventSource
|
||||
|
||||
// MSW boundary configured per AZ-456 AC-3:
|
||||
// - All outbound /api/<service>/... fetches MUST be intercepted.
|
||||
// - A test missing a handler for a network request is a HARD failure
|
||||
|
||||
Reference in New Issue
Block a user