mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 09:41:11 +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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user