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.
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user