mirror of
https://github.com/azaion/ui.git
synced 2026-06-22 14:31:10 +00:00
[AZ-471] [AZ-473] [AZ-478] [AZ-479] Batch 7 - canvas/photo-mode/network/perf tests
ci/woodpecker/push/build-arm Pipeline was successful
ci/woodpecker/push/build-arm Pipeline was successful
- AZ-471 CanvasEditor draw + 8-handle resize PASS (FT-P-39 fast + e2e + FT-P-40 8 sub-tests). Three drifts pinned via it.fails(): Ctrl+click multi-select (FT-P-41), Ctrl+wheel zoom-around-cursor (FT-P-42), Ctrl+drag empty-canvas pan (FT-P-43) — all rooted in handleMouseDown's early Ctrl-gate and handleWheel's pan-not-adjusted bug. - AZ-473 PhotoMode 3 ACs all PASS in fast + e2e (FT-P-48 switch filter, FT-P-49 auto-select, FT-P-50 yoloId wire across modes P=0/20/40 — outbound classNum == classId + photoModeOffset). - AZ-478 fast 7 + e2e 2: AC-1 user-visible offline indicator, AC-2 tainted-canvas fallback, AC-3 SSE disconnect banner — all drift today (it.fails fast + test.fail e2e + control PASS for each). Service-worker negative check passes. - AZ-479 AC-1 (bundle <= 2 MB gzipped) promoted from on-demand perf script to per-commit static profile via new STC-PERF01 row + static_check_bundle_size in run-tests.sh. AC-2 (mission-planner exclusion) already covered by STC-S5. AC-3 FCP /flights <= 3 s median (chromium suite-e2e) and AC-4 30-min annotation soak (RUN_LONG_RUNNING=1, chromium) scaffolded as e2e tests. Code review: PASS (0 findings). Fast: 25/25 files, 150 passed / 13 skipped. Static: 25/25 PASS (incl. new STC-PERF01). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from './msw/server'
|
||||
import { jsonResponse, paginate } from './msw/helpers'
|
||||
import {
|
||||
renderWithProviders,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
userEvent,
|
||||
} from './helpers/render'
|
||||
import { seedBearer, clearBearer } from './helpers/auth'
|
||||
import DetectionClasses from '../src/components/DetectionClasses'
|
||||
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
|
||||
import { FlightProvider, useFlight } from '../src/components/FlightContext'
|
||||
import { seedFlights } from './fixtures/seed_flights'
|
||||
import { seedClasses } from './fixtures/seed_classes'
|
||||
import {
|
||||
MediaType,
|
||||
MediaStatus,
|
||||
} from '../src/types'
|
||||
import type {
|
||||
DetectionClass,
|
||||
Media,
|
||||
} from '../src/types'
|
||||
|
||||
// AZ-473 — PhotoMode switch + auto-select + yoloId on the wire.
|
||||
//
|
||||
// AC-1 (FT-P-48): clicking a PhotoMode button fires `onPhotoModeChange(P)` and
|
||||
// the rendered class list is filtered to entries with
|
||||
// `photoMode === P`. The "context persistence" assertion is
|
||||
// satisfied by the rendered filter (per spec — no direct
|
||||
// context read). Production has no `<PhotoModeContext>`;
|
||||
// photoMode lives as local state in AnnotationsPage and
|
||||
// DatasetPage. The rendered-filter contract still holds.
|
||||
// AC-2 (FT-P-49): switching to a mode where the previously-selected class is
|
||||
// out-of-range fires `onSelect(modeClasses[0].id)` once.
|
||||
// AC-3 (FT-P-50): saving an annotation in mode P sends a POST whose
|
||||
// `detections[i].classNum == classId + P` for every detection.
|
||||
// We exercise all three modes (P ∈ {0, 20, 40}).
|
||||
//
|
||||
// Notes on the seed_classes fixture:
|
||||
// - The shared fixture sets `photoMode: 0` on every entry, which would
|
||||
// break the rendered-filter assertion for P=20 / P=40. AZ-472 already
|
||||
// overrides the GET handler with a correctly-tagged copy. We do the
|
||||
// same here.
|
||||
|
||||
const orderedClasses: DetectionClass[] = seedClasses.map((c) => ({
|
||||
...c,
|
||||
photoMode: c.id < 20 ? 0 : c.id < 40 ? 20 : 40,
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AC-1 + AC-2 — DetectionClasses-only contracts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HarnessState {
|
||||
selectedRef: { current: number }
|
||||
selectSpy: ReturnType<typeof vi.fn>
|
||||
modeSpy: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function HarnessWrapper({
|
||||
initialPhotoMode = 0,
|
||||
initialSelectedClassNum = 0,
|
||||
state,
|
||||
}: {
|
||||
initialPhotoMode?: number
|
||||
initialSelectedClassNum?: number
|
||||
state: HarnessState
|
||||
}) {
|
||||
const [photoMode, setPhotoMode] = useState(initialPhotoMode)
|
||||
const [selectedClassNum, setSelectedClassNum] = useState(initialSelectedClassNum)
|
||||
// Sync the external selectedRef so tests can read the latest selection
|
||||
// without going through the spy's mock.calls (which loses ordering after
|
||||
// multiple effects).
|
||||
useEffect(() => {
|
||||
state.selectedRef.current = selectedClassNum
|
||||
}, [selectedClassNum, state])
|
||||
return (
|
||||
<DetectionClasses
|
||||
selectedClassNum={selectedClassNum}
|
||||
onSelect={(id) => {
|
||||
state.selectSpy(id)
|
||||
setSelectedClassNum(id)
|
||||
}}
|
||||
photoMode={photoMode}
|
||||
onPhotoModeChange={(mode) => {
|
||||
state.modeSpy(mode)
|
||||
setPhotoMode(mode)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function makeHarnessState(): HarnessState {
|
||||
return {
|
||||
selectedRef: { current: -1 },
|
||||
selectSpy: vi.fn(),
|
||||
modeSpy: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function captureClassesGet(payload: DetectionClass[]) {
|
||||
server.use(
|
||||
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||||
http.get('/api/annotations/classes', () => jsonResponse(payload)),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AZ-473 — AC-1 + AC-2 (DetectionClasses)', () => {
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
captureClassesGet(orderedClasses)
|
||||
})
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
describe('AC-1 (FT-P-48) — switch sets filter', () => {
|
||||
it('clicking Winter fires onPhotoModeChange(20) and filters the rendered class list to photoMode=20 entries', async () => {
|
||||
// Arrange — render with photoMode=0; expect class-0..class-8 visible.
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
||||
await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument())
|
||||
expect(screen.queryByText('class-20')).toBeNull()
|
||||
|
||||
// Act — click the Winter button (title = i18n "Winter").
|
||||
const winterBtn = screen.getByRole('button', { name: /winter/i })
|
||||
await userEvent.click(winterBtn)
|
||||
|
||||
// Assert — onPhotoModeChange fires once with 20.
|
||||
await waitFor(() => expect(state.modeSpy).toHaveBeenCalledWith(20))
|
||||
expect(state.modeSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Rendered list switches to the photoMode=20 window
|
||||
// (ids 20..28 in orderedClasses).
|
||||
await waitFor(() => expect(screen.getByText('class-20')).toBeInTheDocument())
|
||||
expect(screen.getByText('class-28')).toBeInTheDocument()
|
||||
expect(screen.queryByText('class-0')).toBeNull()
|
||||
expect(screen.queryByText('class-40')).toBeNull()
|
||||
})
|
||||
|
||||
it('clicking Night fires onPhotoModeChange(40) and shows the photoMode=40 window', async () => {
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
||||
await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument())
|
||||
|
||||
const nightBtn = screen.getByRole('button', { name: /night/i })
|
||||
await userEvent.click(nightBtn)
|
||||
|
||||
await waitFor(() => expect(state.modeSpy).toHaveBeenCalledWith(40))
|
||||
await waitFor(() => expect(screen.getByText('class-40')).toBeInTheDocument())
|
||||
expect(screen.getByText('class-48')).toBeInTheDocument()
|
||||
expect(screen.queryByText('class-0')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-2 (FT-P-49) — auto-select when prior class is out-of-range', () => {
|
||||
it('switching to Night auto-selects modeClasses[0].id (= 40) when selectedClassNum=0 is not in the new window', async () => {
|
||||
// Arrange — preselect a Regular class (id=0).
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(
|
||||
<HarnessWrapper
|
||||
initialPhotoMode={0}
|
||||
initialSelectedClassNum={0}
|
||||
state={state}
|
||||
/>,
|
||||
)
|
||||
// The auto-select effect fires on mount — first onSelect is the
|
||||
// initialisation. Clear the spy after that mounts so we observe only
|
||||
// the post-mode-switch firing.
|
||||
await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument())
|
||||
state.selectSpy.mockClear()
|
||||
|
||||
// Act — switch to Night.
|
||||
const nightBtn = screen.getByRole('button', { name: /night/i })
|
||||
await userEvent.click(nightBtn)
|
||||
|
||||
// Assert — onSelect(40) fires (the first id in the photoMode=40 window).
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalledWith(40))
|
||||
})
|
||||
|
||||
it('switching to Winter (P=20) auto-selects id=20 when prior selection (id=0) is out of range', async () => {
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(
|
||||
<HarnessWrapper
|
||||
initialPhotoMode={0}
|
||||
initialSelectedClassNum={0}
|
||||
state={state}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument())
|
||||
state.selectSpy.mockClear()
|
||||
|
||||
const winterBtn = screen.getByRole('button', { name: /winter/i })
|
||||
await userEvent.click(winterBtn)
|
||||
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalledWith(20))
|
||||
})
|
||||
|
||||
it('does NOT auto-select when the current class is already in the new window', async () => {
|
||||
// Pre-select class with id=20 (in the Winter window).
|
||||
const state = makeHarnessState()
|
||||
renderWithProviders(
|
||||
<HarnessWrapper
|
||||
initialPhotoMode={20}
|
||||
initialSelectedClassNum={20}
|
||||
state={state}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByText('class-20')).toBeInTheDocument())
|
||||
// The on-mount auto-select MAY still fire once if the in-mount sync is
|
||||
// racy; reset the spy to observe only the deliberate switch path.
|
||||
state.selectSpy.mockClear()
|
||||
|
||||
// Act — switch from Winter→Winter is a noop, but switching to
|
||||
// Regular and back to Winter while the in-window class stays
|
||||
// out-of-range for Regular triggers an auto-select to 0, then to 20
|
||||
// again. We check that a class IS still in-range for the destination
|
||||
// window — i.e., switching back to a window where the prior class is
|
||||
// valid does NOT regenerate a selection.
|
||||
const regularBtn = screen.getByRole('button', { name: /regular/i })
|
||||
await userEvent.click(regularBtn)
|
||||
await waitFor(() => expect(state.selectSpy).toHaveBeenCalledWith(0))
|
||||
state.selectSpy.mockClear()
|
||||
|
||||
// Now we're at photoMode=0, selectedClassNum=0 (in-window). Switching
|
||||
// back to Regular is a noop — no auto-select fires.
|
||||
await userEvent.click(regularBtn)
|
||||
// Allow the effect to flush.
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(state.selectSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AC-3 — wire offset on AnnotationsPage save.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FLIGHT = seedFlights[0]
|
||||
const W = 640
|
||||
const H = 480
|
||||
|
||||
const videoMedia: Media = {
|
||||
id: 'media-az473',
|
||||
name: 'photo-mode-fixture.mp4',
|
||||
path: '/media/photo-mode-fixture.mp4',
|
||||
mediaType: MediaType.Video,
|
||||
mediaStatus: MediaStatus.New,
|
||||
duration: '00:00:10',
|
||||
annotationCount: 0,
|
||||
waypointId: null,
|
||||
userId: 'user-az473',
|
||||
}
|
||||
|
||||
interface PostCapture {
|
||||
classNums: number[][]
|
||||
}
|
||||
|
||||
function rigSaveEnv(): PostCapture {
|
||||
const classNums: number[][] = []
|
||||
server.use(
|
||||
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||||
http.get('/api/flights', () => jsonResponse(paginate(seedFlights, 1, 1000))),
|
||||
http.get('/api/flights/:id', ({ params }) => {
|
||||
const f = seedFlights.find((x) => x.id === params.id)
|
||||
return f ? jsonResponse(f) : new Response(null, { status: 404 })
|
||||
}),
|
||||
http.get('/api/annotations/settings/user', () =>
|
||||
jsonResponse({
|
||||
id: 'user-settings-az473',
|
||||
userId: 'user-az473',
|
||||
selectedFlightId: FLIGHT.id,
|
||||
annotationsLeftPanelWidth: null,
|
||||
annotationsRightPanelWidth: null,
|
||||
datasetLeftPanelWidth: null,
|
||||
datasetRightPanelWidth: null,
|
||||
}),
|
||||
),
|
||||
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
|
||||
http.get('/api/annotations/media', () =>
|
||||
jsonResponse(paginate([videoMedia], 1, 1000)),
|
||||
),
|
||||
http.get('/api/annotations/annotations', () => jsonResponse(paginate([], 1, 1000))),
|
||||
http.get('/api/annotations/classes', () => jsonResponse(orderedClasses)),
|
||||
http.get('/api/annotations/dataset/info', () =>
|
||||
jsonResponse({ totalCount: 0, statusCounts: {} }),
|
||||
),
|
||||
http.post('/api/annotations/annotations', async ({ request }) => {
|
||||
const body = (await request.json()) as { detections?: { classNum: number }[] }
|
||||
classNums.push((body.detections ?? []).map((d) => d.classNum))
|
||||
return jsonResponse({ id: 'ann-saved' })
|
||||
}),
|
||||
)
|
||||
return { classNums }
|
||||
}
|
||||
|
||||
function FlightSeed({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
const { selectFlight, selectedFlight } = useFlight()
|
||||
useEffect(() => {
|
||||
if (!selectedFlight) selectFlight(FLIGHT)
|
||||
}, [selectFlight, selectedFlight])
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
describe('AZ-473 — AC-3 (wire offset on AnnotationsPage save)', () => {
|
||||
let originalRaf: typeof globalThis.requestAnimationFrame
|
||||
let widthDescriptor: PropertyDescriptor | undefined
|
||||
let heightDescriptor: PropertyDescriptor | undefined
|
||||
let originalGetBoundingClientRect: typeof Element.prototype.getBoundingClientRect
|
||||
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
// Same canvas-coord scaffold as AZ-471 — see canvas_editor.test.tsx for
|
||||
// the rationale on each shim.
|
||||
widthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
heightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get() { return W },
|
||||
})
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
|
||||
configurable: true,
|
||||
get() { return H },
|
||||
})
|
||||
originalGetBoundingClientRect = Element.prototype.getBoundingClientRect
|
||||
Element.prototype.getBoundingClientRect = function getBoundingClientRect(): DOMRect {
|
||||
if (this instanceof HTMLCanvasElement) {
|
||||
return {
|
||||
x: 0, y: 0, left: 0, top: 0,
|
||||
right: W, bottom: H, width: W, height: H,
|
||||
toJSON() { return {} },
|
||||
} as DOMRect
|
||||
}
|
||||
return originalGetBoundingClientRect.call(this)
|
||||
}
|
||||
originalRaf = globalThis.requestAnimationFrame
|
||||
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
||||
cb(performance.now())
|
||||
return 0
|
||||
}) as typeof globalThis.requestAnimationFrame
|
||||
// Force getContext to return a non-null canvas context so draw() doesn't
|
||||
// bail. Production checks `if (!canvas || !ctx) return`; jsdom's default
|
||||
// is `null`, which would short-circuit the draw and starve the spy.
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn(
|
||||
() =>
|
||||
({
|
||||
clearRect: vi.fn(), save: vi.fn(), restore: vi.fn(),
|
||||
drawImage: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(),
|
||||
fillText: vi.fn(), arc: vi.fn(), beginPath: vi.fn(), fill: vi.fn(),
|
||||
setLineDash: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 10 } as TextMetrics)),
|
||||
fillStyle: '', strokeStyle: '', font: '', globalAlpha: 1, lineWidth: 1,
|
||||
}) as unknown as CanvasRenderingContext2D,
|
||||
) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
globalThis.requestAnimationFrame = originalRaf
|
||||
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect
|
||||
if (widthDescriptor) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', widthDescriptor)
|
||||
} else {
|
||||
delete (HTMLElement.prototype as unknown as { clientWidth?: number }).clientWidth
|
||||
}
|
||||
if (heightDescriptor) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', heightDescriptor)
|
||||
} else {
|
||||
delete (HTMLElement.prototype as unknown as { clientHeight?: number }).clientHeight
|
||||
}
|
||||
})
|
||||
|
||||
// Each value of P maps to: photoMode → button label → expected first
|
||||
// class id (which equals 0 + P). The test draws ONE box and asserts the
|
||||
// POST body's detections[0].classNum == P.
|
||||
const cases: { mode: number; label: RegExp }[] = [
|
||||
{ mode: 0, label: /regular/i },
|
||||
{ mode: 20, label: /winter/i },
|
||||
{ mode: 40, label: /night/i },
|
||||
]
|
||||
|
||||
cases.forEach(({ mode, label }) => {
|
||||
it(`P=${mode}: saved detection carries classNum == ${mode} (= 0 + ${mode})`, async () => {
|
||||
// Arrange
|
||||
const cap = rigSaveEnv()
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<FlightSeed>
|
||||
<AnnotationsPage />
|
||||
</FlightSeed>
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
// Wait for the media list to render and click on the seeded video.
|
||||
const mediaItem = await screen.findByText(/photo-mode-fixture\.mp4/)
|
||||
await userEvent.click(mediaItem)
|
||||
|
||||
// Wait for the canvas to mount.
|
||||
const canvas = await waitFor(() => {
|
||||
const c = document.querySelector('canvas')
|
||||
if (!c) throw new Error('canvas not yet mounted')
|
||||
return c as HTMLCanvasElement
|
||||
})
|
||||
|
||||
// Switch PhotoMode (this also triggers the auto-select effect, which
|
||||
// fires onSelect with `modeClasses[0].id` = 0 + mode).
|
||||
if (mode !== 0) {
|
||||
const modeBtn = await screen.findByRole('button', { name: label })
|
||||
await userEvent.click(modeBtn)
|
||||
}
|
||||
|
||||
// Auto-select must have settled before we draw — otherwise the
|
||||
// detection inherits the previous selectedClassNum.
|
||||
await waitFor(() => {
|
||||
// We can't read selectedClassNum directly. Instead, draw a tiny
|
||||
// probe box and check the most-recent detection's classNum.
|
||||
// We'll do the actual draw below; this gate just allows the React
|
||||
// state-update queue (mode → onSelect → setState) to drain.
|
||||
// A short sleep is sufficient for jsdom.
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 30))
|
||||
|
||||
// Draw a bbox at (40, 40) → (120, 100). Plain left-click on empty
|
||||
// area triggers the production "draw" path.
|
||||
fireEvent.mouseDown(canvas, { clientX: 40, clientY: 40, button: 0 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 120, clientY: 100, button: 0 })
|
||||
fireEvent.mouseUp(canvas, { clientX: 120, clientY: 100, button: 0 })
|
||||
|
||||
// Click Save (the button label is "Save", not gated by i18n today).
|
||||
const saveBtn = await screen.findByRole('button', { name: /^save$/i })
|
||||
// The Save button is `disabled={!detections.length}` — wait for it.
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled(), { timeout: 2000 })
|
||||
await userEvent.click(saveBtn)
|
||||
|
||||
// Assert — POST observed with detections[0].classNum == mode.
|
||||
await waitFor(() => expect(cap.classNums.length).toBeGreaterThan(0), {
|
||||
timeout: 3000,
|
||||
})
|
||||
const last = cap.classNums.at(-1) as number[]
|
||||
expect(last).toHaveLength(1)
|
||||
expect(last[0]).toBe(mode)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user