mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 12: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,179 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderWithProviders, screen, fireEvent, userEvent } from '../../tests/helpers/render'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
|
||||
// AZ-466 — Destructive UX policy (ConfirmDialog half)
|
||||
//
|
||||
// Scope of this file (per AZ-466 ACs that target the dialog itself):
|
||||
// AC-3 (FT-P-28): `role="dialog"`, `aria-modal="true"`, `aria-labelledby`,
|
||||
// `aria-describedby` linkage.
|
||||
// AC-3 (FT-P-29): focus trap — Tab cycles inside the dialog.
|
||||
// AC-2 (FT-N-08): Escape on `<ConfirmDialog>` cancels — `onCancel` is invoked.
|
||||
//
|
||||
// Production drift (`src/components/ConfirmDialog.tsx`):
|
||||
// The dialog renders a plain `<div>` shell with NO `role="dialog"`,
|
||||
// `aria-modal`, `aria-labelledby`, or `aria-describedby` linkage. AC-3
|
||||
// attributes are recorded as `it.fails()`. Focus trap is absent — Tab
|
||||
// does not wrap inside the dialog. AC-3 focus trap is `it.skip` QUARANTINE
|
||||
// until production lands a focus trap. Escape close (FT-N-08) IS wired
|
||||
// (line 22-27 of ConfirmDialog.tsx) and PASSES today.
|
||||
|
||||
describe('AZ-466 — ConfirmDialog (component-level a11y / Escape)', () => {
|
||||
describe('AC-3 (FT-P-28) — modal a11y attributes', () => {
|
||||
it.fails('exposes role="dialog" + aria-modal="true" on the container', () => {
|
||||
// Arrange
|
||||
const noop = () => {}
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete class?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={noop}
|
||||
onCancel={noop}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert — the dialog's container element has the modal a11y attrs.
|
||||
// Drift: production renders a plain <div> with no role / aria attrs.
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
})
|
||||
|
||||
it.fails('links aria-labelledby and aria-describedby to title + message', () => {
|
||||
const noop = () => {}
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete class?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={noop}
|
||||
onCancel={noop}
|
||||
/>,
|
||||
)
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const labelId = dialog.getAttribute('aria-labelledby')
|
||||
const describeId = dialog.getAttribute('aria-describedby')
|
||||
expect(labelId).toBeTruthy()
|
||||
expect(describeId).toBeTruthy()
|
||||
// The referenced ids must point to the title and message nodes.
|
||||
const titleEl = document.getElementById(labelId!)
|
||||
const messageEl = document.getElementById(describeId!)
|
||||
expect(titleEl).toHaveTextContent('Delete class?')
|
||||
expect(messageEl).toHaveTextContent('This cannot be undone.')
|
||||
})
|
||||
|
||||
it('control: the dialog DOM is currently a non-semantic <div> shell', () => {
|
||||
// Pin the current (drift) shape so a regression that, e.g., flips the
|
||||
// outer node to a <span> is caught even before AC-3 is fixed.
|
||||
const noop = () => {}
|
||||
const { container } = renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete class?"
|
||||
onConfirm={noop}
|
||||
onCancel={noop}
|
||||
/>,
|
||||
)
|
||||
const outerDiv = container.querySelector('div.fixed.inset-0')
|
||||
expect(outerDiv).not.toBeNull()
|
||||
expect(outerDiv?.getAttribute('role')).toBeNull()
|
||||
expect(outerDiv?.getAttribute('aria-modal')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-3 (FT-P-29) — focus trap', () => {
|
||||
it.skip(
|
||||
'QUARANTINE — Tab from the last button cycles back to the first focusable element inside the dialog',
|
||||
async () => {
|
||||
// Production has no focus trap. The cancel button auto-focuses on
|
||||
// open (`useEffect` on line 16-18 of ConfirmDialog.tsx) but Tab can
|
||||
// escape the dialog. When a focus trap is added (typically via
|
||||
// `react-focus-lock` or a manual keydown handler), this test should
|
||||
// assert that Tab on the last focusable element returns focus to
|
||||
// the first, and Shift+Tab on the first returns focus to the last.
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('AC-2 (FT-N-08) — Escape cancel', () => {
|
||||
it('invokes onCancel when Escape is pressed while the dialog is open', () => {
|
||||
// Arrange
|
||||
let cancelCalls = 0
|
||||
let confirmCalls = 0
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete?"
|
||||
onConfirm={() => { confirmCalls += 1 }}
|
||||
onCancel={() => { cancelCalls += 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act — fire Escape on window (production attaches a window-level keydown listener).
|
||||
fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
// Assert
|
||||
expect(cancelCalls).toBe(1)
|
||||
expect(confirmCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('does NOT call onCancel when Escape is pressed while the dialog is closed', () => {
|
||||
let cancelCalls = 0
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open={false}
|
||||
title="Closed"
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => { cancelCalls += 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
expect(cancelCalls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1 / AC-2 — happy + cancel paths invoked via the dialog buttons', () => {
|
||||
it('clicking Confirm invokes onConfirm exactly once and not onCancel', async () => {
|
||||
let confirmCalls = 0
|
||||
let cancelCalls = 0
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete?"
|
||||
onConfirm={() => { confirmCalls += 1 }}
|
||||
onCancel={() => { cancelCalls += 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const confirm = screen.getAllByRole('button').find(b => /confirm/i.test(b.textContent ?? ''))
|
||||
expect(confirm).toBeDefined()
|
||||
await userEvent.click(confirm!)
|
||||
|
||||
expect(confirmCalls).toBe(1)
|
||||
expect(cancelCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('clicking Cancel invokes onCancel exactly once and not onConfirm', async () => {
|
||||
let confirmCalls = 0
|
||||
let cancelCalls = 0
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete?"
|
||||
onConfirm={() => { confirmCalls += 1 }}
|
||||
onCancel={() => { cancelCalls += 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const cancel = screen.getAllByRole('button').find(b => /cancel/i.test(b.textContent ?? ''))
|
||||
expect(cancel).toBeDefined()
|
||||
await userEvent.click(cancel!)
|
||||
|
||||
expect(cancelCalls).toBe(1)
|
||||
expect(confirmCalls).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user