mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 11:21:10 +00:00
Merge branch 'dev' into feat/dataset-explorer
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,12 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
|
||||
import { FaRegSnowflake } from 'react-icons/fa'
|
||||
import { api } from '../api/client'
|
||||
import { getClassColor, FALLBACK_CLASS_NAMES } from '../features/annotations/classColors'
|
||||
import { api, endpoints } from '../api'
|
||||
// classColors lives under 06_annotations until F3 moves it to its own home.
|
||||
// Importing through the 06_annotations barrel would create a cycle
|
||||
// (DetectionClasses -> 06_annotations barrel -> AnnotationsPage -> DetectionClasses).
|
||||
// STC-ARCH-01 exempts this single path as an F3-pending edge.
|
||||
import { getClassColor, FALLBACK_CLASS_NAMES } from '../class-colors'
|
||||
import type { DetectionClass } from '../types'
|
||||
|
||||
interface Props {
|
||||
@@ -29,7 +33,7 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
|
||||
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DetectionClass[]>('/api/annotations/classes')
|
||||
api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||
.then(list => setClasses(list?.length ? list : FALLBACK_CLASSES))
|
||||
.catch(() => setClasses(FALLBACK_CLASSES))
|
||||
}, [])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { api, endpoints } from '../api'
|
||||
import type { Flight, UserSettings } from '../types'
|
||||
|
||||
interface FlightState {
|
||||
@@ -21,17 +21,17 @@ export function FlightProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const refreshFlights = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<{ items: Flight[] }>('/api/flights?pageSize=1000')
|
||||
const data = await api.get<{ items: Flight[] }>(endpoints.flights.collection('pageSize=1000'))
|
||||
setFlights(data.items ?? [])
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshFlights()
|
||||
api.get<UserSettings>('/api/annotations/settings/user')
|
||||
api.get<UserSettings>(endpoints.annotations.settingsUser())
|
||||
.then(settings => {
|
||||
if (settings?.selectedFlightId) {
|
||||
api.get<Flight>(`/api/flights/${settings.selectedFlightId}`)
|
||||
api.get<Flight>(endpoints.flights.flight(settings.selectedFlightId))
|
||||
.then(f => setSelectedFlight(f))
|
||||
.catch(() => {})
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function FlightProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const selectFlight = useCallback((f: Flight | null) => {
|
||||
setSelectedFlight(f)
|
||||
api.put('/api/annotations/settings/user', { selectedFlightId: f?.id ?? null }).catch(() => {})
|
||||
api.put(endpoints.annotations.settingsUser(), { selectedFlightId: f?.id ?? null }).catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import { server } from '../../tests/msw/server'
|
||||
import { jsonResponse, paginate } from '../../tests/msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor } from '../../tests/helpers/render'
|
||||
import { seedBearer, clearBearer } from '../../tests/helpers/auth'
|
||||
import { seedFlights } from '../../tests/fixtures/seed_flights'
|
||||
import { opAlice, seedPermissions } from '../../tests/fixtures/seed_users'
|
||||
import { FlightProvider } from './FlightContext'
|
||||
import Header from './Header'
|
||||
|
||||
// AZ-468 — Header flight-dropdown a11y + Escape handler.
|
||||
// FT-P-30 closed-state a11y — aria-expanded=false, accessible trigger name
|
||||
// FT-P-31 open-state a11y — aria-expanded=true, role=listbox/menu,
|
||||
// aria-activedescendant points to a real id
|
||||
// FT-N-09 Escape close + detach — Escape closes the dropdown and the
|
||||
// document-level Escape handler is removed
|
||||
// (no leakage into other components).
|
||||
//
|
||||
// Production status (today): src/components/Header.tsx renders a plain
|
||||
// <button> trigger and a <div> menu without any aria-* attributes, and the
|
||||
// dropdown has NO Escape key handler (only a `mousedown` listener for
|
||||
// click-outside). All three task ACs therefore fail today; FT-P-30/31 are
|
||||
// captured as documented DRIFT via `it.fails()` (flips green when production
|
||||
// gains the attributes); FT-N-09 is QUARANTINEd via `it.skip` because the
|
||||
// behavior is wholly absent — there is no addEventListener('keydown', ...) in
|
||||
// the dropdown to assert against.
|
||||
|
||||
function HeaderHarness({ children }: { children?: ReactNode }) {
|
||||
return <FlightProvider>{children}<Header /></FlightProvider>
|
||||
}
|
||||
|
||||
function mountHeader() {
|
||||
return renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/flights"
|
||||
element={<HeaderHarness><div data-testid="content" /></HeaderHarness>}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login-route" />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/flights'] },
|
||||
)
|
||||
}
|
||||
|
||||
function wireAuthAndFlights() {
|
||||
server.use(
|
||||
// AZ-510 — bootstrap = POST refresh -> { token } + chained GET /users/me.
|
||||
http.post('/api/admin/auth/refresh', () => jsonResponse({ token: 'test-bearer-default' })),
|
||||
http.get('/api/admin/users/me', () =>
|
||||
jsonResponse({ ...opAlice, permissions: seedPermissions[opAlice.id] ?? [] }),
|
||||
),
|
||||
http.get('/api/flights', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const pageSize = Number(url.searchParams.get('pageSize') ?? '50')
|
||||
return jsonResponse(paginate(seedFlights, 1, pageSize))
|
||||
}),
|
||||
http.get('/api/annotations/settings/user', () =>
|
||||
jsonResponse({
|
||||
id: 'us-1', userId: opAlice.id, selectedFlightId: null,
|
||||
annotationsLeftPanelWidth: null, annotationsRightPanelWidth: null,
|
||||
datasetLeftPanelWidth: null, datasetRightPanelWidth: null,
|
||||
}),
|
||||
),
|
||||
http.put('/api/annotations/settings/user', async ({ request }) => {
|
||||
const body = await request.json()
|
||||
return jsonResponse(body)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AZ-468 / src/components/Header.tsx — flight dropdown', () => {
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
wireAuthAndFlights()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('FT-P-30 — closed-state a11y', () => {
|
||||
it.fails(
|
||||
'trigger advertises aria-expanded=false when the menu is closed (drift: attribute currently missing)',
|
||||
async () => {
|
||||
mountHeader()
|
||||
|
||||
// Wait for the flights list to have been fetched so the trigger is hydrated.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
|
||||
// AC-1 contract: aria-expanded=false when closed; no aria-activedescendant.
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(trigger).not.toHaveAttribute('aria-activedescendant')
|
||||
},
|
||||
)
|
||||
|
||||
it('control — closed trigger today lacks aria-expanded entirely (drift seen)', async () => {
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
expect(trigger).not.toHaveAttribute('aria-expanded')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-P-31 — open-state a11y', () => {
|
||||
it.fails(
|
||||
'opened dropdown advertises aria-expanded=true and listbox/menu role with a real aria-activedescendant (drift: attributes missing today)',
|
||||
async () => {
|
||||
const user = userEvent.setup()
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
|
||||
await user.click(trigger)
|
||||
|
||||
// AC-2 contract.
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
const listbox = screen.getByRole('listbox')
|
||||
expect(listbox).toBeInTheDocument()
|
||||
const optionId = trigger.getAttribute('aria-activedescendant')
|
||||
expect(optionId).toBeTruthy()
|
||||
expect(document.getElementById(optionId as string)).not.toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('control — opened dropdown today exposes options but with no role and no aria wiring (drift seen)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
await user.click(trigger)
|
||||
|
||||
// The filter input renders on open, so the panel is visibly open.
|
||||
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument()
|
||||
// But none of the listbox roles or aria-activedescendant wiring exists yet.
|
||||
expect(screen.queryByRole('listbox')).toBeNull()
|
||||
expect(trigger).not.toHaveAttribute('aria-activedescendant')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-N-09 — Escape close + document-level handler detached', () => {
|
||||
it.skip(
|
||||
'QUARANTINE (no production behavior): Escape closes the dropdown and the document keydown handler is removed',
|
||||
async () => {
|
||||
// When the production code lands a document-level keydown listener that
|
||||
// handles Escape, this test asserts:
|
||||
// 1. Pressing Escape closes the dropdown (filter input gone)
|
||||
// 2. The document.addEventListener('keydown', ...) call made when the
|
||||
// dropdown opened is paired with a removeEventListener('keydown', ...)
|
||||
// with the SAME handler reference when the dropdown closes (verified
|
||||
// via spies on document.addEventListener/removeEventListener).
|
||||
// The test below is a sketch of the assertion shape — left skipped because
|
||||
// Header has no keydown listener today and asserting against absent code
|
||||
// would produce noise, not signal.
|
||||
const addSpy = vi.spyOn(document, 'addEventListener')
|
||||
const removeSpy = vi.spyOn(document, 'removeEventListener')
|
||||
const user = userEvent.setup()
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
|
||||
await user.click(trigger)
|
||||
const keydownAdds = addSpy.mock.calls.filter(([type]) => type === 'keydown')
|
||||
expect(keydownAdds.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByPlaceholderText(/filter/i)).toBeNull()
|
||||
|
||||
const keydownRemoves = removeSpy.mock.calls.filter(([type, fn]) =>
|
||||
type === 'keydown' && fn === keydownAdds[0]?.[1],
|
||||
)
|
||||
expect(keydownRemoves.length).toBeGreaterThanOrEqual(1)
|
||||
},
|
||||
)
|
||||
|
||||
it('control — Escape today is a no-op; the dropdown stays open (drift seen)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
await user.click(trigger)
|
||||
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
// QUARANTINE evidence: the filter input is still present — Escape did
|
||||
// nothing because the Header has no keydown handler today.
|
||||
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { useAuth } from '../auth'
|
||||
import { useFlight } from './FlightContext'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import HelpModal from './HelpModal'
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as Header } from './Header'
|
||||
export { default as HelpModal } from './HelpModal'
|
||||
export { default as ConfirmDialog } from './ConfirmDialog'
|
||||
export { default as DetectionClasses } from './DetectionClasses'
|
||||
export { FlightProvider, useFlight } from './FlightContext'
|
||||
Reference in New Issue
Block a user