[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
+267
View File
@@ -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.
},
)
})
})
+166
View File
@@ -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',
() => {},
)
})
})
+171
View File
@@ -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()
},
)
})
})
+63 -3
View File
@@ -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 },
]),
),
]
+8
View File
@@ -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 })
+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,
})
})
})
})
+33
View File
@@ -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"
]
}
}
+35
View File
@@ -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