Files
ui/tests/admin_class_edit.test.tsx
T
Armen Rohalov 434854bf3c admin v2: implement design from ui_design/v2/plugin/admin.html
- Design system: v2 CSS variables (surface-0/1/2, border-hair, accent-amber/cyan/red/green/blue)
  and utility classes (.btn, .inp, .pill, .chip, .bracket, .panel, .seg, .swatch,
  .type-sq, .grid-bg, .ibtn, .checkbox, .tab); v1 az-* names aliased to v2 vars
  so other pages still render. Google Fonts (IBM Plex Sans + JetBrains Mono)
  loaded via <link> in index.html <head> to avoid FOUT.
- Header rebuilt to v2: amber wordmark + // divider, amber-bordered flight pill
  with cyan live dot, tab-style nav with amber underline on active, LINK status
  pill, cog + sign-out icon buttons.
- AdminPage rewritten to 3-column layout (340 / flex / 280):
  - Detection Classes: search + ADD button, table with #/Name/Hex/Ops columns,
    name-only inline edit with ringed swatch, sibling-row error alert.
  - AI Recognition Engine + GPS Device Link panels with corner-bracket borders,
    number steppers, segmented protocol control, dashed telemetry footers.
    Hooks (useAiSettings, useGpsSettings) seed factory defaults so the UI is
    interactive when GET fails (no backend).
  - Default Aircrafts: P/C/F type chips, isDefault star toggle, + ADD AIRCRAFT
    modal with model/type/resolution/maxMinutes/default fields.
- Co-located components: Modal (backdrop + ESC + body-scroll-lock),
  NumberStepper (▲▼ with clamp on click but not on typing), ClassEditRow.
- Types: Aircraft extended with FixedWing + optional resolution/maxMinutes;
  new AiRecognitionSettings/Telemetry, GpsDeviceSettings/Telemetry, GpsProtocol.
- Endpoints: /api/admin/ai-settings, /api/admin/gps-settings (+ /ping, /reconnect).
  POST /api/flights/aircrafts (plural REST collection).
- MSW: stateful admin-settings handler with resetAdminSettingsSeed() wired into
  tests/setup.ts. Aircraft seed expanded to 6 entries matching the mockup.
- i18n: full admin.{classes,aiEngine,gpsDevice,aircrafts} key sets in en+ua;
  nav.dataset shortened to "Dataset"; obsolete users-management keys removed.
- Tests: new AdminPage AI/GPS/aircraft test suites; admin_class_edit selectors
  updated for the name-only inline editor and the modal-based add flow.
2026-05-19 02:01:20 +03:00

