mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 11:41:11 +00:00
cfffb4bdd7
ci/woodpecker/push/build-arm Pipeline failed
- 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).
170 lines
6.4 KiB
TypeScript
170 lines
6.4 KiB
TypeScript
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 } from './helpers/render'
|
|
import { seedBearer, clearBearer } from './helpers/auth'
|
|
import { SettingsPage } from '../src/features/settings'
|
|
import { seedAircraft } from './fixtures/seed_aircraft'
|
|
import type { SystemSettings, DirectorySettings } from '../src/types'
|
|
|
|
// AZ-477 — Settings save resilience + 2 s error budget.
|
|
//
|
|
// AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery: PUT 500 ⇒ saving flag clears
|
|
// (Save button enabled again) AND a DOM error
|
|
// region (role="alert") is visible within 2 s.
|
|
// AC-2 (FT-N-14 / NFT-RES-06) — Network drop: same two conditions on
|
|
// HttpResponse.error().
|
|
// AC-3 (NFT-PERF-09) — Deadline: wall-clock from PUT response/error
|
|
// to error visibility ≤ 2 s.
|
|
//
|
|
// 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',
|
|
name: 'Unit Alpha',
|
|
militaryUnit: 'A-1',
|
|
defaultCameraWidth: 1920,
|
|
defaultCameraFoV: 60,
|
|
thumbnailWidth: 256,
|
|
thumbnailHeight: 256,
|
|
thumbnailBorder: 1,
|
|
generateAnnotatedImage: false,
|
|
silentDetection: false,
|
|
}
|
|
|
|
const DIRS_SEED: DirectorySettings = {
|
|
id: 'dirs-1',
|
|
videosDir: '/data/videos',
|
|
imagesDir: '/data/images',
|
|
labelsDir: '/data/labels',
|
|
resultsDir: '/data/results',
|
|
thumbnailsDir: '/data/thumbnails',
|
|
gpsSatDir: '/data/gps/sat',
|
|
gpsRouteDir: '/data/gps/route',
|
|
}
|
|
|
|
interface SettingsRig {
|
|
systemPuts: number
|
|
/** Wall-clock instant the PUT handler returned its (failing) response. */
|
|
responseAt: { value: number | null }
|
|
}
|
|
|
|
type SettingsFailure = { kind: 'http'; status: number } | { kind: 'network' }
|
|
|
|
function rigSettingsEnv(failure: SettingsFailure): SettingsRig {
|
|
let systemPuts = 0
|
|
const responseAt = { value: null as number | null }
|
|
|
|
server.use(
|
|
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
|
http.get('/api/annotations/settings/system', () => jsonResponse(SYSTEM_SEED)),
|
|
http.get('/api/annotations/settings/directories', () => jsonResponse(DIRS_SEED)),
|
|
http.get('/api/flights/aircrafts', () => jsonResponse(seedAircraft)),
|
|
http.put('/api/annotations/settings/system', () => {
|
|
systemPuts += 1
|
|
responseAt.value = performance.now()
|
|
if (failure.kind === 'http') {
|
|
return new HttpResponse('upstream failure', { status: failure.status })
|
|
}
|
|
return HttpResponse.error()
|
|
}),
|
|
)
|
|
|
|
return { get systemPuts() { return systemPuts }, responseAt }
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
// 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', () => {
|
|
beforeEach(() => {
|
|
seedBearer()
|
|
})
|
|
|
|
afterEach(() => {
|
|
clearBearer()
|
|
})
|
|
|
|
describe('AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery', () => {
|
|
it('PUT 500 → Save button is no longer disabled within 2 s', async () => {
|
|
rigSettingsEnv({ kind: 'http', status: 500 })
|
|
await renderAndClickSave()
|
|
const saveButton = await findSystemSaveButton()
|
|
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('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 → 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('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()
|
|
})
|
|
})
|
|
})
|