mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 19:11:11 +00:00
1dd25edee3
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>
172 lines
6.9 KiB
TypeScript
172 lines
6.9 KiB
TypeScript
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()
|
|
},
|
|
)
|
|
})
|
|
})
|