mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 10:21:11 +00:00
- Rewrite SettingsPage to 5-panel v2 layout: Tenant, Directories,
Aircrafts, Language, Session — corner-bracket panels, sticky footer
pinned to viewport bottom (Cancel + Save Changes), live dirty-state
indicator.
- Wire try/catch/finally + role="alert" in save handler so AZ-477's
three it.fails contract tests flip to passing; remove the obsolete
v1-drift control test and its unhandledRejection harness.
- Add EN/UA language toggle; persist to localStorage('azaion.lang')
and read on i18n init. Export LANG_STORAGE_KEY from src/i18n.
- Add Add-Aircraft flow (reuses admin Modal) and view-only star
default toggle.
- Extend the v2 design system with .btn-danger-ghost, .star,
.path-wrap/.browse classes. Scope settings.html-spec button
proportions (padding 7px 14px, weight 400, letter-spacing 0.10em,
line-height 1.5) under .settings-page so the admin spec is unaffected.
- Restore module-scoped bootstrapInflight declaration in
src/auth/AuthContext.tsx (deleted in 2a62415 while references
remained — every test using tests/setup.ts was throwing
ReferenceError).
This commit is contained in:
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from './msw/server'
|
||||
import { jsonResponse } from './msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor, userEvent, within } from './helpers/render'
|
||||
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||
import { seedBearer, clearBearer } from './helpers/auth'
|
||||
import { SettingsPage } from '../src/features/settings'
|
||||
import { seedAircraft } from './fixtures/seed_aircraft'
|
||||
@@ -18,16 +18,9 @@ import type { SystemSettings, DirectorySettings } from '../src/types'
|
||||
// AC-3 (NFT-PERF-09) — Deadline: wall-clock from PUT response/error
|
||||
// to error visibility ≤ 2 s.
|
||||
//
|
||||
// Production today (`SettingsPage.saveSystem` / `saveDirs`) does
|
||||
// setSaving(true); await api.put(...); setSaving(false)
|
||||
// with no try/finally and no error region in the JSX. Both AC-1 and AC-2 are
|
||||
// drift today: the button stays disabled forever and no alert appears. The
|
||||
// AC-3 deadline assertion is also vacuously failing (no DOM element to find).
|
||||
// We mark the contract assertions `it.fails()` and pin the current drift with
|
||||
// control tests, so:
|
||||
// - The drift is documented in the test suite.
|
||||
// - The contract tests will start passing the moment SettingsPage wires
|
||||
// try/finally + an error region — no edits to this file required.
|
||||
// v2 SettingsPage wraps `save()` in try/catch/finally and renders an inline
|
||||
// role="alert" in the sticky footer when the PUT rejects. The three contract
|
||||
// tests below assert that wiring directly.
|
||||
|
||||
const SYSTEM_SEED: SystemSettings = {
|
||||
id: 'sys-1',
|
||||
@@ -84,163 +77,93 @@ function rigSettingsEnv(failure: SettingsFailure): SettingsRig {
|
||||
}
|
||||
|
||||
/**
|
||||
* SettingsPage renders two "Save" buttons (one per panel) once both GETs
|
||||
* resolve. We always exercise the *system* panel — its handler (`saveSystem`)
|
||||
* has the same try-finally drift as `saveDirs`, and scoping the query to
|
||||
* "Tenant Configuration" makes the selector unambiguous regardless of which
|
||||
* GET resolves first.
|
||||
* SettingsPage (v2) renders a single sticky-footer "Save Changes" button that
|
||||
* persists whichever panels are dirty in parallel. The footer button is the
|
||||
* only Save affordance; per-panel Save buttons no longer exist. We must mark
|
||||
* the Tenant panel as dirty by editing a field before the footer button
|
||||
* becomes enabled — selecting the Military Unit input by accessible name and
|
||||
* typing a single character is enough to flip the dirty flag.
|
||||
*/
|
||||
async function findSystemSaveButton(): Promise<HTMLElement> {
|
||||
const systemHeading = await screen.findByRole('heading', { name: /Tenant Configuration/i })
|
||||
const panel = systemHeading.parentElement as HTMLElement
|
||||
return within(panel).getByRole('button', { name: /^Save$/i })
|
||||
// Wait until the data has loaded (heading is present immediately, but the
|
||||
// input is rendered only after the GET resolves).
|
||||
await screen.findByRole('heading', { name: /Tenant Configuration/i })
|
||||
return screen.getByRole('button', { name: /^Save Changes$/i })
|
||||
}
|
||||
|
||||
async function makeTenantDirty(): Promise<void> {
|
||||
const militaryUnit = await screen.findByLabelText(/Military Unit/i)
|
||||
await userEvent.type(militaryUnit, '!')
|
||||
}
|
||||
|
||||
async function renderAndClickSave(): Promise<void> {
|
||||
renderWithProviders(<SettingsPage />)
|
||||
await makeTenantDirty()
|
||||
const saveButton = await findSystemSaveButton()
|
||||
await userEvent.click(saveButton)
|
||||
}
|
||||
|
||||
describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
|
||||
// Production today has no try/catch around the settings-save api.put().
|
||||
// When MSW returns 500 (or HttpResponse.error()), the rejected promise
|
||||
// becomes an unhandled rejection at the process level and Vitest fails
|
||||
// the run with exit code 1 — even though every test assertion passes.
|
||||
// This handler swallows the *expected* rejection pattern only, so any
|
||||
// unexpected unhandled rejection still surfaces as a hard failure.
|
||||
// The drift itself is asserted by the it.fails() contract tests above
|
||||
// ("Save button stays disabled" / "no DOM error region").
|
||||
let suppressedRejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => {
|
||||
const msg =
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: typeof reason === 'string'
|
||||
? reason
|
||||
: ''
|
||||
if (
|
||||
msg.startsWith('500: upstream failure') ||
|
||||
msg.startsWith('Failed to fetch') ||
|
||||
msg === 'Network error' ||
|
||||
msg.includes('network error')
|
||||
) {
|
||||
suppressedRejections.push(reason)
|
||||
return
|
||||
}
|
||||
// Re-throw — surface unexpected rejections to the test runner.
|
||||
throw reason
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
suppressedRejections = []
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
// Sanity: every test in this file expects exactly one swallowed
|
||||
// rejection (the settings PUT). If a test triggers more — or zero — the
|
||||
// drift assumption changed and the harness should flag it.
|
||||
if (suppressedRejections.length > 1) {
|
||||
throw new Error(
|
||||
`AZ-477 harness: expected at most 1 suppressed rejection, got ${suppressedRejections.length}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe('AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery', () => {
|
||||
it.fails(
|
||||
'PUT 500 → Save button is no longer disabled within 2 s',
|
||||
async () => {
|
||||
// Drift: saveSystem awaits api.put() outside a try/finally; on a
|
||||
// rejected promise the trailing `setSaving(false)` is never reached
|
||||
// and the button stays disabled forever.
|
||||
rigSettingsEnv({ kind: 'http', status: 500 })
|
||||
await renderAndClickSave()
|
||||
const saveButton = await findSystemSaveButton()
|
||||
await waitFor(
|
||||
() => expect(saveButton).not.toBeDisabled(),
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it.fails(
|
||||
'PUT 500 → an in-DOM error region (role="alert") appears within 2 s',
|
||||
async () => {
|
||||
// Drift: SettingsPage renders no error region. Will pass once a
|
||||
// toast / inline alert is wired into the save handler.
|
||||
rigSettingsEnv({ kind: 'http', status: 500 })
|
||||
await renderAndClickSave()
|
||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||
// Message shape: production task picks the i18n key; the test only
|
||||
// asserts that *some* user-visible error text is present.
|
||||
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
||||
},
|
||||
)
|
||||
|
||||
it('control: today the Save button stays disabled after a 500 (current drift)', async () => {
|
||||
// Pins the silent-failure drift: button remains in `disabled` state
|
||||
// because setSaving(false) is unreachable.
|
||||
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
|
||||
it('PUT 500 → Save button is no longer disabled within 2 s', async () => {
|
||||
rigSettingsEnv({ kind: 'http', status: 500 })
|
||||
await renderAndClickSave()
|
||||
await waitFor(() => expect(rig.systemPuts).toBe(1))
|
||||
// Wait briefly past the response; the button must stay disabled
|
||||
// (drift: setSaving(false) is unreachable past the rejected await).
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
const saveButton = await findSystemSaveButton()
|
||||
expect(saveButton).toBeDisabled()
|
||||
await waitFor(
|
||||
() => expect(saveButton).not.toBeDisabled(),
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('PUT 500 → an in-DOM error region (role="alert") appears within 2 s', async () => {
|
||||
rigSettingsEnv({ kind: 'http', status: 500 })
|
||||
await renderAndClickSave()
|
||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||
// Message shape: production task picks the i18n key; the test only
|
||||
// asserts that *some* user-visible error text is present.
|
||||
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-2 (FT-N-14 / NFT-RES-06) — network drop', () => {
|
||||
it.fails(
|
||||
'network error → Save button is no longer disabled within 2 s',
|
||||
async () => {
|
||||
rigSettingsEnv({ kind: 'network' })
|
||||
await renderAndClickSave()
|
||||
const saveButton = await findSystemSaveButton()
|
||||
await waitFor(
|
||||
() => expect(saveButton).not.toBeDisabled(),
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
},
|
||||
)
|
||||
it('network error → Save button is no longer disabled within 2 s', async () => {
|
||||
rigSettingsEnv({ kind: 'network' })
|
||||
await renderAndClickSave()
|
||||
const saveButton = await findSystemSaveButton()
|
||||
await waitFor(
|
||||
() => expect(saveButton).not.toBeDisabled(),
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
})
|
||||
|
||||
it.fails(
|
||||
'network error → an in-DOM error region (role="alert") appears within 2 s',
|
||||
async () => {
|
||||
rigSettingsEnv({ kind: 'network' })
|
||||
await renderAndClickSave()
|
||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
||||
},
|
||||
)
|
||||
it('network error → an in-DOM error region (role="alert") appears within 2 s', async () => {
|
||||
rigSettingsEnv({ kind: 'network' })
|
||||
await renderAndClickSave()
|
||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-3 (NFT-PERF-09) — deadline ≤ 2 s', () => {
|
||||
it.fails(
|
||||
'500 → DOM error region visible within 2000 ms of the response',
|
||||
async () => {
|
||||
// The deadline is measured from the moment the 500 response is
|
||||
// returned by MSW (rig.responseAt.value) to the moment role="alert"
|
||||
// is found. Today the alert never appears; the assertion is set so
|
||||
// it will pass the moment the alert is wired AND comes up under the
|
||||
// 2-second budget.
|
||||
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
|
||||
await renderAndClickSave()
|
||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 })
|
||||
const alertVisibleAt = performance.now()
|
||||
expect(rig.responseAt.value).not.toBeNull()
|
||||
const elapsed = alertVisibleAt - (rig.responseAt.value as number)
|
||||
// Elapsed must be ≥ 0 (response landed first) AND ≤ 2000 ms.
|
||||
expect(elapsed).toBeGreaterThanOrEqual(0)
|
||||
expect(elapsed).toBeLessThanOrEqual(2000)
|
||||
expect(alertEl).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
it('500 → DOM error region visible within 2000 ms of the response', async () => {
|
||||
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
|
||||
await renderAndClickSave()
|
||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 })
|
||||
const alertVisibleAt = performance.now()
|
||||
expect(rig.responseAt.value).not.toBeNull()
|
||||
const elapsed = alertVisibleAt - (rig.responseAt.value as number)
|
||||
// Elapsed must be ≥ 0 (response landed first) AND ≤ 2000 ms.
|
||||
expect(elapsed).toBeGreaterThanOrEqual(0)
|
||||
expect(elapsed).toBeLessThanOrEqual(2000)
|
||||
expect(alertEl).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user