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