mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 08:01:10 +00:00
[AZ-461] [AZ-464] [AZ-470] [AZ-472] Batch 5 - detection/bulk-validate/panel-width/classes tests
ci/woodpecker/push/build-arm Pipeline was successful
ci/woodpecker/push/build-arm Pipeline was successful
- AZ-461 sync image detect URL canary (FT-P-11) PASS;
async-video QUARANTINE (FT-P-12) + X-Refresh-Token drift
(FT-P-13) recorded as it.fails() with controls.
- AZ-464 bulk-validate URL + UI sync (≤2 s) PASS;
body shape drift {annotationIds,status} vs contract
{ids,targetStatus:30} captured as it.fails().
- AZ-470 panel-width debounce + rehydration: entire task
is Phase-B target (useResizablePanel has no PUT writer
/ no rehydration); 3 ACs as it.fails() with controls.
- AZ-472 DetectionClasses load + click + fallback PASS;
hotkey arithmetic P=0 PASS, P=20/P=40 it.fails() for
classes[idx+P]-against-dense-array drift.
Code review: PASS (0 findings). Fast: 18/18 files,
102 passed / 13 skipped. Static: 21/21 PASS.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from './msw/server'
|
||||
import { jsonResponse, paginate } from './msw/helpers'
|
||||
import { renderWithProviders, screen, fireEvent, waitFor } from './helpers/render'
|
||||
import { seedBearer, clearBearer } from './helpers/auth'
|
||||
import { FlightProvider } from '../src/components/FlightContext'
|
||||
import DatasetPage from '../src/features/dataset/DatasetPage'
|
||||
import { AnnotationStatus, AnnotationSource } from '../src/types'
|
||||
import type { DatasetItem } from '../src/types'
|
||||
|
||||
// AZ-464 — Bulk-validate URL + body + UI sync within 2 s.
|
||||
//
|
||||
// AC-1 (FT-P-20 URL): outbound POST URL is `/api/annotations/dataset/bulk-status`.
|
||||
// AC-2 (FT-P-20 body): outbound body carries the media-id set + the target
|
||||
// status. Spec contract is `{ids, targetStatus: 30}`
|
||||
// (post-AC-04 enum scheme); production today emits
|
||||
// `{annotationIds, status: 2}`. Two `it.fails()` tests
|
||||
// pin the documented drifts (field names + status value)
|
||||
// and a control pins the current behavior.
|
||||
// AC-3 (FT-P-21 + NFT-PERF-07): after a 200 from the POST, every selected
|
||||
// row's DOM badge reads `Validated` within 2 s. The
|
||||
// production handler awaits the POST response then calls
|
||||
// fetchItems() — the second GET returns updated items.
|
||||
|
||||
const seedItems: DatasetItem[] = [
|
||||
{
|
||||
annotationId: 'ann-az464-1',
|
||||
imageName: 'az464-1.jpg',
|
||||
thumbnailPath: '/thumbs/az464-1.jpg',
|
||||
status: AnnotationStatus.Created,
|
||||
createdDate: '2026-05-11T10:00:00Z',
|
||||
createdEmail: 'op_alice@test.local',
|
||||
flightName: 'Flight A',
|
||||
source: AnnotationSource.Manual,
|
||||
isSeed: false,
|
||||
isSplit: false,
|
||||
},
|
||||
{
|
||||
annotationId: 'ann-az464-2',
|
||||
imageName: 'az464-2.jpg',
|
||||
thumbnailPath: '/thumbs/az464-2.jpg',
|
||||
status: AnnotationStatus.Created,
|
||||
createdDate: '2026-05-11T10:01:00Z',
|
||||
createdEmail: 'op_alice@test.local',
|
||||
flightName: 'Flight A',
|
||||
source: AnnotationSource.Manual,
|
||||
isSeed: false,
|
||||
isSplit: false,
|
||||
},
|
||||
{
|
||||
annotationId: 'ann-az464-3',
|
||||
imageName: 'az464-3.jpg',
|
||||
thumbnailPath: '/thumbs/az464-3.jpg',
|
||||
status: AnnotationStatus.Created,
|
||||
createdDate: '2026-05-11T10:02:00Z',
|
||||
createdEmail: 'op_alice@test.local',
|
||||
flightName: 'Flight A',
|
||||
source: AnnotationSource.Manual,
|
||||
isSeed: false,
|
||||
isSplit: false,
|
||||
},
|
||||
]
|
||||
|
||||
interface CapturedBulk {
|
||||
url: string
|
||||
pathname: string
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface SyncRig {
|
||||
posts: CapturedBulk[]
|
||||
validatedAfterPost: { current: boolean }
|
||||
}
|
||||
|
||||
function rigDatasetAndBulk(): SyncRig {
|
||||
const posts: CapturedBulk[] = []
|
||||
const validatedAfterPost = { current: false }
|
||||
|
||||
server.use(
|
||||
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||||
http.get('/api/flights', () => jsonResponse(paginate([], 1, 1000))),
|
||||
http.get('/api/annotations/settings/user', () => new Response(null, { status: 404 })),
|
||||
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
|
||||
http.get('/api/annotations/classes', () => jsonResponse([])),
|
||||
// Dataset list — returns the seeded items, paginated. After the bulk POST
|
||||
// fires, this handler flips its `status` field to Validated for the
|
||||
// entire seed so the second GET delivers the updated payload.
|
||||
http.get('/api/annotations/dataset', () => {
|
||||
const items = seedItems.map((it) =>
|
||||
validatedAfterPost.current
|
||||
? { ...it, status: AnnotationStatus.Validated }
|
||||
: { ...it },
|
||||
)
|
||||
return jsonResponse(paginate(items, 1, items.length))
|
||||
}),
|
||||
http.post('/api/annotations/dataset/bulk-status', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
const url = new URL(request.url)
|
||||
posts.push({
|
||||
url: request.url,
|
||||
pathname: url.pathname,
|
||||
body,
|
||||
})
|
||||
// Flip the GET handler so the next fetchItems() returns updated rows.
|
||||
validatedAfterPost.current = true
|
||||
return jsonResponse({ updated: 3, status: 30 })
|
||||
}),
|
||||
)
|
||||
|
||||
return { posts, validatedAfterPost }
|
||||
}
|
||||
|
||||
async function selectItemsWithCtrlClick(annotationIds: string[]): Promise<void> {
|
||||
// The DatasetPage doesn't expose row test-ids; row identity lives in
|
||||
// imageName + annotationId. Locate each row by its image name.
|
||||
for (const id of annotationIds) {
|
||||
const item = seedItems.find((s) => s.annotationId === id)!
|
||||
const cell = await screen.findByText(item.imageName)
|
||||
// Walk to the parent row that owns the onClick handler. The row is the
|
||||
// outer `<div>` rendered for each item; its className contains
|
||||
// `cursor-pointer`. Use `closest(...)` against a stable structural
|
||||
// selector to be resilient to copy edits.
|
||||
const row = cell.closest('div.cursor-pointer')
|
||||
expect(row).toBeTruthy()
|
||||
fireEvent.click(row!, { ctrlKey: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe('AZ-464 — bulk-validate URL + body + UI sync', () => {
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
})
|
||||
|
||||
describe('AC-1 (FT-P-20) — URL canary', () => {
|
||||
it('clicking Validate fires exactly one POST against `/api/annotations/dataset/bulk-status`', async () => {
|
||||
// Arrange
|
||||
const { posts } = rigDatasetAndBulk()
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<DatasetPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
// Wait for items to render.
|
||||
await screen.findByText(seedItems[0].imageName)
|
||||
|
||||
// Act — Ctrl+click the 3 seed items, then click Validate.
|
||||
await selectItemsWithCtrlClick(seedItems.map((it) => it.annotationId))
|
||||
const validateBtn = await screen.findByRole('button', { name: /Validate \(\d+\)/i })
|
||||
fireEvent.click(validateBtn)
|
||||
|
||||
// Assert — exactly one POST observed; URL matches contract.
|
||||
await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })
|
||||
expect(posts[0].pathname).toBe('/api/annotations/dataset/bulk-status')
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-2 (FT-P-20) — body shape', () => {
|
||||
it.fails(
|
||||
'body carries `{ids: <N>, targetStatus: 30}` per contract',
|
||||
async () => {
|
||||
// Production today sends `{annotationIds: <N>, status: 2}` — both
|
||||
// field names AND the status value differ from the contract. The
|
||||
// assertion below fails on either drift; flips green when production
|
||||
// aligns with the AC-04 wire enum scheme.
|
||||
const { posts } = rigDatasetAndBulk()
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<DatasetPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
await screen.findByText(seedItems[0].imageName)
|
||||
await selectItemsWithCtrlClick(seedItems.map((it) => it.annotationId))
|
||||
const validateBtn = await screen.findByRole('button', { name: /Validate \(\d+\)/i })
|
||||
fireEvent.click(validateBtn)
|
||||
await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })
|
||||
|
||||
const body = posts[0].body
|
||||
expect(body).toHaveProperty('ids')
|
||||
expect(Array.isArray(body.ids)).toBe(true)
|
||||
expect((body.ids as unknown[])).toHaveLength(seedItems.length)
|
||||
expect(body).toHaveProperty('targetStatus', 30)
|
||||
|
||||
clearBearer()
|
||||
},
|
||||
)
|
||||
|
||||
it('control: production sends `{annotationIds, status: AnnotationStatus.Validated}` (current drift shape)', async () => {
|
||||
// Pin the CURRENT shape so a regression that drops `annotationIds` or
|
||||
// changes `status` to a non-enum value is caught even before AC-2 flips
|
||||
// green. When AC-04 lands the wire enum scheme, this control needs to
|
||||
// be adjusted alongside production.
|
||||
const { posts } = rigDatasetAndBulk()
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<DatasetPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
await screen.findByText(seedItems[0].imageName)
|
||||
await selectItemsWithCtrlClick(seedItems.map((it) => it.annotationId))
|
||||
const validateBtn = await screen.findByRole('button', { name: /Validate \(\d+\)/i })
|
||||
fireEvent.click(validateBtn)
|
||||
await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })
|
||||
|
||||
const body = posts[0].body
|
||||
expect(body).toHaveProperty('annotationIds')
|
||||
expect(Array.isArray(body.annotationIds)).toBe(true)
|
||||
expect((body.annotationIds as unknown[])).toHaveLength(seedItems.length)
|
||||
expect(body).toHaveProperty('status', AnnotationStatus.Validated)
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-3 (FT-P-21 + NFT-PERF-07) — UI sync within 2 s', () => {
|
||||
it('every selected row badge reads `Validated` ≤ 2 000 ms after the POST resolves', async () => {
|
||||
// Arrange
|
||||
const { posts } = rigDatasetAndBulk()
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<DatasetPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
await screen.findByText(seedItems[0].imageName)
|
||||
await selectItemsWithCtrlClick(seedItems.map((it) => it.annotationId))
|
||||
const validateBtn = await screen.findByRole('button', { name: /Validate \(\d+\)/i })
|
||||
|
||||
// Act — record wall-clock at click time so the perf budget is observed.
|
||||
const t0 = Date.now()
|
||||
fireEvent.click(validateBtn)
|
||||
|
||||
// Assert — POST observed, then all rows show the Validated badge.
|
||||
await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })
|
||||
|
||||
// The Validated badge text comes from i18n key `dataset.status.validated`,
|
||||
// resolving to 'Validated' in the en bundle. Every seedItem row has
|
||||
// exactly one badge `<span>` inside the row card.
|
||||
await waitFor(
|
||||
() => {
|
||||
const validatedBadges = screen.getAllByText('Validated')
|
||||
// The status-filter button bar also contains a 'Validated' button —
|
||||
// filter to the badge spans (size class `px-1 rounded` is unique to
|
||||
// the badge in DatasetPage's row template).
|
||||
const rowBadges = validatedBadges.filter((el) =>
|
||||
(el.className ?? '').includes('px-1 rounded'),
|
||||
)
|
||||
expect(rowBadges).toHaveLength(seedItems.length)
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
const elapsed = Date.now() - t0
|
||||
expect(elapsed).toBeLessThanOrEqual(2000)
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from './msw/server'
|
||||
import { jsonResponse, errorResponse } from './msw/helpers'
|
||||
import { renderWithProviders, screen, fireEvent, waitFor, userEvent, act } from './helpers/render'
|
||||
import { seedBearer, clearBearer } from './helpers/auth'
|
||||
import { seedClasses } from './fixtures/seed_classes'
|
||||
import DetectionClasses from '../src/components/DetectionClasses'
|
||||
import { FALLBACK_CLASS_NAMES } from '../src/features/annotations/classColors'
|
||||
import type { DetectionClass } from '../src/types'
|
||||
|
||||
// AZ-472 — DetectionClasses load + 1-9 hotkeys + click path + empty/5xx fallback.
|
||||
//
|
||||
// AC-1 (FT-P-44): GET /api/annotations/classes observed at mount; rendered list
|
||||
// reflects the active photoMode filter (no fallback marker).
|
||||
// AC-2 (FT-P-45): for each P ∈ {0, 20, 40}, key k=1..9 selects the k-th class
|
||||
// within the P-window — i.e., the entry with id `P + (k-1)`
|
||||
// per FT-P-45 spec ("the appropriate window of 9").
|
||||
// AC-3 (FT-P-46): clicking a class entry fires onSelect(c.id) once.
|
||||
// AC-4 (FT-P-47): when /api/annotations/classes returns [] OR a 5xx, the
|
||||
// fallback list is rendered and the id set equals
|
||||
// [0..N-1, 20..20+N-1, 40..40+N-1].
|
||||
//
|
||||
// Documented drifts (from `_docs/02_document/tests/blackbox-tests.md` note on
|
||||
// AC-37 row 79: "fix can land either side per data_parameters.md"):
|
||||
// - Production hotkey logic uses `classes[idx + photoMode]` against the
|
||||
// loaded array. For a dense response of length 27 (3 windows × 9 entries)
|
||||
// this yields the wrong class for P=20 and the index is out-of-range for
|
||||
// P=40. AC-2 for P=20/P=40 is `it.fails()`. Both flip green when either
|
||||
// production switches to `modeClasses[idx]` (filter-then-index) OR the
|
||||
// suite serves a sparse length-60 array.
|
||||
// - The seed_classes fixture today sets `photoMode: 0` on every entry,
|
||||
// which makes the rendering filter `c.photoMode === photoMode` show only
|
||||
// P=0 entries. To unblock AZ-472 without modifying the AZ-456-owned
|
||||
// fixture, every test in this file overrides the GET handler with a
|
||||
// correctly-tagged copy (`orderedClasses`, photoMode set per offset).
|
||||
|
||||
const orderedClasses: DetectionClass[] = seedClasses.map((c) => ({
|
||||
...c,
|
||||
photoMode: c.id < 20 ? 0 : c.id < 40 ? 20 : 40,
|
||||
}))
|
||||
|
||||
function captureClassesGets(payload: DetectionClass[], opts?: { status?: number }) {
|
||||
const calls: { url: string }[] = []
|
||||
server.use(
|
||||
http.get('/api/annotations/classes', ({ request }) => {
|
||||
calls.push({ url: new URL(request.url).pathname })
|
||||
if (opts?.status && opts.status >= 500) return errorResponse(opts.status, 'simulated server error')
|
||||
return jsonResponse(payload)
|
||||
}),
|
||||
// AuthProvider GETs /api/admin/auth/refresh on every mount — the default
|
||||
// admin handler only responds to POST. Returning 401 here silences MSW's
|
||||
// unhandled-request errors without affecting these tests (AuthProvider's
|
||||
// .catch swallows the failure and DetectionClasses doesn't depend on auth
|
||||
// user state).
|
||||
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||||
)
|
||||
return calls
|
||||
}
|
||||
|
||||
interface HarnessState {
|
||||
selectedRef: { current: number }
|
||||
selectSpy: ReturnType<typeof vi.fn>
|
||||
modeSpy: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function HarnessWrapper({
|
||||
initialPhotoMode = 0,
|
||||
state,
|
||||
}: {
|
||||
initialPhotoMode?: number
|
||||
state: HarnessState
|
||||
}) {
|
||||
return (
|
||||
<DetectionClasses
|
||||
selectedClassNum={state.selectedRef.current}
|
||||
onSelect={(id: number) => {
|
||||
state.selectedRef.current = id
|
||||
state.selectSpy(id)
|
||||
}}
|
||||
photoMode={initialPhotoMode}
|
||||
onPhotoModeChange={(mode: number) => {
|
||||
state.modeSpy(mode)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function makeHarnessState(): HarnessState {
|
||||
return {
|
||||
selectedRef: { current: -1 },
|
||||
selectSpy: vi.fn(),
|
||||
modeSpy: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('AZ-472 — DetectionClasses (load / hotkeys / click / fallback)', () => {
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
})
|
||||
|
||||
describe('AC-1 (FT-P-44) — load contract', () => {
|
||||
it('GETs /api/annotations/classes and renders the active-mode window', async () => {
|
||||
// Arrange — install a counting handler returning the corrected seed.
|
||||
const calls = captureClassesGets(orderedClasses)
|
||||
const state = makeHarnessState()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
||||
|
||||
// Assert — the GET fired against the contract URL.
|
||||
await waitFor(() => expect(calls.length).toBeGreaterThan(0))
|
||||
expect(calls[0].url).toBe('/api/annotations/classes')
|
||||
|
||||
// Observable: 9 entries for photoMode=0 (ids 0..8). FALLBACK_CLASS_NAMES
|
||||
// is NOT used because the API returned data.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('class-0')).toBeInTheDocument()
|
||||
expect(screen.getByText('class-8')).toBeInTheDocument()
|
||||
})
|
||||
// The fallback's first name is "Car" — absent here, since the API
|
||||
// returned a populated payload.
|
||||
expect(screen.queryByText('Car')).toBeNull()
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-2 (FT-P-45) — hotkey arithmetic', () => {
|
||||
it('photoMode=0: keys 1..9 select ids 0..8 (production matches spec)', async () => {
|
||||
// Arrange
|
||||
captureClassesGets(orderedClasses)
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
|
||||
|
||||
// Act + Assert — for each k=1..9, dispatch keydown then check arg.
|
||||
for (let k = 1; k <= 9; k++) {
|
||||
state.selectSpy.mockClear()
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: String(k) })
|
||||
})
|
||||
const expectedId = 0 + (k - 1)
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
|
||||
expect(state.selectSpy.mock.calls.at(-1)?.[0]).toBe(expectedId)
|
||||
}
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
it.fails(
|
||||
'photoMode=20: keys 1..9 select ids 20..28 (production drift — uses classes[idx+P] against dense array)',
|
||||
async () => {
|
||||
// Production today computes `classes[idx + 20]` against a length-27
|
||||
// array — for k=1..9 this lands in the 40s window, returning the
|
||||
// wrong id (or undefined for P=40). Spec intent (FT-P-45 "appropriate
|
||||
// window of 9") is `P + (k-1)`. Test is `it.fails()` until either the
|
||||
// production formula switches to filter-then-index OR the suite
|
||||
// serves a sparse length-60 array.
|
||||
captureClassesGets(orderedClasses)
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={20} state={state} />)
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
|
||||
|
||||
for (let k = 1; k <= 9; k++) {
|
||||
state.selectSpy.mockClear()
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: String(k) })
|
||||
})
|
||||
const expectedId = 20 + (k - 1)
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
|
||||
expect(state.selectSpy.mock.calls.at(-1)?.[0]).toBe(expectedId)
|
||||
}
|
||||
|
||||
clearBearer()
|
||||
},
|
||||
)
|
||||
|
||||
it.fails(
|
||||
'photoMode=40: keys 1..9 select ids 40..48 (production drift — index out of range)',
|
||||
async () => {
|
||||
// For P=40 the production index `idx + 40` (range 40..48) exceeds the
|
||||
// dense array length 27 — `cls` is undefined and `onSelect` never
|
||||
// fires; the assertion below times out / fails accordingly. Same
|
||||
// recovery as P=20 above.
|
||||
captureClassesGets(orderedClasses)
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={40} state={state} />)
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
|
||||
|
||||
for (let k = 1; k <= 9; k++) {
|
||||
state.selectSpy.mockClear()
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: String(k) })
|
||||
})
|
||||
const expectedId = 40 + (k - 1)
|
||||
// selectSpy may have 0 calls; toHaveBeenLastCalledWith with no calls
|
||||
// throws, which is the failure signal `it.fails()` expects.
|
||||
expect(state.selectSpy).toHaveBeenLastCalledWith(expectedId)
|
||||
}
|
||||
|
||||
clearBearer()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('AC-3 (FT-P-46) — click path', () => {
|
||||
it('clicking a class entry fires onSelect with that class.id', async () => {
|
||||
captureClassesGets(orderedClasses)
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
||||
const target = await screen.findByText('class-3')
|
||||
state.selectSpy.mockClear()
|
||||
|
||||
// Act
|
||||
await userEvent.click(target)
|
||||
|
||||
// Assert — onSelect fires with id 3 (the entry's id field).
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
|
||||
expect(state.selectSpy.mock.calls.at(-1)?.[0]).toBe(3)
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-4 (FT-P-47) — fallback on empty / 5xx', () => {
|
||||
it('renders the FALLBACK_CLASS_NAMES list when the API returns []', async () => {
|
||||
// Arrange
|
||||
captureClassesGets([])
|
||||
const state = makeHarnessState()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
||||
|
||||
// Assert — fallback list of FALLBACK_CLASS_NAMES.length entries is
|
||||
// rendered (one button per fallback class for the active photoMode).
|
||||
// Each button's accessible name contains the fallback class name plus
|
||||
// its shortName slice; we match by button accessible-name regex to
|
||||
// avoid the dual-text duplicate (`Car` appears in both name and
|
||||
// shortName spans).
|
||||
const findClassButton = async (name: string) =>
|
||||
screen.findByRole('button', { name: new RegExp(`\\b${name}\\b`) })
|
||||
for (const name of FALLBACK_CLASS_NAMES) {
|
||||
await expect(findClassButton(name)).resolves.toBeInTheDocument()
|
||||
}
|
||||
// Sanity: the seed name 'class-0' is NOT visible (we returned [] not seed).
|
||||
expect(screen.queryByText('class-0')).toBeNull()
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
it('renders the fallback list when the API returns 500', async () => {
|
||||
// Arrange — error hits the .catch branch in production, which also sets
|
||||
// the fallback. The observable shape is identical to the empty-payload
|
||||
// case above.
|
||||
captureClassesGets([], { status: 500 })
|
||||
const state = makeHarnessState()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
||||
|
||||
// Assert
|
||||
const findClassButton = async (name: string) =>
|
||||
screen.findByRole('button', { name: new RegExp(`\\b${name}\\b`) })
|
||||
for (const name of FALLBACK_CLASS_NAMES) {
|
||||
await expect(findClassButton(name)).resolves.toBeInTheDocument()
|
||||
}
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
it('fallback id set equals [0..N-1, 20..20+N-1, 40..40+N-1]', () => {
|
||||
// The fallback list is built statically in production as
|
||||
// [0,20,40].flatMap(o => FALLBACK_CLASS_NAMES.map((_, i) => ({ id: i + o }))).
|
||||
// We pin the contract directly without rendering — downstream tests
|
||||
// (AZ-473 PhotoMode) depend on this id set. If the fallback shape ever
|
||||
// changes, this test fails AND so do the AZ-473 dependants.
|
||||
const N = FALLBACK_CLASS_NAMES.length
|
||||
const expected = new Set<number>()
|
||||
for (const offset of [0, 20, 40]) {
|
||||
for (let i = 0; i < N; i++) expected.add(i + offset)
|
||||
}
|
||||
const derived = new Set(
|
||||
[0, 20, 40].flatMap((o) => FALLBACK_CLASS_NAMES.map((_, i) => i + o)),
|
||||
)
|
||||
expect(derived).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from './msw/server'
|
||||
import { jsonResponse, paginate, sse } from './msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||
import { seedBearer, clearBearer } from './helpers/auth'
|
||||
import { FlightProvider } from '../src/components/FlightContext'
|
||||
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
|
||||
import { MediaType, MediaStatus } from '../src/types'
|
||||
import type { Media } from '../src/types'
|
||||
|
||||
// AZ-461 — Detection endpoints (sync image / async video / long-video header).
|
||||
//
|
||||
// AC-1 (FT-P-11): clicking Detect on an image issues exactly one POST whose
|
||||
// URL matches `^/api/detect/[0-9]+$` (the wire contract). The
|
||||
// production handler in <AnnotationsSidebar> already POSTs
|
||||
// `/api/detect/${media.id}` against the active media — passes
|
||||
// today when the media id is numeric.
|
||||
// AC-2 (FT-P-12): async-video detect endpoint + SSE — TARGET (Phase B). The
|
||||
// async path does not exist in production today (single
|
||||
// Detect button POSTs the same endpoint regardless of media
|
||||
// type; no `/api/detect/video/<id>` route, no `jobId`, no
|
||||
// EventSource on `/api/detect/stream/<id>`). Recorded as
|
||||
// `it.fails()` so the test runs in CI (the spec requires
|
||||
// "test code itself runs (does not just xit)") and emits a
|
||||
// console log "FT-P-12 awaits AC-25 / async video detect impl"
|
||||
// per AC-2 contract. Flips green when AC-25 lands.
|
||||
// AC-3 (FT-P-13): long-video detect carries an `X-Refresh-Token` header — no
|
||||
// such header is added in production (`api.post` only sets
|
||||
// Authorization + Content-Type). `it.fails()` until the
|
||||
// header is wired in Phase B per task spec note.
|
||||
|
||||
// Production detect URL is `/api/detect/<media.id>`. The contract regex
|
||||
// `^/api/detect/[0-9]+$` requires a numeric id segment; the seed media for
|
||||
// this test uses a numeric-style string id ('42') so the regex matches the
|
||||
// observed URL today. (Other tests use 'media-1' style ids for unrelated
|
||||
// reasons.)
|
||||
const NUMERIC_MEDIA_ID = '42'
|
||||
const NUMERIC_VIDEO_MEDIA_ID = '57'
|
||||
|
||||
const seedImageMedia: Media = {
|
||||
id: NUMERIC_MEDIA_ID,
|
||||
name: 'detect-image.jpg',
|
||||
path: '/media/detect-image.jpg',
|
||||
mediaType: MediaType.Image,
|
||||
mediaStatus: MediaStatus.New,
|
||||
duration: null,
|
||||
annotationCount: 0,
|
||||
waypointId: null,
|
||||
userId: 'user-az461',
|
||||
}
|
||||
|
||||
const seedVideoMedia: Media = {
|
||||
id: NUMERIC_VIDEO_MEDIA_ID,
|
||||
name: 'detect-video.mp4',
|
||||
path: '/media/detect-video.mp4',
|
||||
mediaType: MediaType.Video,
|
||||
mediaStatus: MediaStatus.New,
|
||||
duration: '00:01:30',
|
||||
annotationCount: 0,
|
||||
waypointId: null,
|
||||
userId: 'user-az461',
|
||||
}
|
||||
|
||||
interface CapturedRequest {
|
||||
url: string
|
||||
method: string
|
||||
pathname: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
interface CapturedSSE {
|
||||
url: string
|
||||
}
|
||||
|
||||
function captureDetectAndBootstrap(opts?: {
|
||||
mediaItems?: Media[]
|
||||
detectStatus?: number
|
||||
detectResponse?: Record<string, unknown>
|
||||
registerVideoEndpoints?: boolean
|
||||
}): { detectCalls: CapturedRequest[]; sseOpens: CapturedSSE[] } {
|
||||
const detectCalls: CapturedRequest[] = []
|
||||
const sseOpens: CapturedSSE[] = []
|
||||
const items = opts?.mediaItems ?? [seedImageMedia]
|
||||
const detectStatus = opts?.detectStatus ?? 200
|
||||
const detectResponse = opts?.detectResponse ?? { detections: [] }
|
||||
|
||||
const handlers = [
|
||||
// Wide-net detect catcher — production POSTs `/api/detect/<id>` for any
|
||||
// media id today. The handler captures URL + headers so AC-1 + AC-3 can
|
||||
// assert against the same request log.
|
||||
http.post('/api/detect/:rest*', async ({ request, params }) => {
|
||||
const url = new URL(request.url)
|
||||
const headers: Record<string, string> = {}
|
||||
request.headers.forEach((v, k) => {
|
||||
headers[k] = v
|
||||
})
|
||||
detectCalls.push({
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
pathname: url.pathname,
|
||||
headers,
|
||||
})
|
||||
// Synthesize an async-video shape if the URL matches the future Phase B
|
||||
// contract `^/api/detect/video/[0-9]+$`. Today no such request fires;
|
||||
// when AC-25 lands and production routes here, this responder makes the
|
||||
// jobId assertion in AC-2 stop being a "wholly absent" failure.
|
||||
if (typeof params.rest === 'string' && params.rest.startsWith('video/')) {
|
||||
return jsonResponse({ jobId: 12345 })
|
||||
}
|
||||
if (detectStatus >= 400) {
|
||||
return new Response(JSON.stringify({ error: 'simulated' }), { status: detectStatus })
|
||||
}
|
||||
return jsonResponse(detectResponse)
|
||||
}),
|
||||
|
||||
// Phase B — async video detect SSE. Today no production code opens this
|
||||
// EventSource; the handler exists only so AC-2's `it.fails()` body can
|
||||
// run end-to-end without MSW unhandled-request errors when the path
|
||||
// eventually lands.
|
||||
...(opts?.registerVideoEndpoints
|
||||
? [
|
||||
http.get('/api/detect/stream/:jobId', ({ request }) => {
|
||||
sseOpens.push({ url: new URL(request.url).pathname })
|
||||
return sse([
|
||||
{ event: 'progress', data: { pct: 50 }, id: '1' },
|
||||
{ event: 'done', data: { detections: [] }, id: '2' },
|
||||
])
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
// Bootstrap — minimal handlers so <AnnotationsPage> mounts cleanly and
|
||||
// <MediaList> shows the seeded media item.
|
||||
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||||
http.get('/api/flights', () => jsonResponse(paginate([], 1, 1000))),
|
||||
http.get('/api/annotations/settings/user', () => new Response(null, { status: 404 })),
|
||||
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
|
||||
http.get('/api/annotations/media', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const page = Number(url.searchParams.get('page') ?? '1')
|
||||
const pageSize = Number(url.searchParams.get('pageSize') ?? '1000')
|
||||
return jsonResponse(paginate(items, page, pageSize))
|
||||
}),
|
||||
http.get('/api/annotations/annotations', () => jsonResponse(paginate([], 1, 1000))),
|
||||
http.get('/api/annotations/classes', () => jsonResponse([])),
|
||||
http.get('/api/annotations/dataset/info', () => jsonResponse({ totalCount: 0, statusCounts: {} })),
|
||||
]
|
||||
server.use(...handlers)
|
||||
return { detectCalls, sseOpens }
|
||||
}
|
||||
|
||||
// The Detect button label comes from i18n key `annotations.detect`, which
|
||||
// resolves to `'AI Detect'` in the en bundle (see `src/i18n/en.json`). Match
|
||||
// the localized string rather than the i18n key so the test stays robust
|
||||
// against future copy tweaks while still asserting on the rendered DOM.
|
||||
const DETECT_BUTTON_NAME = /AI Detect/i
|
||||
|
||||
async function selectMediaAndClickDetect(mediaName: string): Promise<void> {
|
||||
const mediaItem = await screen.findByText(mediaName)
|
||||
await userEvent.click(mediaItem)
|
||||
// The Detect button lives in <AnnotationsSidebar>'s header. It is rendered
|
||||
// unconditionally but is `disabled` until selectedMedia is non-null —
|
||||
// userEvent.click on a disabled element is a no-op, so wait for it to
|
||||
// enable first.
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByRole('button', { name: DETECT_BUTTON_NAME })
|
||||
expect(btn).not.toBeDisabled()
|
||||
})
|
||||
await userEvent.click(screen.getByRole('button', { name: DETECT_BUTTON_NAME }))
|
||||
}
|
||||
|
||||
describe('AZ-461 — detection endpoints (sync / async / long-video header)', () => {
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
})
|
||||
|
||||
describe('AC-1 (FT-P-11) — sync image detect URL canary', () => {
|
||||
it('clicks Detect on an image and observes exactly one POST whose URL matches /api/detect/<id>', async () => {
|
||||
// Arrange
|
||||
const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedImageMedia] })
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
// Act
|
||||
await selectMediaAndClickDetect(seedImageMedia.name)
|
||||
|
||||
// Assert — exactly one POST fired against the contract URL.
|
||||
await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
|
||||
expect(detectCalls[0].method).toBe('POST')
|
||||
// FT-P-11 contract regex: `^/api/detect/[0-9]+$`. Numeric media id makes
|
||||
// production's `/api/detect/${media.id}` satisfy this regex today.
|
||||
expect(detectCalls[0].pathname).toMatch(/^\/api\/detect\/[0-9]+$/)
|
||||
expect(detectCalls[0].pathname).toBe(`/api/detect/${NUMERIC_MEDIA_ID}`)
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-2 (FT-P-12) — async video detect endpoint + SSE (Phase B target — QUARANTINE)', () => {
|
||||
it.fails(
|
||||
'POSTs `/api/detect/video/<id>`, response carries jobId, EventSource opens on `/api/detect/stream/<jobId>`',
|
||||
async () => {
|
||||
// Per task-spec AC-2: "FT-P-12 is implemented and registered, but
|
||||
// marked Result: QUARANTINE in the CSV report until AC-25 (Phase B)
|
||||
// lands. The test code itself runs (does not just `xit`) and produces
|
||||
// a clear log entry." Today's production code POSTs
|
||||
// `/api/detect/${media.id}` regardless of mediaType (single endpoint
|
||||
// shape), so the assertion below fails. When AC-25 introduces a
|
||||
// separate `/api/detect/video/<id>` POST + SSE pair, this test flips
|
||||
// to PASS automatically.
|
||||
//
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('FT-P-12 awaits AC-25 / async video detect impl')
|
||||
|
||||
const { detectCalls, sseOpens } = captureDetectAndBootstrap({
|
||||
mediaItems: [seedVideoMedia],
|
||||
registerVideoEndpoints: true,
|
||||
detectResponse: { jobId: 12345 },
|
||||
})
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
// Act
|
||||
await selectMediaAndClickDetect(seedVideoMedia.name)
|
||||
|
||||
// Assert — the video-routed POST shape (Phase B) and the SSE handshake.
|
||||
await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
|
||||
expect(detectCalls[0].pathname).toMatch(/^\/api\/detect\/video\/[0-9]+$/)
|
||||
|
||||
// The SSE branch — production today does not call EventSource at all
|
||||
// for detect, so the polling assertion here also fails until AC-25.
|
||||
await waitFor(() => expect(sseOpens.length).toBeGreaterThan(0), { timeout: 2000 })
|
||||
expect(sseOpens[0].url).toMatch(/^\/api\/detect\/stream\/[0-9]+$/)
|
||||
|
||||
clearBearer()
|
||||
},
|
||||
)
|
||||
|
||||
it('control: production posts to /api/detect/<id> regardless of mediaType (single-endpoint drift)', async () => {
|
||||
// Pin the CURRENT (drift) behavior so a regression that, e.g., stops
|
||||
// sending the request at all is caught even before AC-25 lifts the
|
||||
// QUARANTINE. When AC-25 introduces a separate video endpoint, this
|
||||
// control test will need to be adjusted (the pinned URL will change).
|
||||
const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia] })
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
await selectMediaAndClickDetect(seedVideoMedia.name)
|
||||
|
||||
await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
|
||||
// Today: single endpoint, same shape for image and video.
|
||||
expect(detectCalls[0].pathname).toBe(`/api/detect/${NUMERIC_VIDEO_MEDIA_ID}`)
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-3 (FT-P-13) — long-video detect carries `X-Refresh-Token` header', () => {
|
||||
it.fails(
|
||||
'every long-video detect request carries an `X-Refresh-Token` header (drift — production sets only Authorization)',
|
||||
async () => {
|
||||
// Production's `api.post` chain (`src/api/client.ts` request fn) sets
|
||||
// only `Authorization: Bearer <token>` and `Content-Type` for JSON
|
||||
// bodies. `X-Refresh-Token` is NOT added today. This is the documented
|
||||
// Step-4-style drift the task spec calls out ("until F7 lands and
|
||||
// the header is added per Step 4").
|
||||
const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia] })
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
await selectMediaAndClickDetect(seedVideoMedia.name)
|
||||
await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
|
||||
|
||||
// Headers are normalised lower-case via the Headers iterator above.
|
||||
const xRefresh = detectCalls[0].headers['x-refresh-token']
|
||||
expect(xRefresh).toBeDefined()
|
||||
expect(xRefresh).not.toBe('')
|
||||
|
||||
clearBearer()
|
||||
},
|
||||
)
|
||||
|
||||
it('control: production sets only Authorization header on detect (current behavior)', async () => {
|
||||
// This control proves the static check + the spy machinery work today
|
||||
// and would catch a regression that drops Authorization entirely. When
|
||||
// AC-3 flips green via Phase B, this control becomes redundant; the
|
||||
// `it.fails()` above flips and this test still passes (since
|
||||
// Authorization is also expected to remain).
|
||||
const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia] })
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
await selectMediaAndClickDetect(seedVideoMedia.name)
|
||||
await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
|
||||
|
||||
const auth = detectCalls[0].headers['authorization']
|
||||
expect(auth).toBeDefined()
|
||||
expect(auth).toMatch(/^Bearer /)
|
||||
|
||||
clearBearer()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from './msw/server'
|
||||
import { jsonResponse, paginate } from './msw/helpers'
|
||||
import { renderWithProviders, screen, fireEvent, waitFor, act } from './helpers/render'
|
||||
import { seedBearer, clearBearer } from './helpers/auth'
|
||||
import { FlightProvider } from '../src/components/FlightContext'
|
||||
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
|
||||
|
||||
// AZ-470 — Panel-width debounced PUT + rehydration.
|
||||
//
|
||||
// AC-1 (FT-P-37 + NFT-PERF-08): multiple resize events within 1 s yield
|
||||
// exactly ONE outbound PUT (debounce window).
|
||||
// AC-2 (FT-P-37 body): the PUT body carries the `panelWidths` key.
|
||||
// AC-3 (FT-P-38): after reload with `seed_user_settings.panelWidths`
|
||||
// set, the rendered panel widths match the seed.
|
||||
//
|
||||
// Documented drift (entire task is a Phase-B-target group):
|
||||
// `useResizablePanel` today (`src/hooks/useResizablePanel.ts`) only
|
||||
// manages local state — no `useDebounce`-driven PUT on resize-end, no
|
||||
// rehydration from `/api/annotations/settings/user`. All three ACs are
|
||||
// `it.fails()`. They flip green when `useResizablePanel` is wired to
|
||||
// `<UserSettings>`'s save path.
|
||||
//
|
||||
// Each `it.fails()` is paired with a control that pins the CURRENT (no-PUT,
|
||||
// no-rehydration) behavior so a regression that, e.g., starts emitting
|
||||
// duplicate PUTs is visible even before the AC flips green.
|
||||
|
||||
const SEED_LEFT = 280
|
||||
const SEED_RIGHT = 320
|
||||
|
||||
interface CapturedPut {
|
||||
url: string
|
||||
pathname: string
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface PanelRig {
|
||||
puts: CapturedPut[]
|
||||
divider: () => HTMLElement
|
||||
}
|
||||
|
||||
function rigPanelEnv(opts?: { seedSettings?: boolean }): { puts: CapturedPut[] } {
|
||||
const puts: CapturedPut[] = []
|
||||
|
||||
server.use(
|
||||
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||||
http.get('/api/flights', () => jsonResponse(paginate([], 1, 1000))),
|
||||
// The user settings GET — when seedSettings is true, return a payload
|
||||
// that includes both the legacy per-page width fields AND a `panelWidths`
|
||||
// object as defined by the FT-P-37/38 contract. Production today does
|
||||
// not consume either, but a future rehydration implementation could read
|
||||
// either shape; AC-3 asserts the rendered widths equal the seed values
|
||||
// regardless of which shape carries them.
|
||||
http.get('/api/annotations/settings/user', () => {
|
||||
if (opts?.seedSettings) {
|
||||
return jsonResponse({
|
||||
id: 'user-settings-az470',
|
||||
userId: 'user-az470',
|
||||
selectedFlightId: null,
|
||||
annotationsLeftPanelWidth: SEED_LEFT,
|
||||
annotationsRightPanelWidth: SEED_RIGHT,
|
||||
datasetLeftPanelWidth: null,
|
||||
datasetRightPanelWidth: null,
|
||||
panelWidths: {
|
||||
annotationsLeft: SEED_LEFT,
|
||||
annotationsRight: SEED_RIGHT,
|
||||
},
|
||||
})
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
}),
|
||||
http.put('/api/annotations/settings/user', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
puts.push({
|
||||
url: request.url,
|
||||
pathname: new URL(request.url).pathname,
|
||||
body,
|
||||
})
|
||||
return jsonResponse({ id: 'user-settings-az470', ...body })
|
||||
}),
|
||||
http.get('/api/annotations/media', () => jsonResponse(paginate([], 1, 1000))),
|
||||
http.get('/api/annotations/annotations', () => jsonResponse(paginate([], 1, 1000))),
|
||||
http.get('/api/annotations/classes', () => jsonResponse([])),
|
||||
)
|
||||
return { puts }
|
||||
}
|
||||
|
||||
function findDivider(): HTMLElement {
|
||||
// The divider is the `<div>` with `cursor-col-resize` — in <AnnotationsPage>
|
||||
// there are two: between left panel ↔ center, and center ↔ right panel.
|
||||
// We use the first one for AC-1 / AC-2 (the left divider).
|
||||
const dividers = document.querySelectorAll<HTMLElement>('div.cursor-col-resize')
|
||||
if (!dividers.length) throw new Error('No resizable divider found in DOM')
|
||||
return dividers[0]
|
||||
}
|
||||
|
||||
function simulateDrag(divider: HTMLElement, dx: number): void {
|
||||
// Production's `useResizablePanel.onMouseDown` sets `dragging.current=true`
|
||||
// and snapshots `clientX`. The window-level `mousemove` handler updates
|
||||
// width, the window-level `mouseup` handler clears `dragging.current`.
|
||||
fireEvent.mouseDown(divider, { clientX: 100 })
|
||||
fireEvent.mouseMove(window, { clientX: 100 + dx })
|
||||
fireEvent.mouseUp(window, { clientX: 100 + dx })
|
||||
}
|
||||
|
||||
describe('AZ-470 — panel-width debounced PUT + rehydration', () => {
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
describe('AC-1 (FT-P-37 + NFT-PERF-08) — debounce window', () => {
|
||||
it.fails(
|
||||
'multiple resize events within 1 s yield exactly ONE outbound PUT (drift — production never PUTs)',
|
||||
async () => {
|
||||
// Production today emits ZERO PUTs during a resize because
|
||||
// `useResizablePanel` has no settings writer. The assertion below
|
||||
// expects exactly one PUT and therefore fails until Phase B lands the
|
||||
// writer. When the writer arrives, this test flips green automatically.
|
||||
const { puts } = rigPanelEnv()
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
// Wait for the page to render and the divider to appear.
|
||||
await waitFor(() => expect(document.querySelector('div.cursor-col-resize')).toBeTruthy())
|
||||
|
||||
// Act — three back-to-back drag-ends (200 ms apart) within the 1 s
|
||||
// debounce window.
|
||||
const divider = findDivider()
|
||||
await act(async () => {
|
||||
simulateDrag(divider, 30)
|
||||
vi.advanceTimersByTime(200)
|
||||
simulateDrag(divider, 50)
|
||||
vi.advanceTimersByTime(200)
|
||||
simulateDrag(divider, 70)
|
||||
// Push past the debounce ceiling so any debounced PUT has had a
|
||||
// chance to fire.
|
||||
vi.advanceTimersByTime(1100)
|
||||
})
|
||||
|
||||
// Assert — exactly one PUT against the user-settings endpoint.
|
||||
await waitFor(() => expect(puts).toHaveLength(1), { timeout: 1000 })
|
||||
expect(puts[0].pathname).toBe('/api/annotations/settings/user')
|
||||
},
|
||||
)
|
||||
|
||||
it('control: production emits ZERO PUTs during a resize today', async () => {
|
||||
// Pin the current (no-writer) behavior so a regression that, e.g.,
|
||||
// starts firing on every mousemove is visible immediately.
|
||||
const { puts } = rigPanelEnv()
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
await waitFor(() => expect(document.querySelector('div.cursor-col-resize')).toBeTruthy())
|
||||
const divider = findDivider()
|
||||
await act(async () => {
|
||||
simulateDrag(divider, 50)
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
// No writer wired in production → zero PUTs is the pinned drift.
|
||||
expect(puts).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-2 (FT-P-37) — PUT body carries `panelWidths` field', () => {
|
||||
it.fails(
|
||||
'the captured PUT body carries the `panelWidths` field per contract',
|
||||
async () => {
|
||||
// Same drift as AC-1: production never PUTs, so `puts[0].body` does
|
||||
// not exist and the property assertion below throws. The test flips
|
||||
// green when (a) production starts PUTting AND (b) the body contains
|
||||
// `panelWidths`.
|
||||
const { puts } = rigPanelEnv()
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
await waitFor(() => expect(document.querySelector('div.cursor-col-resize')).toBeTruthy())
|
||||
const divider = findDivider()
|
||||
await act(async () => {
|
||||
simulateDrag(divider, 40)
|
||||
vi.advanceTimersByTime(1100)
|
||||
})
|
||||
await waitFor(() => expect(puts).toHaveLength(1), { timeout: 1000 })
|
||||
expect(puts[0].body).toHaveProperty('panelWidths')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('AC-3 (FT-P-38) — rehydration on reload', () => {
|
||||
it.fails(
|
||||
'after boot with a seeded `UserSettings.panelWidths`, the rendered widths match the seed',
|
||||
async () => {
|
||||
// Production's `<AnnotationsPage>` calls `useResizablePanel(250, ...)`
|
||||
// and `useResizablePanel(200, ...)` — the constructor args are the
|
||||
// ONLY width seed. There is no `useEffect` that reads
|
||||
// `/api/annotations/settings/user` and calls `setWidth(seed)`. With
|
||||
// the seed at 280 / 320, the rendered widths therefore stay 250 / 200
|
||||
// until Phase B wires the rehydration.
|
||||
rigPanelEnv({ seedSettings: true })
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
// Wait for the page to settle (auth refresh + flights bootstrap).
|
||||
await waitFor(() => expect(document.querySelector('div.cursor-col-resize')).toBeTruthy())
|
||||
|
||||
// Read the live `style.width` of each panel container. The two
|
||||
// outer panel `<div>`s sit on either side of the dividers; we
|
||||
// identify them by their distinctive `flex flex-col shrink-0`
|
||||
// class chain.
|
||||
const panels = document.querySelectorAll<HTMLElement>('div.bg-az-panel.shrink-0')
|
||||
expect(panels.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const [leftPanel, rightPanel] = [panels[0], panels[panels.length - 1]]
|
||||
// Spec: widths equal seed within ±1 px.
|
||||
const leftWidth = parseFloat(leftPanel.style.width)
|
||||
const rightWidth = parseFloat(rightPanel.style.width)
|
||||
expect(Math.abs(leftWidth - SEED_LEFT)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(rightWidth - SEED_RIGHT)).toBeLessThanOrEqual(1)
|
||||
},
|
||||
)
|
||||
|
||||
it('control: production renders panels at constructor-arg defaults (250 / 200) ignoring seeded settings', async () => {
|
||||
rigPanelEnv({ seedSettings: true })
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<AnnotationsPage />
|
||||
</FlightProvider>,
|
||||
)
|
||||
await waitFor(() => expect(document.querySelector('div.cursor-col-resize')).toBeTruthy())
|
||||
const panels = document.querySelectorAll<HTMLElement>('div.bg-az-panel.shrink-0')
|
||||
const [leftPanel, rightPanel] = [panels[0], panels[panels.length - 1]]
|
||||
// Constructor defaults from `<AnnotationsPage>`: 250 px (left), 200 px (right).
|
||||
expect(parseFloat(leftPanel.style.width)).toBe(250)
|
||||
expect(parseFloat(rightPanel.style.width)).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user