import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { http, HttpResponse } from 'msw' import { server } from './msw/server' import { jsonResponse } from './msw/helpers' import { renderWithProviders, screen, fireEvent, waitFor, } from './helpers/render' import { seedBearer, clearBearer } from './helpers/auth' import { FlightProvider } from '../src/components' import { DatasetPage } from '../src/features/dataset' import { AnnotationSource, AnnotationStatus, Affiliation, CombatReadiness, MediaType, } from '../src/types' import type { AnnotationListItem, DatasetItem } from '../src/types' // AZ-474 — tile-split + YOLO parser + auto-zoom + indicator + malformed. // // Production reality: the split UI is QUARANTINED today (per // `_docs/04_refactoring/01-testability-refactoring/deferred_to_refactor.md` // row D11; traceability matrix marks AC-39 / FT-P-51 [Q]). // // - There is no Split-tile button on `` and no // `POST /api/annotations/dataset/{id}/split` callsite anywhere in // `src/`. // - There is no YOLO label parser module and no `` / // auto-zoom viewport / tile-zoom indicator. // - `DatasetItem.isSplit: boolean` is on the type and surfaces from // `GET /api/annotations/dataset`, but `DatasetPage` does not read it // (it reads `isSeed` for the red-ring affordance instead). // // Every AC is therefore a documented drift today: the AC tests use // `it.fails()` (and one `test.fail()` for the e2e) and each is paired // with a control PASS that pins the *current* behaviour, so a regression // that *removes* the placeholder (e.g., `DatasetPage` starts crashing on // `isSplit: true`) is caught immediately, and the contract tests flip // green automatically once the split surface lands in Phase B. // --------------------------------------------------------------------------- // Shared MSW seeds: a happy-path split annotation, a malformed-label one, // and a non-split. The dataset-list shape mirrors `DatasetItem` exactly. // --------------------------------------------------------------------------- const happySplitAnnotation: AnnotationListItem = { id: 'ann-split-happy', mediaId: 'media-tile', time: null, createdDate: '2026-05-11T10:00:00Z', userId: 'user-alice', source: AnnotationSource.AI, status: AnnotationStatus.Edited, isSplit: true, splitTile: '3 0.5 0.5 0.2 0.2', detections: [ { id: 'det-tile-1', classNum: 3, label: 'class-3', confidence: 0.91, affiliation: Affiliation.Hostile, combatReadiness: CombatReadiness.Ready, centerX: 0.5, centerY: 0.5, width: 0.2, height: 0.2, }, ], } const malformedSplitAnnotation: AnnotationListItem = { id: 'ann-split-malformed', mediaId: 'media-tile', time: null, createdDate: '2026-05-11T10:01:00Z', userId: 'user-alice', source: AnnotationSource.AI, status: AnnotationStatus.Edited, isSplit: true, splitTile: 'garbage', detections: [], } const nonSplitAnnotation: AnnotationListItem = { id: 'ann-not-split', mediaId: 'media-tile', time: null, createdDate: '2026-05-11T10:02:00Z', userId: 'user-alice', source: AnnotationSource.Manual, status: AnnotationStatus.Created, isSplit: false, splitTile: null, detections: [], } function datasetRowFromAnnotation(a: AnnotationListItem): DatasetItem { return { annotationId: a.id, imageName: `image-${a.mediaId}-${a.id}.jpg`, thumbnailPath: `/thumbs/${a.mediaId}.jpg`, status: a.status, createdDate: a.createdDate, createdEmail: 'op_alice@test.local', flightName: 'Flight 1', source: a.source, isSeed: false, isSplit: a.isSplit, } } beforeEach(() => { seedBearer() server.use( http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })), // FlightProvider mounts a user-settings fetch when authenticated. The // dataset surface does not depend on it; we satisfy MSW's unhandled- // request gate with a 404 so the noise does not pollute the report. http.get('/api/annotations/settings/user', () => new Response(null, { status: 404 })), http.get('/api/annotations/dataset', () => jsonResponse({ items: [ datasetRowFromAnnotation(happySplitAnnotation), datasetRowFromAnnotation(malformedSplitAnnotation), datasetRowFromAnnotation(nonSplitAnnotation), ], totalCount: 3, }), ), ) }) afterEach(() => { clearBearer() vi.restoreAllMocks() }) // --------------------------------------------------------------------------- // AC-1 — tile-split endpoint contract (FT-P-51, [Q]). // --------------------------------------------------------------------------- describe('AZ-474 — AC-1 (FT-P-51 [Q]): tile-split endpoint contract', () => { it.fails( 'splitting a tile sends POST /api/annotations/dataset//split', async () => { // Arrange — capture POSTs to the split endpoint. Production has no // such callsite today, so this MSW handler will never fire and the // assertion fails. Once the SPA wires a "Split tile" affordance, // this test flips green. const splitPosts: { url: string; body: unknown }[] = [] server.use( http.post('/api/annotations/dataset/:id/split', async ({ request, params }) => { splitPosts.push({ url: request.url, body: await request.json().catch(() => null) }) return jsonResponse({ id: params.id, ok: true }, { status: 200 }) }), ) renderWithProviders( , ) // Act — wait for the dataset to render, then look for the Split-tile // affordance. The locator is intentionally generic (any button or // role with "split" in its accessible name) so the test passes for // any reasonable implementation choice in Phase B. await waitFor( () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0), { timeout: 3000 }, ) const splitBtn = await screen.findByRole( 'button', { name: /split/i }, { timeout: 1000 }, ) fireEvent.click(splitBtn) // Assert — exactly one POST to /api/annotations/dataset//split. await waitFor(() => expect(splitPosts.length).toBe(1), { timeout: 1000 }) expect(splitPosts[0].url).toMatch( /\/api\/annotations\/dataset\/[^/]+\/split$/, ) }, ) it('control: today no Split-tile affordance is rendered (drift snapshot)', async () => { renderWithProviders( , ) await waitFor( () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0), { timeout: 3000 }, ) expect(screen.queryByRole('button', { name: /split/i })).toBeNull() }) }) // --------------------------------------------------------------------------- // AC-2 — YOLO label parser happy path (FT-P-52). // --------------------------------------------------------------------------- describe('AZ-474 — AC-2 (FT-P-52): YOLO label parser happy path', () => { it.fails( 'a parser module parses "3 0.5 0.5 0.2 0.2" into the canonical 5-tuple', async () => { // Black-box discipline note: the spec's "parser module" does not // exist yet. The right way to test this once it ships is via the // public surface (rendered tile rect, downstream save body, etc.), // not via a direct import of the parser. For now the test fails // because there IS no public surface that consumes splitTile. // // Production behaviour today: double-click loads // /api/annotations/dataset/ (the full AnnotationListItem with // splitTile) but the editor never reads splitTile. So the parser // is not exercised by ANY user-visible action, and there is no // observable to assert against. // // We render the full DatasetPage, double-click the happy-split // annotation row, and look for the parsed tile rect being applied // to a TileViewer. Today no TileViewer mounts and no rect is // produced — the test fails as drift. server.use( http.get( `/api/annotations/dataset/${happySplitAnnotation.id}`, () => jsonResponse(happySplitAnnotation), ), ) renderWithProviders( , ) await waitFor( () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0), { timeout: 3000 }, ) const happyImg = screen.getByAltText( new RegExp(`image-${happySplitAnnotation.mediaId}-${happySplitAnnotation.id}`), ) fireEvent.doubleClick(happyImg.closest('div')!) // The parsed tile rect should be exposed via a `data-tile-rect` // attribute on the TileViewer mount, e.g. "0.4,0.4,0.6,0.6" // (cx-w/2, cy-h/2, cx+w/2, cy+h/2) for input "3 0.5 0.5 0.2 0.2". // No such element exists today. const rect = await screen.findByTestId('tile-rect', {}, { timeout: 1500 }) expect(rect.getAttribute('data-tile-rect')).toBe('0.4,0.4,0.6,0.6') }, ) it('control: today the editor mounts without parsing splitTile', async () => { server.use( http.get( `/api/annotations/dataset/${happySplitAnnotation.id}`, () => jsonResponse(happySplitAnnotation), ), ) renderWithProviders( , ) await waitFor( () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0), { timeout: 3000 }, ) const happyImg = screen.getByAltText( new RegExp(`image-${happySplitAnnotation.mediaId}-${happySplitAnnotation.id}`), ) fireEvent.doubleClick(happyImg.closest('div')!) // Pin: no tile-rect testid is present today. expect(screen.queryByTestId('tile-rect')).toBeNull() }) }) // --------------------------------------------------------------------------- // AC-3 — DatasetItem.isSplit honored on the dataset list (FT-P-53). // --------------------------------------------------------------------------- describe('AZ-474 — AC-3 (FT-P-53): DatasetItem.isSplit honored on dataset list', () => { it.fails( 'items with isSplit: true render a split affordance distinct from non-split items', async () => { renderWithProviders( , ) await waitFor( () => expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(3), { timeout: 3000 }, ) // Spec: the rendered card for an isSplit annotation MUST carry a // visible affordance that the non-split card does NOT carry. The // simplest acceptable shape is `data-is-split="true"` on the card // root, but a localized badge / icon would also satisfy this. const happyCard = screen .getByAltText( new RegExp(`image-${happySplitAnnotation.mediaId}-${happySplitAnnotation.id}`), ) .closest('div') const nonSplitCard = screen .getByAltText( new RegExp(`image-${nonSplitAnnotation.mediaId}-${nonSplitAnnotation.id}`), ) .closest('div') // Drift today: isSplit is read from the network shape but never // consumed by the renderer. expect(happyCard?.getAttribute('data-is-split')).toBe('true') expect(nonSplitCard?.getAttribute('data-is-split')).not.toBe('true') }, ) it('control: dataset list mounts and renders all rows even with mixed isSplit values', async () => { renderWithProviders( , ) // Pin: page renders both split and non-split items without crash. await waitFor( () => expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(3), { timeout: 3000 }, ) }) }) // --------------------------------------------------------------------------- // AC-4 — auto-zoom viewport matches tile rect (FT-P-54). // --------------------------------------------------------------------------- describe('AZ-474 — AC-4 (FT-P-54): tile auto-zoom viewport matches tile rect', () => { it.fails('opening a split annotation auto-zooms the viewport to the tile rect', async () => { server.use( http.get( `/api/annotations/dataset/${happySplitAnnotation.id}`, () => jsonResponse(happySplitAnnotation), ), ) renderWithProviders( , ) await waitFor( () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0), { timeout: 3000 }, ) const happyImg = screen.getByAltText( new RegExp(`image-${happySplitAnnotation.mediaId}-${happySplitAnnotation.id}`), ) fireEvent.doubleClick(happyImg.closest('div')!) // Spec: the viewport rect (in normalized canvas coords) should match // the parsed tile rect — for "3 0.5 0.5 0.2 0.2" → [0.4, 0.4, 0.6, 0.6] // ±0.5 px after rendering. We expose this via a `data-viewport-rect` // attribute on the canvas mount. const viewport = await screen.findByTestId( 'tile-viewport', {}, { timeout: 1500 }, ) const rect = viewport.getAttribute('data-viewport-rect') ?? '' const [x1, y1, x2, y2] = rect.split(',').map(Number) expect(Math.abs(x1 - 0.4)).toBeLessThan(0.01) expect(Math.abs(y1 - 0.4)).toBeLessThan(0.01) expect(Math.abs(x2 - 0.6)).toBeLessThan(0.01) expect(Math.abs(y2 - 0.6)).toBeLessThan(0.01) }) it('control: today no tile-viewport testid is exposed', () => { renderWithProviders( , ) expect(screen.queryByTestId('tile-viewport')).toBeNull() }) }) // --------------------------------------------------------------------------- // AC-5 — zoom indicator visible while active (FT-P-55). // --------------------------------------------------------------------------- describe('AZ-474 — AC-5 (FT-P-55): tile-zoom indicator visible while active', () => { it.fails( 'while zoomed into a tile, the indicator carries an accessible name', async () => { server.use( http.get( `/api/annotations/dataset/${happySplitAnnotation.id}`, () => jsonResponse(happySplitAnnotation), ), ) renderWithProviders( , ) await waitFor( () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0), { timeout: 3000 }, ) const happyImg = screen.getByAltText( new RegExp(`image-${happySplitAnnotation.mediaId}-${happySplitAnnotation.id}`), ) fireEvent.doubleClick(happyImg.closest('div')!) // Spec: an indicator with role="status" and an accessible name // matching the i18n-keyed "Tile zoom" text (or equivalent) is in // the DOM while the zoom is active. const indicator = await screen.findByRole( 'status', { name: /tile|zoom/i }, { timeout: 1500 }, ) expect(indicator).toBeInTheDocument() }, ) it('control: today no role="status" + name=/tile|zoom/ indicator is mounted', () => { renderWithProviders( , ) // Pin: there may be other role=status nodes (spinners), but none with // a tile/zoom accessible name. expect(screen.queryByRole('status', { name: /tile|zoom/i })).toBeNull() }) }) // --------------------------------------------------------------------------- // AC-6 — malformed YOLO label surfaces a user-visible error (FT-N-10). // --------------------------------------------------------------------------- describe('AZ-474 — AC-6 (FT-N-10): malformed YOLO label produces user-visible error', () => { it.fails( 'opening an annotation with splitTile="garbage" renders an in-DOM error and no NaN bbox', async () => { server.use( http.get( `/api/annotations/dataset/${malformedSplitAnnotation.id}`, () => jsonResponse(malformedSplitAnnotation), ), ) const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined) try { renderWithProviders( , ) await waitFor( () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0), { timeout: 3000 }, ) const malformedImg = screen.getByAltText( new RegExp( `image-${malformedSplitAnnotation.mediaId}-${malformedSplitAnnotation.id}`, ), ) fireEvent.doubleClick(malformedImg.closest('div')!) // Spec: user-visible error surfaces — a role="alert" region or a // localized toast — and NO bbox is rendered for the malformed label. // alert() is forbidden by NFT-SEC-07; the assertion below pins that. const alertEl = await screen.findByRole('alert', {}, { timeout: 1500 }) expect(alertEl).toBeInTheDocument() expect(alertSpy).not.toHaveBeenCalled() // No NaN-rendered box: every rendered bbox stroke produces finite // getBoundingClientRect values. We check via canvas geometry — // CanvasEditor draws into a 2D context, so any NaN coords would // have made the canvas blank or thrown — neither is observable // post-fix because the page should refuse to render the tile and // surface the alert instead. const canvases = document.querySelectorAll('canvas') for (const c of canvases) { const rect = c.getBoundingClientRect() expect(Number.isFinite(rect.width)).toBe(true) expect(Number.isFinite(rect.height)).toBe(true) } } finally { alertSpy.mockRestore() } }, ) it('control: today the page does NOT crash on a malformed splitTile (silent swallow)', async () => { server.use( http.get( `/api/annotations/dataset/${malformedSplitAnnotation.id}`, () => jsonResponse(malformedSplitAnnotation), ), ) renderWithProviders( , ) await waitFor( () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0), { timeout: 3000 }, ) const malformedImg = screen.getByAltText( new RegExp( `image-${malformedSplitAnnotation.mediaId}-${malformedSplitAnnotation.id}`, ), ) fireEvent.doubleClick(malformedImg.closest('div')!) // Pin: page stays mounted; no role="alert" is rendered today. expect(screen.queryByRole('alert')).toBeNull() }) it('control: alert() is never called from the dataset double-click path', async () => { server.use( http.get( `/api/annotations/dataset/${malformedSplitAnnotation.id}`, () => jsonResponse(malformedSplitAnnotation), ), ) const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined) try { renderWithProviders( , ) await waitFor( () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0), { timeout: 3000 }, ) const malformedImg = screen.getByAltText( new RegExp( `image-${malformedSplitAnnotation.mediaId}-${malformedSplitAnnotation.id}`, ), ) fireEvent.doubleClick(malformedImg.closest('div')!) // Defence in depth (NFT-SEC-07): alert() is banned outside the // seeded allow-list. This control passes today (no alert) AND // continues to pass after the fix lands (which uses an in-DOM alert). expect(alertSpy).not.toHaveBeenCalled() } finally { alertSpy.mockRestore() } }) })