Merge branch 'dev' into feat/dataset-explorer

This commit is contained in:
Armen Rohalov
2026-05-14 20:26:20 +03:00
383 changed files with 40090 additions and 923 deletions
+179
View File
@@ -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)
})
})
})
+7 -3
View File
@@ -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))
}, [])
+5 -5
View File
@@ -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 (
+208
View File
@@ -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 -1
View File
@@ -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'
+5
View File
@@ -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'