358 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { http } from 'msw'
import { server } from './msw/server'
import { jsonResponse, errorResponse } from './msw/helpers'
import { renderWithProviders, screen, fireEvent, waitFor, userEvent, within } from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { AdminPage } from '../src/features/admin'
import type { DetectionClass } from '../src/types'
// AZ-512 — Admin: edit existing detection class (inline form + PATCH wiring).
//
// AC-1 — edit affordance visible on every class row
// AC-2 — clicking edit opens inline form, seeded values, single-row at a time
// AC-3 — Save → exactly one PATCH /api/admin/classes/{id} with full body;
// row re-renders with new values; form closes
// AC-3 — Enter inside form behaves like Save
// AC-4 — Cancel button → no network call; row reverts
// AC-4 — Escape inside form behaves like Cancel
// AC-5 — empty name OR non-positive maxSizeM → no PATCH; inline error visible
// AC-6 — PATCH 500 → form stays open; inline error visible; no alert()
// AC-7 — covered by the static FT-P-22 parity gate (scripts/check-i18n-coverage.mjs)
// which runs in CI; AdminPage uses `t('admin.classes.<key>')` for every
// user-visible new string. No runtime test added here.
// AC-8 — regression guards for add + delete behaviour (no dedicated AdminPage
// test suite predates this file; cover the smallest happy path).
//
// Cross-workspace note: as of AZ-512 ship, the admin/ sibling service does NOT
// expose PATCH /api/admin/classes/{id} (verified 2026-05-13). Tests pass on
// MSW stubs; Step 11 (Run Tests) is therefore passable on stubs; Step 16
// (Deploy) gates on AZ-513 landing on admin/.
const TWO_CLASSES: DetectionClass[] = [
{ id: 1, name: 'class-a', shortName: 'a', color: '#ff0000', maxSizeM: 7, photoMode: 0 },
{ id: 2, name: 'class-b', shortName: 'b', color: '#00ff00', maxSizeM: 5, photoMode: 0 },
]
function setClassesHandler(classes: DetectionClass[]) {
server.use(http.get('/api/annotations/classes', () => jsonResponse(classes)))
}
// Pre-existing bug: the default `/api/admin/users` handler returns
// `paginate(seedUsers)` → `{ items, totalCount, ... }`, but AdminPage does
// `setUsers(response)` expecting `User[]`, then crashes on `users.map`. The
// catch() swallows fetch errors but not the subsequent React render error.
// Sidestep here by returning a plain array — does NOT fix the underlying
// shape mismatch (out of scope for AZ-512; flag in batch report).
function stubUsersAsPlainArray() {
server.use(http.get('/api/admin/users', () => jsonResponse([])))
}
function capturePatchCalls() {
const calls: { url: string; body: unknown }[] = []
server.use(
http.patch('/api/admin/classes/:id', async ({ params, request }) => {
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
calls.push({ url: `/api/admin/classes/${String(params.id)}`, body })
return jsonResponse({ id: Number(params.id), ...body })
}),
)
return calls
}
function getRow(idText: string): HTMLElement {
const cell = screen.getByText(idText, { selector: 'td' })
const tr = cell.closest('tr')
if (!tr) throw new Error(`row for "${idText}" not found`)
return tr as HTMLElement
}
async function clickEdit(rowIdText: string) {
// Arrange — find the editable row by its id-cell, then its pencil button.
const row = getRow(rowIdText)
const editBtn = within(row).getByRole('button', { name: /edit|редагувати/i })
await userEvent.click(editBtn)
}
beforeEach(() => {
seedBearer()
setClassesHandler(TWO_CLASSES)
stubUsersAsPlainArray()
})
afterEach(() => {
clearBearer()
})
describe('AZ-512 / AdminPage — inline detection-class edit', () => {
describe('AC-1: edit affordance visible on every class row', () => {
it('renders a pencil button per row', async () => {
// Act
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Assert — one edit button per class row.
const editButtons = await screen.findAllByRole('button', { name: /edit|редагувати/i })
expect(editButtons.length).toBe(TWO_CLASSES.length)
})
})
describe('AC-2: clicking edit opens inline form with seeded values', () => {
it('row 1 enters edit mode with name="class-a"; other rows stay read-only', async () => {
// Arrange
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act
await clickEdit('1')
// Assert — name input is visible inside row 1 (v2 minimal edit:
// only the name is editable inline; shortName/color/maxSizeM are
// preserved in form state and sent on save).
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
expect(nameInput).toBeInTheDocument()
// Assert — row 2 stays read-only: the row still shows the plain text name.
const row2 = getRow('2')
expect(within(row2).getByText('class-b')).toBeInTheDocument()
})
it('opening edit on row 2 while row 1 is editing closes row 1 (single-row invariant)', async () => {
// Arrange
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
expect(within(getRow('1')).getByDisplayValue('class-a')).toBeInTheDocument()
// Act
await clickEdit('2')
// Assert — row 1 reverts; row 2 now hosts the form.
expect(within(getRow('1')).getByText('class-a')).toBeInTheDocument()
expect(within(getRow('2')).getByDisplayValue('class-b')).toBeInTheDocument()
})
})
describe('AC-3: Save sends PATCH and refreshes', () => {
it('Save button → one PATCH with full body, row re-renders, form closes', async () => {
// Arrange — capture PATCH; second GET returns the renamed class.
const patchCalls = capturePatchCalls()
let getCount = 0
server.use(
http.get('/api/annotations/classes', () => {
getCount += 1
if (getCount === 1) return jsonResponse(TWO_CLASSES)
return jsonResponse([{ ...TWO_CLASSES[0], name: 'class-a-renamed' }, TWO_CLASSES[1]])
}),
)
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'class-a-renamed')
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert — exactly one PATCH with the complete editable shape.
await waitFor(() => expect(patchCalls.length).toBe(1))
expect(patchCalls[0].url).toBe('/api/admin/classes/1')
expect(patchCalls[0].body).toEqual({
name: 'class-a-renamed',
shortName: 'a',
color: '#ff0000',
maxSizeM: 7,
})
// Assert — row re-renders read-only with the new name.
await waitFor(() => {
expect(screen.getByText('class-a-renamed')).toBeInTheDocument()
})
})
it('Enter key inside form behaves like Save', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'pressed-enter')
// Act
fireEvent.keyDown(nameInput, { key: 'Enter' })
// Assert
await waitFor(() => expect(patchCalls.length).toBe(1))
expect((patchCalls[0].body as { name: string }).name).toBe('pressed-enter')
})
})
describe('AC-4: Cancel discards edits without network', () => {
it('Cancel button → no PATCH; row reverts', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'never-saved')
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^cancel$|^скасувати$/i }))
// Assert — original value back; no PATCH issued.
expect(within(getRow('1')).getByText('class-a')).toBeInTheDocument()
expect(patchCalls.length).toBe(0)
})
it('Escape key inside form behaves like Cancel', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
// Act
fireEvent.keyDown(nameInput, { key: 'Escape' })
// Assert
expect(within(getRow('1')).getByText('class-a')).toBeInTheDocument()
expect(patchCalls.length).toBe(0)
})
})
describe('AC-5: validation prevents invalid submits', () => {
it('empty name → no PATCH; nameRequired error visible', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert — no PATCH; error alert rendered (v2 renders the alert in
// a sibling tr below the edit row, not inside row1 itself).
expect(patchCalls.length).toBe(0)
const alert = screen.getByRole('alert')
expect(alert.textContent ?? '').toMatch(/name is required|назва обов/i)
})
// The maxSizeM field is no longer editable inline in v2 (mockup shows
// name-only). The original "non-positive maxSizeM" validation test is
// removed — the constraint is now enforced by a separate edit-class
// flow (not yet built) rather than inline.
})
describe('AC-6: backend error is surfaced inline', () => {
it('PATCH 500 → form stays open; updateFailed error visible; no alert() called', async () => {
// Arrange — install a stub that 500s on PATCH; spy on window.alert.
let patchCount = 0
server.use(
http.patch('/api/admin/classes/:id', () => {
patchCount += 1
return errorResponse(500, 'simulated server error')
}),
)
const alertSpy = window.alert
let alertCalls = 0
window.alert = () => { alertCalls += 1 }
try {
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'will-fail')
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert — PATCH happened, error rendered (in a sibling tr), form
// still open, no alert().
await waitFor(() => expect(patchCount).toBe(1))
const row1After = getRow('1')
const alert = await screen.findByRole('alert')
expect(alert.textContent ?? '').toMatch(/update failed|не вдалося оновити/i)
expect(within(row1After).getByDisplayValue('will-fail')).toBeInTheDocument()
expect(alertCalls).toBe(0)
} finally {
window.alert = alertSpy
}
})
})
describe('AC-8: regression — add + delete unchanged', () => {
it('Add posts to /api/admin/classes and refetches the list', async () => {
// Arrange — capture POST; second GET returns 3 classes.
const postCalls: { body: unknown }[] = []
let getCount = 0
const NEW_CLASS: DetectionClass = { id: 3, name: 'fresh', shortName: '', color: '#FF9D3D', maxSizeM: 7, photoMode: 0 }
server.use(
http.post('/api/admin/classes', async ({ request }) => {
postCalls.push({ body: await request.json() })
return jsonResponse(NEW_CLASS, { status: 201 })
}),
http.get('/api/annotations/classes', () => {
getCount += 1
if (getCount === 1) return jsonResponse(TWO_CLASSES)
return jsonResponse([...TWO_CLASSES, NEW_CLASS])
}),
)
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act — v2 layout: click the top "+ ADD" button to open an inline
// add-row at the top of the table, type the name, click the save
// (cyan checkmark, aria-label "Save") icon button.
const classesPanel = getRow('1').closest('aside') as HTMLElement
await userEvent.click(within(classesPanel).getByRole('button', { name: /^\+ add$|^\+ додати$/i }))
const addRow = within(classesPanel).getByText('+', { selector: 'td' }).closest('tr') as HTMLElement
const nameInput = within(addRow).getByPlaceholderText('Name') as HTMLInputElement
await userEvent.type(nameInput, 'fresh')
await userEvent.click(within(addRow).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert
await waitFor(() => expect(postCalls.length).toBe(1))
expect((postCalls[0].body as { name: string }).name).toBe('fresh')
await waitFor(() => expect(screen.getByText('fresh')).toBeInTheDocument())
})
it('Delete sends DELETE and removes the row optimistically', async () => {
// Arrange
const deleteCalls: string[] = []
server.use(
http.delete('/api/admin/classes/:id', ({ params }) => {
deleteCalls.push(`/api/admin/classes/${String(params.id)}`)
return new Response(null, { status: 204 })
}),
)
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act
const row1 = getRow('1')
await userEvent.click(within(row1).getByRole('button', { name: '×' }))
// Assert
await waitFor(() => expect(deleteCalls).toEqual(['/api/admin/classes/1']))
await waitFor(() => expect(screen.queryByText('class-a')).not.toBeInTheDocument())
})
})
})