# Blackbox Tests Every test is observed at the SPA's public surface — DOM, ARIA, outbound network, EventSource state, browser storage, console — never via `src/` imports beyond the typed wire-contract enums (`P9`). Each test trails an `Expected result source: results_report.md row N` line; the comparison method, tolerance, and reference file for the assertion come from that row. Profile (`fast` / `e2e` / `static`) decides which runner picks the test up — see `environment.md`. ## Positive Scenarios ### FT-P-01: Bootstrap refresh sends credentials:'include' **Summary**: On `` init, the bootstrap refresh call includes the HttpOnly refresh cookie. **Traces to**: AC-01 **Category**: Auth — bootstrap **Profile**: fast **Preconditions**: - Refresh cookie present in the browser jar (test stubs it on the test domain). - No bearer in memory. **Input data**: app mount in a fresh browser session — `results_report.md` row 02. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Mount `` (test renderer or e2e navigation to `/`) | Outbound `fetch` to `/api/admin/auth/refresh` is observed | | 2 | Inspect the `RequestInit` of that fetch | `credentials === 'include'`; cookie sent by the test runtime | **Expected outcome**: see row 02 (exact: `credentials: 'include'`). **Max execution time**: 5s. **Expected result source**: `results_report.md` row 02. --- ### FT-P-02: 401-retry sequence — refresh and retry the original request **Summary**: A 401 on an authenticated request triggers `POST /api/admin/auth/refresh` (cookie-bound) then a retry of the original request with the new bearer. **Traces to**: AC-01, AC-23 **Profile**: fast (with MSW) and e2e **Preconditions**: - Authenticated session active; bearer about to expire. **Input data**: any authenticated `GET /api/admin/*` issued via `apiClient` — `results_report.md` row 03. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Trigger an authenticated call whose first response is 401 (stub) | The SPA issues `POST /api/admin/auth/refresh` with `credentials:'include'` | | 2 | Refresh stub returns 200 with new bearer | The SPA retries the original request with the new bearer | | 3 | Inspect the final response observed by the calling code | 200 (success) | **Expected outcome**: sequence exactly per row 03; exactly one refresh call per refresh cycle (row 12). **Max execution time**: 5s. **Expected result source**: `results_report.md` rows 03, 12. --- ### FT-P-03: Refresh transparency — `` does not unmount **Summary**: Auth refresh occurring mid-session does not unmount the routed view. **Traces to**: AC-23 **Profile**: fast **Preconditions**: - User on `/flights`; bearer about to expire. **Input data**: in-flight refresh while `` is mounted — `results_report.md` row 11. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Mount `/flights`; record render counter on `` | initial render = 1 | | 2 | Force a 401 → refresh → retry cycle | refresh handled | | 3 | Inspect render counter post-refresh | delta ≤ 1 re-render | **Expected outcome**: row 11 — `` children stay mounted; ≤1 re-render delta. **Max execution time**: 5s. **Expected result source**: `results_report.md` row 11. --- ### FT-P-04: AnnotationStatus enum on the wire **Summary**: Outbound annotation save body's `status` is a number from the spec set `{0,10,20,30,40}`. **Traces to**: AC-04 **Profile**: fast (static + payload assertion) **Preconditions**: `_docs/00_problem/input_data/enum_spec_snapshot.json` is up-to-date (Phase 3 gate). **Input data**: `POST /api/annotations/annotations` body captured at save — `results_report.md` row 18; also static check of `src/types/index.ts` per row 14. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Static read `AnnotationStatus` from `src/types/index.ts` | `{None:0, Created:10, Edited:20, Validated:30, Deleted:40}` | | 2 | Trigger an annotation save in `` (test mount) | `POST /api/annotations/annotations` issued | | 3 | Inspect body | `body.status ∈ {0,10,20,30,40}` | **Expected outcome**: rows 14 (enum map) and 18 (wire payload). **Max execution time**: 3s. **Expected result source**: `results_report.md` rows 14, 18. --- ### FT-P-05: MediaStatus / Affiliation / CombatReadiness enums match the spec **Summary**: Each enum in `src/types/index.ts` matches the spec member set and numeric values pinned in `enum_spec_snapshot.json`. **Traces to**: AC-04 **Profile**: static **Preconditions**: snapshot present (Phase 3 gate). **Input data**: static read of `src/types/index.ts` against the snapshot — `results_report.md` rows 15, 16, 17. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Parse `src/types/index.ts` for `enum MediaStatus`, `Affiliation`, `CombatReadiness` | parsed without error | | 2 | Compare member sets to snapshot | sets equal per row | | 3 | Compare numeric values member-by-member | equal per row | **Expected outcome**: rows 15, 16, 17 (set_contains + exact). **Max execution time**: 1s. **Expected result source**: `results_report.md` rows 15, 16, 17. --- ### FT-P-06: Detection wire payload — affiliation + combatReadiness in spec value sets **Summary**: Every `Detection` element of an outbound annotation save has valid enum members. **Traces to**: AC-04 **Profile**: fast **Preconditions**: snapshot present; an annotation with N detections about to be saved. **Input data**: `POST /api/annotations/annotations` body — `results_report.md` row 19. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Save an annotation with `N≥2` detections, mixed affiliations and readiness | request observed | | 2 | Inspect every `detections[i]` | `affiliation` and `combatReadiness` are in spec value sets | **Expected outcome**: row 19. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 19. --- ### FT-P-07: Annotation save endpoint URL is doubly-prefixed **Summary**: Save POSTs to `/api/annotations/annotations`, not the single-prefix path. **Traces to**: AC-05 **Profile**: fast and e2e **Preconditions**: user is editing an annotation in ``. **Input data**: click Save — `results_report.md` row 22. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Trigger Save | exactly one POST observed | | 2 | Inspect URL | matches `^/api/annotations/annotations$` | **Expected outcome**: row 22. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 22. --- ### FT-P-08: Annotation save body contains all required fields **Summary**: Save body has `{Source, WaypointId, videoTime, mediaId, detections, status}` and NOT the legacy `time` key. **Traces to**: AC-05 **Profile**: fast **Preconditions**: an annotation with all spec-required fields present in the editor state. **Input data**: click Save — `results_report.md` row 23. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Trigger Save with `Source=AI`, a `WaypointId`, a `videoTime` | POST observed | | 2 | Inspect body keys | `{Source, WaypointId, videoTime, mediaId, detections, status} ⊆ keys`; `time` absent | **Expected outcome**: row 23. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 23. --- ### FT-P-09: Annotation-status SSE opens on `` mount **Traces to**: AC-09 **Profile**: fast (EventSource test double) and e2e **Input data**: mount `/annotations` — `results_report.md` row 24. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Mount `/annotations` | exactly one `EventSource` constructed | | 2 | Inspect URL | matches `^/api/annotations/annotations/events(\?|$)` | **Expected outcome**: row 24. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 24. --- ### FT-P-10: Annotation-status SSE closes on unmount **Traces to**: AC-09 **Profile**: fast **Preconditions**: continuation of FT-P-09. **Input data**: unmount `/annotations` — `results_report.md` row 25. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Unmount the route | `EventSource.readyState → CLOSED (2)` within 1 s | **Expected outcome**: row 25 (CLOSED within ≤ 1 000 ms). **Max execution time**: 3s. **Expected result source**: `results_report.md` row 25. --- ### FT-P-11: Sync image detect endpoint **Traces to**: AC-25 (sync path) **Profile**: fast and e2e **Input data**: click Detect on a `MediaType.Image` — `results_report.md` row 26. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Open `` on an image; click Detect | exactly one POST observed | | 2 | Inspect URL | matches `^/api/detect/[0-9]+$` | **Expected outcome**: row 26. **Max execution time**: 30s (server-side detect time included). **Expected result source**: `results_report.md` row 26. --- ### FT-P-12: Async video detect endpoint + SSE (target — Phase B) **Traces to**: AC-25 (async path) **Profile**: fast (mocked) — `quarantined` until F7 lands **Status**: target — UI does not implement async video detect today (`04_verification_log.md` F7). Test is written so it activates the day the feature ships. **Input data**: click Detect on a `MediaType.Video` (behind feature flag) — `results_report.md` row 27. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Trigger detect on a video | one POST `^/api/detect/video/[0-9]+$` | | 2 | Inspect response | JSON `{jobId: }` | | 3 | Wait | EventSource opens to `^/api/detect/stream/[0-9]+(\?|$)` | **Expected outcome**: row 27 (3 assertions). **Max execution time**: 10s. **Expected result source**: `results_report.md` row 27. --- ### FT-P-13: Long-video detect carries `X-Refresh-Token` header **Traces to**: AC-25 **Profile**: fast — `quarantined` until F7 lands (and the header is added per Step 4) **Status**: target. **Input data**: long-video async detect — `results_report.md` row 28. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Trigger long-video detect | request headers observed | | 2 | Inspect headers | `X-Refresh-Token` present and non-empty | **Expected outcome**: row 28. **Max execution time**: 5s. **Expected result source**: `results_report.md` row 28. --- ### FT-P-14: Overlay membership at the lower in-window edge **Summary**: An annotation at `videoTime = T - 30 ms` (inside `[-50, +150]` window around `T`) renders. **Traces to**: AC-28 **Profile**: fast (component test on ``) **Input data**: `currentTime = T`, annotation at `T - 30 ms` — `results_report.md` row 29. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Render `` with the annotation and set `currentTime = T` | overlay rect rendered | **Expected outcome**: row 29 (range check). **Max execution time**: 1s. **Expected result source**: `results_report.md` row 29. --- ### FT-P-15: Overlay membership at the upper in-window edge **Summary**: An annotation at `videoTime = T + 120 ms` renders. **Traces to**: AC-28 **Profile**: fast **Input data**: `currentTime = T`, annotation at `T + 120 ms` — derived from `results_report.md` row 29. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Render `` with the annotation and `currentTime = T` | overlay rendered | **Expected outcome**: present. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 29. --- ### FT-P-16: Flight selection persists via `PUT /api/annotations/settings/user` **Traces to**: AC-06 **Profile**: fast **Input data**: `FlightContext.selectFlight()` — `results_report.md` row 32. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Trigger `selectFlight` for a known flight id | exactly one PUT observed | | 2 | Inspect URL | matches `^/api/annotations/settings/user$` | | 3 | Inspect body | `{selectedFlightId: }` (subset) | | 4 | Code-search the test domain | NO call to `/api/flights/select` | **Expected outcome**: row 32. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 32. --- ### FT-P-17: Selected-flight rehydration on boot **Traces to**: AC-06 **Profile**: e2e (and fast with stubbed `UserSettings`) **Preconditions**: `UserSettings.selectedFlightId` known in seed. **Input data**: full reload — `results_report.md` row 33. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Hard reload as the seeded user | app boots | | 2 | Inspect `FlightContext` state via DOM (selected flight indicator) | matches `UserSettings.selectedFlightId` | **Expected outcome**: row 33. **Max execution time**: 10s. **Expected result source**: `results_report.md` row 33. --- ### FT-P-18: Live-GPS SSE opens within 5 s of flight select **Traces to**: AC-08 **Profile**: e2e (uses `flights/` live-gps simulator) **Input data**: select a flight — `results_report.md` row 34. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Select a flight in `
` | EventSource opens | | 2 | Inspect URL | matches `^/api/flights/[0-9]+/live-gps(\?|$)` | | 3 | Wait | `readyState === OPEN (1)` within ≤ 5 000 ms | **Expected outcome**: row 34. **Max execution time**: 10s. **Expected result source**: `results_report.md` row 34. --- ### FT-P-19: Live-GPS SSE closes within 1 s of deselect **Traces to**: AC-08 **Profile**: e2e **Input data**: deselect — `results_report.md` row 35. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Deselect the flight | EventSource state transitions | | 2 | Inspect | all `^/api/flights/[0-9]+/live-gps` sources `CLOSED (2)` within ≤ 1 000 ms | **Expected outcome**: row 35. **Max execution time**: 5s. **Expected result source**: `results_report.md` row 35. --- ### FT-P-20: Bulk-validate request URL and body **Traces to**: AC-07 **Profile**: fast and e2e **Input data**: select N items, click Validate — `results_report.md` row 36. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Select N items on `` | selection state observable | | 2 | Click Validate | one POST observed | | 3 | Inspect URL + body | `/api/annotations/dataset/bulk-status`; body `{ids: , targetStatus: 30}` | **Expected outcome**: row 36 (`targetStatus: 30` after AC-04 fix). **Max execution time**: 5s. **Expected result source**: `results_report.md` row 36. --- ### FT-P-21: Bulk-validate UI reflects new status within 2 s **Traces to**: AC-07 **Profile**: fast **Preconditions**: continuation of FT-P-20. **Input data**: server returns 200 — `results_report.md` row 37. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Stub a 200 response | UI updates | | 2 | Inspect each selected row within ≤ 2 000 ms | status badge reads `Validated` | **Expected outcome**: row 37. **Max execution time**: 5s. **Expected result source**: `results_report.md` row 37. --- ### FT-P-22: i18n key parity en ↔ ua **Traces to**: AC-12 **Profile**: static **Input data**: `src/i18n/en.json`, `src/i18n/ua.json` — `results_report.md` row 45. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Read both files | parsed | | 2 | Compute deep, sorted key sets | `set_equals` | **Expected outcome**: row 45. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 45. --- ### FT-P-23: No raw user-visible strings outside `t(...)` **Traces to**: AC-12 **Profile**: static (lint rule) **Input data**: lint over `src/**/*.tsx` — `results_report.md` row 46. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Run the lint rule | reports 0 findings | | 2 | Allow-list applies to proper-noun acronyms only | allow-list respected | **Expected outcome**: row 46. **Max execution time**: 30s. **Expected result source**: `results_report.md` row 46. --- ### FT-P-24: i18n detector path used at first boot **Traces to**: AC-13 **Profile**: fast — `quarantined` until the detector is added in Step 4 **Input data**: first boot in a clean profile — `results_report.md` row 47. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Mount the app with a profile providing `Accept-Language: uk` | i18next initialises | | 2 | Inspect `i18next.language` | resolves from detector (not hardcoded `'en'`) | | 3 | Static read `src/i18n/i18n.ts` | no `lng: 'en'` hardcoded init present | **Expected outcome**: row 47. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 47. --- ### FT-P-25: i18n persistence across reload **Traces to**: AC-13 **Profile**: e2e — `quarantined` until the detector + persistence land **Input data**: toggle language, reload — `results_report.md` row 48. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Click language toggle in `
` to `uk` | UI re-renders in Ukrainian | | 2 | Hard reload | app boots | | 3 | Inspect `i18next.language` | equals `uk` | **Expected outcome**: row 48. **Max execution time**: 10s. **Expected result source**: `results_report.md` row 48. --- ### FT-P-26: Class-delete with confirmation — happy path **Traces to**: AC-14, AC-30 **Profile**: fast **Input data**: click Delete on a class entry in `` and Confirm — `results_report.md` row 49. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Click Delete | `` mounts | | 2 | Click Confirm | exactly one DELETE observed | | 3 | Inspect URL | matches `^/api/admin/classes/[0-9]+$` | **Expected outcome**: row 49 (confirm path). **Max execution time**: 5s. **Expected result source**: `results_report.md` row 49. --- ### FT-P-27: Destructive policy — dialog before request for every destructive surface **Traces to**: AC-14 **Profile**: fast **Input data**: each destructive surface listed in `_docs/ui_design/` (class delete, user deactivate, dataset bulk-overwrite, irreversible bulk) — `results_report.md` row 51. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | For each surface, trigger the destructive action | `` opens BEFORE any HTTP fires | | 2 | Inspect request log up to dialog open | empty for the destructive route | **Expected outcome**: row 51. **Max execution time**: 10s. **Expected result source**: `results_report.md` row 51. --- ### FT-P-28: ConfirmDialog has dialog + modal a11y attributes **Traces to**: AC-15 **Profile**: fast **Input data**: render `` — `results_report.md` row 52. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Open the dialog | root element rendered | | 2 | Inspect root attributes | `role="dialog"` and `aria-modal="true"` | **Expected outcome**: row 52. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 52. --- ### FT-P-29: ConfirmDialog focus trap (Tab cycles inside) **Traces to**: AC-15 **Profile**: fast **Input data**: open dialog and Tab through — `results_report.md` row 53. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Open dialog; focus on first focusable element | initial focus inside dialog | | 2 | Tab repeatedly | focus stays inside the dialog subtree; first ↔ last cycles | **Expected outcome**: row 53. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 53. --- ### FT-P-30: Header flight dropdown closed-state a11y **Traces to**: AC-16 **Profile**: fast **Input data**: render `
` with dropdown closed — `results_report.md` row 55. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Mount Header | dropdown trigger rendered | | 2 | Inspect attributes | `role="combobox"`, `aria-expanded="false"`, `aria-haspopup="listbox"` | **Expected outcome**: row 55. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 55. --- ### FT-P-31: Header flight dropdown open-state a11y **Traces to**: AC-16 **Profile**: fast **Input data**: open the dropdown — `results_report.md` row 56. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Click trigger | dropdown opens | | 2 | Inspect | `aria-expanded="true"`; outside-click handler attached (was NOT attached while closed — observable via DOM event listener count or via behavior under a synthetic outside-click while closed: no-op) | **Expected outcome**: row 56. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 56. --- ### FT-P-32: ProtectedRoute spinner a11y **Traces to**: AC-17 **Profile**: fast **Input data**: render `` in loading state — `results_report.md` row 58. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Mount in loading state | spinner rendered | | 2 | Inspect | `role="status"` + non-empty accessible label | **Expected outcome**: row 58. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 58. --- ### FT-P-33: ProtectedRoute timeout fallback after 10 s **Traces to**: AC-17 **Profile**: fast (with fake timers) **Input data**: loading exceeds the timeout — `results_report.md` row 59. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Mount loading; advance fake time to 10 s | timer fires | | 2 | Inspect DOM | fallback (retry CTA or error message) present; spinner unmounted | **Expected outcome**: row 59. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 59. --- ### FT-P-34: Browser-support smoke (Chromium + Firefox) **Traces to**: AC-18 **Profile**: e2e (manual smoke per AC — no enforcement today) **Input data**: render `/flights`, `/annotations`, `/dataset` on each supported browser — `results_report.md` row 60. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Navigate to each route | page renders | | 2 | Inspect console + DOM landmarks | no console errors; main landmark roles present | **Expected outcome**: row 60. **Max execution time**: 30s per browser. **Expected result source**: `results_report.md` row 60. --- ### FT-P-35: Mobile bottom-nav variant at 480 px **Traces to**: AC-19 **Profile**: e2e **Input data**: render at viewport 480×800 — `results_report.md` row 61. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Resize browser to 480×800 | layout reflows | | 2 | Inspect DOM | bottom-nav variant present; top-bar variant absent | **Expected outcome**: row 61. **Max execution time**: 5s. **Expected result source**: `results_report.md` row 61. --- ### FT-P-36: Desktop top-bar variant at 1024 px **Traces to**: AC-19 **Profile**: e2e **Input data**: render at viewport 1024×768 — `results_report.md` row 62. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Resize to 1024×768 | reflow | | 2 | Inspect DOM | top-bar present; bottom-nav absent | **Expected outcome**: row 62. **Max execution time**: 5s. **Expected result source**: `results_report.md` row 62. --- ### FT-P-37: Panel-width persistence — debounced PUT on resize end **Traces to**: AC-21 **Profile**: fast — `quarantined` until `useResizablePanel` writes back (Step 4) **Input data**: drag divider — `results_report.md` row 64. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Simulate drag-end on a `useResizablePanel` divider | PUT observed within 1 s (debounce-aware) | | 2 | Inspect URL + body | matches `/api/annotations/settings/user`; body contains `panelWidths` key | **Expected outcome**: row 64. **Max execution time**: 5s. **Expected result source**: `results_report.md` row 64. --- ### FT-P-38: Panel-width rehydration on reload **Traces to**: AC-21 **Profile**: e2e — `quarantined` until Step 4 **Input data**: reload after resize — `results_report.md` row 65. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Resize panels; capture widths W | widths observable | | 2 | Reload | app boots | | 3 | Re-measure | widths equal W within ± 1 px | **Expected outcome**: row 65. **Max execution time**: 15s. **Expected result source**: `results_report.md` row 65. --- ### FT-P-39: Manual bounding-box draw on `` **Traces to**: AC-35 **Profile**: fast (synthetic pointer events) **Input data**: mousedown(x1,y1) → mousemove(x2,y2) → mouseup with `selectedClassNum = C`, `photoMode = P` — `results_report.md` row 73. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Fire pointer sequence on the canvas | one new local detection appended | | 2 | Inspect detection | `classNum == C + P`; `x,y,w,h` normalised within ± 1 px of the drawn rect | **Expected outcome**: row 73. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 73. --- ### FT-P-40: 8-handle bbox resize **Traces to**: AC-36 (a) **Profile**: fast **Input data**: drag each of the 8 handles in turn — `results_report.md` row 74. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | For h ∈ {NW,N,NE,W,E,SW,S,SE}: mousedown on h, drag (dx,dy), mouseup | bbox edges adjacent to h move; opposite edges unchanged | | 2 | Force the drag past zero size | resulting bbox has `w>0` and `h>0` (clamped) | **Expected outcome**: row 74. **Max execution time**: 5s. **Expected result source**: `results_report.md` row 74. --- ### FT-P-41: Ctrl+click multi-select on canvas **Traces to**: AC-36 (b) **Profile**: fast **Input data**: Ctrl+click an unselected bbox; second Ctrl+click on the same — `results_report.md` row 75. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Have one bbox already selected | initial selection = {A} | | 2 | Ctrl+click bbox B | selection set = {A, B}; prior selection preserved | | 3 | Ctrl+click bbox B again | selection set = {A} (toggle off) | **Expected outcome**: row 75. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 75. --- ### FT-P-42: Ctrl+wheel zoom-around-cursor **Traces to**: AC-36 (c) **Profile**: fast **Input data**: Ctrl+wheel at (cx, cy) — `results_report.md` row 76. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Record the world coordinate at (cx, cy) before zoom | W₀ | | 2 | Dispatch Ctrl+wheel | zoom level changes | | 3 | Inverse-map (cx, cy) → world coordinate after zoom | equals W₀ within ± 1 viewport px | **Expected outcome**: row 76. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 76. --- ### FT-P-43: Ctrl+drag pan on empty canvas **Traces to**: AC-36 (d) **Profile**: fast **Input data**: Ctrl+drag on empty area — `results_report.md` row 77. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Ctrl+drag from (x1,y1) by (dx,dy) | viewport origin translates by `(-dx,-dy)` within ± 1 px | | 2 | Inspect bbox state | no bbox created or modified | **Expected outcome**: row 77. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 77. --- ### FT-P-44: DetectionClasses loads from `/api/annotations/classes` **Traces to**: AC-37 (load path) **Profile**: fast **Input data**: mount with a successful response of N classes — `results_report.md` row 78. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Mount `` | GET observed | | 2 | Inspect rendered entries | N entries; active-mode filter applied; no fallback indicator | **Expected outcome**: row 78. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 78. --- ### FT-P-45: Class hotkey 1–9 selects `classes[(key-1) + P]` **Traces to**: AC-37 (hotkey path) **Profile**: fast (synthetic `keydown` on `window`) **Input data**: keys `'1'..'9'`, `photoMode = P`, classes mode-ordered — `results_report.md` row 79. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | For each key k ∈ {1..9}: dispatch `keydown` | `onSelect` fires once | | 2 | Inspect arg | `class.id == classes[(k-1) + P].id` | | 3 | Inspect rendered list element | label index `i+1` equals `k` | **Expected outcome**: row 79. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 79. --- ### FT-P-46: Class click path **Traces to**: AC-37 (click path) **Profile**: fast **Input data**: click a class entry — `results_report.md` row 80. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Click entry for class C | `onSelect` fires once with `C.id` | **Expected outcome**: row 80. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 80. --- ### FT-P-47: Fallback class list on API empty/5xx **Traces to**: AC-37 (fallback) **Profile**: fast **Input data**: `GET /api/annotations/classes` returns `[]` (or 5xx) — `results_report.md` row 81. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Stub empty response | fallback list rendered | | 2 | Inspect rendered IDs | `FALLBACK_CLASS_NAMES × 3 PhotoMode offsets` (set equals `[0..N-1, 20..20+N-1, 40..40+N-1]`) | **Expected outcome**: row 81. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 81. --- ### FT-P-48: PhotoMode switch — mode set + filter **Traces to**: AC-38 (mode set + filter) **Profile**: fast **Input data**: click Winter while `photoMode = 0` — `results_report.md` row 82. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Click Winter | `onPhotoModeChange(20)` fires once | | 2 | Inspect rendered class list | filtered to entries with `photoMode == 20` | **Expected outcome**: row 82. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 82. --- ### FT-P-49: PhotoMode auto-select when prior class no longer valid **Traces to**: AC-38 (auto-select) **Profile**: fast **Input data**: switch mode where previously selected class is not in the new filtered set — `results_report.md` row 83. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Pre-select a Regular class | initial state | | 2 | Switch to Night (`photoMode = 40`) | `onSelect` fires once with `modeClasses[0].id` | **Expected outcome**: row 83. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 83. --- ### FT-P-50: yoloId on the wire — `classNum == classId + photoModeOffset` **Traces to**: AC-38 (wire) **Profile**: fast **Input data**: save annotation after drawing with C, P — `results_report.md` row 84. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Draw a bbox with `selectedClassNum = C`, `photoMode = P` | local detection appended | | 2 | Save | POST observed | | 3 | Inspect new detection in body | `classNum == C + P` | **Expected outcome**: row 84. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 84. --- ### FT-P-51: Tile-split endpoint contract **Traces to**: AC-39 (endpoint) **Profile**: fast — `quarantined` until the split action surfaces on the dataset page **Input data**: click Split tile on a dataset item — `results_report.md` row 85. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Trigger Split tile | one POST observed | | 2 | Inspect URL + status | `^/api/annotations/dataset/[0-9]+/split$`; 200 JSON | **Expected outcome**: row 85. **Max execution time**: 5s. **Expected result source**: `results_report.md` row 85. --- ### FT-P-52: YOLO label parser — happy path **Traces to**: AC-39 (parser happy) **Profile**: fast **Input data**: `isSplit: true, splitTile: "3 0.5 0.5 0.2 0.2"` — `results_report.md` row 86. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Pass the item through the parser/render path | no exception | | 2 | Inspect parsed result | `{classNum:3, cx:0.5, cy:0.5, w:0.2, h:0.2}` | **Expected outcome**: row 86. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 86. --- ### FT-P-53: `DatasetItem.isSplit` is honored on the dataset list path **Traces to**: AC-39 (dataset list) **Profile**: fast **Input data**: `GET /api/annotations/dataset` response contains `isSplit: true` — `results_report.md` row 88. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Stub the response | render no crash | | 2 | Inspect that the field is read into the item state (DOM marker present for split items) | field present | **Expected outcome**: row 88. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 88. --- ### FT-P-54: Tile auto-zoom viewport matches tile rect **Traces to**: AC-40 (viewport) **Profile**: fast — `quarantined` (UX missing today) **Input data**: open a `splitTile`-bearing annotation — `results_report.md` row 89. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Double-click the annotation in `` | `` opens to that annotation | | 2 | Inspect viewport rect | equals tile rect within ± 1 px per edge | **Expected outcome**: row 89. **Max execution time**: 5s. **Expected result source**: `results_report.md` row 89. --- ### FT-P-55: Tile-zoom indicator visible while active **Traces to**: AC-40 (indicator) **Profile**: fast — `quarantined` **Input data**: tile zoom active → cleared — `results_report.md` row 90. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Activate tile zoom | indicator icon/badge present in canvas chrome | | 2 | Clear tile zoom | indicator removed | **Expected outcome**: row 90. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 90. --- ## Negative Scenarios ### FT-N-01: Overlay annotation below the lower bound is NOT rendered **Summary**: At `currentTime = T`, an annotation at `T - 60 ms` (outside `[-50, +150]` ms) is excluded. **Traces to**: AC-28 **Profile**: fast **Input data**: `currentTime = T`, annotation at `T - 60 ms` — `results_report.md` row 30. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Render with the off-window annotation | overlay NOT rendered | **Expected outcome**: row 30 (absent). **Max execution time**: 1s. **Expected result source**: `results_report.md` row 30. --- ### FT-N-02: Overlay annotation above the upper bound is NOT rendered **Traces to**: AC-28 **Profile**: fast **Input data**: `currentTime = T`, annotation at `T + 160 ms` — `results_report.md` row 31. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Render | overlay NOT rendered | **Expected outcome**: row 31. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 31. --- ### FT-N-03: Authenticated non-admin navigating to `/admin` is redirected to `/flights` **Traces to**: AC-22 **Profile**: e2e — `quarantined` until role-gate is added (Step 4 / Step 8) **Input data**: log in as `op_alice` (Operator) → navigate `/admin` — `results_report.md` row 08. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Authenticate as Operator | session active | | 2 | Navigate to `/admin` | redirect | | 3 | Inspect final URL + tree | URL is `/flights`; `` NOT mounted | **Expected outcome**: row 08. **Max execution time**: 10s. **Expected result source**: `results_report.md` row 08. --- ### FT-N-04: Unauthenticated user navigating to `/admin` is redirected to `/login` **Traces to**: AC-22 **Profile**: e2e **Input data**: no session → navigate `/admin` — `results_report.md` row 09. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Clear cookies and bearer | unauthenticated | | 2 | Navigate to `/admin` | redirect | | 3 | Inspect final URL | `/login` | **Expected outcome**: row 09. **Max execution time**: 10s. **Expected result source**: `results_report.md` row 09. --- ### FT-N-05: Authenticated user without SETTINGS permission navigating to `/settings` **Traces to**: AC-22 **Profile**: e2e — `quarantined` **Input data**: `op_bob` (no SETTINGS) → `/settings` — `results_report.md` row 10. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Authenticate as op_bob | session active | | 2 | Navigate to `/settings` | redirect | | 3 | Inspect final URL + tree | `/flights`; `` NOT mounted | **Expected outcome**: row 10. **Max execution time**: 10s. **Expected result source**: `results_report.md` row 10. --- ### FT-N-06: Upload of 501 MB file surfaces a user-visible 413 error **Traces to**: AC-10 **Profile**: e2e **Input data**: 501 MB synthetic file via dropzone — `results_report.md` row 39. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Drop 501 MB onto `` | upload request issued | | 2 | nginx returns 413 | rejected upstream | | 3 | Inspect UI | user-visible error containing the i18n "file too large" string; NO `alert()` | **Expected outcome**: row 39. **Max execution time**: 30s. **Expected result source**: `results_report.md` row 39. --- ### FT-N-07: Class-delete Cancel path — NO DELETE request issued **Traces to**: AC-14, AC-30 **Profile**: fast **Input data**: click Delete → click Cancel — derived from `results_report.md` row 49. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Click Delete | `` mounts | | 2 | Click Cancel | dialog unmounts | | 3 | Inspect network log | zero DELETE requests fired | **Expected outcome**: cancel branch of row 49. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 49. --- ### FT-N-08: Escape on `` cancels — no destructive request **Traces to**: AC-15 **Profile**: fast **Input data**: open dialog → press Escape — `results_report.md` row 54. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Open dialog | mounted | | 2 | Dispatch `keydown` Escape | dialog unmounts; cancel callback fires exactly once | | 3 | Inspect network log | no destructive HTTP fired | **Expected outcome**: row 54. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 54. --- ### FT-N-09: Header dropdown Escape — close + handler detached **Traces to**: AC-16 **Profile**: fast **Input data**: open dropdown → press Escape — `results_report.md` row 57. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Open dropdown | open | | 2 | Dispatch Escape | dropdown closes | | 3 | Inspect attributes | `aria-expanded="false"`; outside-click handler detached | **Expected outcome**: row 57. **Max execution time**: 1s. **Expected result source**: `results_report.md` row 57. --- ### FT-N-10: Malformed YOLO label surfaces a user-visible error (no silent swallow, no NaN render) **Traces to**: AC-39 **Profile**: fast **Input data**: `isSplit: true, splitTile: "garbage"` — `results_report.md` row 87. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Stub the item; render | parse attempted | | 2 | Inspect UI | error toast / inline error present | | 3 | Inspect rendered overlay | no NaN values, no tile region rendered | **Expected outcome**: row 87. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 87. --- ### FT-N-11: Numeric field with empty input — no silent zero **Traces to**: AC-26 **Profile**: fast — `quarantined` until form hygiene fix (Step 4) **Input data**: clear a `09_settings` numeric input and submit — `results_report.md` row 66. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Clear the field | form state observed | | 2 | Click Save | form blocks save | | 3 | Inspect network log | no PUT fires | | 4 | Inspect DOM | submit disabled OR explicit validation error rendered | **Expected outcome**: row 66. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 66. --- ### FT-N-12: Numeric field with non-numeric input — rejected **Traces to**: AC-26 **Profile**: fast — `quarantined` **Input data**: type "abc" and submit — `results_report.md` row 67. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Type non-numeric value | state updates | | 2 | Click Save | rejected | | 3 | Inspect | validation error rendered; no PUT | **Expected outcome**: row 67. **Max execution time**: 3s. **Expected result source**: `results_report.md` row 67. --- ### FT-N-13: Settings save with 500 response — `saving` flag reset; error surfaced **Traces to**: AC-27 **Profile**: fast — `quarantined` until try/finally fix (Step 4) **Input data**: upstream PUT returns 500 — `results_report.md` row 68. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Trigger save | request issued | | 2 | Stub responds 500 within 2 s | failure handled | | 3 | Inspect within ≤ 2 000 ms | toast / inline error present; `saving === false`; no navigation away | **Expected outcome**: row 68. **Max execution time**: 5s. **Expected result source**: `results_report.md` row 68. --- ### FT-N-14: Settings save with network failure — try/finally state reset **Traces to**: AC-27 **Profile**: fast — `quarantined` **Input data**: PUT throws (network drop) — `results_report.md` row 69. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Trigger save | request issued | | 2 | Stub throws | rejection handled | | 3 | Inspect | `saving === false`; user-visible error present | **Expected outcome**: row 69. **Max execution time**: 5s. **Expected result source**: `results_report.md` row 69. --- ### FT-N-15: `MediaType` magic-literal / magic-string hygiene **Traces to**: AC-29 **Profile**: static **Input data**: regex sweep of `src/` for `mediaType\s*[!=]==?\s*[0-9]` and `mediaType\s*[!=]==?\s*['"]` — `results_report.md` rows 20, 21. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Run the two regexes | results captured | | 2 | Inspect | `match_count == 0` for both | **Expected outcome**: rows 20, 21. **Max execution time**: 5s. **Expected result source**: `results_report.md` rows 20, 21. --- ### FT-N-16: mission-planner `getWeatherData` fail-soft when `VITE_OWM_API_KEY` is unset **Traces to**: AC-42 (AZ-499 AC-3) **Profile**: fast **Input data**: build-time env with `VITE_OWM_API_KEY=""` (or undefined). **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Spy `globalThis.fetch` | spy installed, no calls yet | | 2 | Stub `import.meta.env.VITE_OWM_API_KEY = ""` and invoke `getWeatherData(50, 30)` | resolves | | 3 | Inspect return value | `=== null` | | 4 | Inspect fetch spy | `mock.calls.length === 0` | **Pass criteria**: function returns `null` AND no outbound HTTP request is made when the API key is unset. Mirrors the AZ-448 fail-soft contract on the main SPA. **Max execution time**: 1s (env stub + sync inspection only). **Expected result source**: AZ-499 AC-3 (no `results_report.md` row needed — behavioral test, no input data). --- ## Cycle 2 Additions (Phase B Cycle 2 — Self-hosted satellite tiles + mission-planner OWM hardening) The scenarios below were appended via `/test-spec` cycle-update mode after Phase B Cycle 2 completed (AZ-498 + AZ-499, batch_11). They use the same template shapes as the original spec. Cross-references: AC-41 (satellite tiles), AC-42 (mission-planner OWM env hardening) are the new global ACs added to `traceability-matrix.md`; the underlying task-spec ACs are AZ-498 AC-1..AC-7, AC-9 and AZ-499 AC-1..AC-6 (AZ-498 AC-8 was dropped with explicit user approval per `_docs/03_implementation/batch_11_report.md`; AZ-499 AC-7 is a manual deliverable, not a test). ### FT-P-56: Self-hosted satellite tile URL is env-var resolved **Traces to**: AC-41 (AZ-498 AC-1, AC-2) **Profile**: fast **Input data**: build-time env with `VITE_SATELLITE_TILE_URL` set, unset, or set with a trailing slash. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Stub `VITE_SATELLITE_TILE_URL=http://satellite-provider:5100/tiles/{z}/{x}/{y}` and call `getTileUrl()` | returns the env value verbatim | | 2 | Stub `VITE_SATELLITE_TILE_URL=""` and call `getTileUrl()` | returns `DEFAULT_SATELLITE_TILE_URL` (`http://localhost:5100/tiles/{z}/{x}/{y}`) | | 3 | Stub `VITE_SATELLITE_TILE_URL=http://satellite-provider:5100/tiles/{z}/{x}/{y}/` (trailing slash) | returns the value with the trailing slash stripped | | 4 | Mount `` with the env unset; inspect rendered `` `data-tile-url` | equals `DEFAULT_SATELLITE_TILE_URL` | **Pass criteria**: all four assertions hold. Mirrors the established `getOwmBaseUrl()` / `getApiBase()` env-resolution pattern. **Max execution time**: 2s (jsdom render + four stub variations). **Expected result source**: AZ-498 AC-1, AC-2 (no `results_report.md` row needed — env-var plumbing, no input data fixture). --- ### FT-P-57: `` enables cookie-auth on tile fetches **Traces to**: AC-41 (AZ-498 AC-3); E1 (air-gap-friendly bundle); RID R-Reliability for tile auth **Profile**: fast **Input data**: `` and `` mounted with the default tile URL. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Mount ``; inspect rendered `` `data-cross-origin` attribute | `=== "use-credentials"` | | 2 | Mount ``; inspect rendered `` `data-cross-origin` attribute | `=== "use-credentials"` | | 3 | (e2e — gated) Issue `GET ` from the rendered map; inspect outbound request | `request.credentials === "include"` (browser attaches the same-origin auth cookie) | **Pass criteria**: every `` the SPA renders carries `crossOrigin="use-credentials"` so the browser sends the satellite-provider cookie on same-origin tile requests. Step 3 e2e is gated by the cross-workspace satellite-provider cookie-auth ticket landing (Step 16 deploy gate). **Max execution time**: 2s for steps 1+2 (fast); e2e step is part of `infrastructure.e2e.ts` — bounded by suite-e2e timeout. **Expected result source**: AZ-498 AC-3 (no `results_report.md` row — DOM-attribute observable). --- ### FT-P-58: Classic/satellite map toggle, `mapType` state, and `MiniMap.Props.mapType` are removed **Traces to**: AC-41 (AZ-498 AC-4) **Profile**: fast **Input data**: `` mounted with the default tile URL; `` mounted with only `pointPosition` (no `mapType` prop). **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Mount ``; query `screen.queryByRole('button', { name: /satellite|classic/i })` | returns `null` | | 2 | Mount ``; query `screen.getAllByTestId('tile-layer')` | length `=== 1` (no per-mode branching, single layer) | | 3 | Compile-time check: instantiate `` without `mapType` | TypeScript `tsc --noEmit -p tsconfig.test.json` succeeds (STC-T1) | | 4 | Compile-time check: source-tree grep for any remaining `mapType` reference under `src/features/flights/` | zero hits (compilation error if not — covered by STC-T1) | **Pass criteria**: no toggle button, no `mapType` state, `MiniMap.Props` has no `mapType`. Removal is permanent; the `flights.planner.satellite` i18n key was removed from both `en.json` and `ua.json` in lockstep (i18n key parity preserved via STC-FP22). **Max execution time**: 2s (jsdom render + grep). **Expected result source**: AZ-498 AC-4. --- ### FT-P-59: e2e harness exercises the new `/tiles/{z}/{x}/{y}` path **Traces to**: AC-41 (AZ-498 AC-6); E1 (air-gap) **Profile**: e2e **Input data**: suite-e2e compose stack up; `tile-stub` configured at `http://tile-stub:8082/tiles/{z}/{x}/{y}`. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `infrastructure.e2e.ts` AC-2 — issue `GET http://tile-stub:8082/tiles/1/0/0` from the playwright runner | HTTP 200, response is a 256×256 image (JPEG) | | 2 | Inspect response headers | `Content-Type: image/jpeg`, `Cache-Control` present, `ETag` present | | 3 | Inspect outbound request from the SPA's `` | URL matches `^http://tile-stub:8082/tiles/\d+/\d+/\d+$` (NOT `/{z}/{x}/{y}.png`, NOT the legacy `/sat/...` Esri shape) | | 4 | Inspect `EXTERNAL_HOSTS` route guard | OSM and Esri hosts are NOT in the allow-list (removed during cycle 2 cleanup) | **Pass criteria**: tile fetch shape matches the satellite-provider contract documented at `_docs/02_document/contracts/satellite-provider/tiles.md`. Note: the same-origin cookie-auth path (cookie attached on the actual fetch) is verified once the cross-workspace satellite-provider cookie-auth ticket lands; until then, the e2e profile uses the `tile-stub` which accepts requests without a cookie. **Max execution time**: bounded by suite-e2e infrastructure-test timeout (per `e2e/tests/infrastructure.e2e.ts`). **Expected result source**: contract at `_docs/02_document/contracts/satellite-provider/tiles.md` v1.0.0; AZ-498 AC-6. --- ### FT-P-60: mission-planner `getWeatherData` uses env-resolved key + base URL **Traces to**: AC-42 (AZ-499 AC-1, AC-2, AC-4) **Profile**: fast **Input data**: build-time env with `VITE_OWM_API_KEY` set + `VITE_OWM_BASE_URL` either set, unset, or set with a trailing slash. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Spy `globalThis.fetch` returning a 200 OK with body `{ wind: { speed: 5, deg: 90 } }` | spy installed | | 2 | Stub `VITE_OWM_API_KEY=abc123` + `VITE_OWM_BASE_URL=""`; invoke `getWeatherData(50, 30)` | outbound URL contains `appid=abc123` AND `units=metric` | | 3 | Stub `VITE_OWM_API_KEY=abc123` + `VITE_OWM_BASE_URL=https://example.test/data/2.5`; invoke `getWeatherData(50, 30)` | outbound URL starts with `https://example.test/data/2.5/weather?` | | 4 | Stub `VITE_OWM_API_KEY=abc123` + `VITE_OWM_BASE_URL=https://example.test/data/2.5/` (trailing slash); invoke `getWeatherData(50, 30)` | outbound URL starts with `https://example.test/data/2.5/weather?` (slash stripped) | | 5 | Stub `VITE_OWM_API_KEY=abc123` + `VITE_OWM_BASE_URL=""`; invoke `getWeatherData(50, 30)` | outbound URL starts with `https://api.openweathermap.org/data/2.5/weather?` (default base) | | 6 | Inspect return value on a successful fetch | `=== { windSpeed: 5, windAngle: 90 }` (existing parsed-wind shape preserved) | **Pass criteria**: every outbound URL is reconstructed from env vars; the public `getWeatherData(lat, lon)` signature and `WeatherData` return shape are unchanged. Pairs with the AZ-499 NFR-Compatibility constraint. **Max execution time**: 2s (env stubs + fetch-spy assertions; no real network). **Expected result source**: AZ-499 AC-1, AC-2, AC-4 (no `results_report.md` row — env-var plumbing). --- ### FT-P-61: mission-planner `geocodeAddress` uses env-resolved Google API key **Traces to**: AC-43 (AZ-501 AC-1) **Profile**: fast **Input data**: build-time env with `VITE_GOOGLE_GEOCODE_KEY` set to a placeholder string. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Spy `globalThis.fetch` returning a 200 OK with body `{ status: 'OK', results: [{ geometry: { location: { lat, lng } } }] }` | spy installed | | 2 | Stub `VITE_GOOGLE_GEOCODE_KEY=env-key-xyz`; invoke `geocodeAddress('Kyiv, Ukraine')` | outbound URL contains `key=env-key-xyz` AND `address=Kyiv%2C%20Ukraine` | | 3 | Inspect return value | `=== { lat, lng }` from the mocked response | **Pass criteria**: the outbound URL is reconstructed from the env var; no literal key remains in `mission-planner/src/services/GeocodeService.ts` (defense-in-depth confirmed by STC-SEC1D / NFT-SEC-09b). **Max execution time**: 2s. **Expected result source**: AZ-501 AC-1. --- ### FT-N-17: mission-planner `geocodeAddress` fail-soft when `VITE_GOOGLE_GEOCODE_KEY` is unset **Traces to**: AC-43 (AZ-501 AC-3) **Profile**: fast **Input data**: build-time env with `VITE_GOOGLE_GEOCODE_KEY` empty / undefined. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Spy `globalThis.fetch`; spy `console.warn` | spies installed | | 2 | Stub `VITE_GOOGLE_GEOCODE_KEY=''`; invoke `geocodeAddress('anywhere')` | returns `null`; fetch is NOT called; `console.warn` called exactly once with a message containing `VITE_GOOGLE_GEOCODE_KEY` | | 3 | Stub `VITE_GOOGLE_GEOCODE_KEY=env-key-xyz` and force `fetch` to reject with `Error('boom')`; invoke `geocodeAddress('anywhere')` | returns `null`; promise does NOT throw | **Pass criteria**: missing-key path is silent-but-warned and never throws; network-error path is silent and never throws — preserves the LeftBoard address-box UX of "Enter does nothing if address is unresolvable". **Max execution time**: 2s. **Expected result source**: AZ-501 AC-3. --- ### FT-P-62: AdminPage class edit — inline form + PATCH wire contract + refresh **Traces to**: O9 (P12) — landed cycle 4 / 2026-05-13 by AZ-512. **Profile**: fast **Input data**: an `` mount with at least one detection class loaded via `GET /api/annotations/classes`; the user activates the row's edit (✎) affordance. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Inspect each rendered row | One edit (✎) button per class row (AC-1) | | 2 | Click the edit (✎) on row N | Row N replaces its read-only cells with editable `name` / `shortName` / `color` / `maxSizeM` inputs seeded with the row's current values; Save + Cancel buttons appear; no other row is in edit mode (AC-2 single-row invariant) | | 3 | Click edit (✎) on row M while row N is editing | Row N reverts to read-only; row M enters edit mode | | 4 | Modify `name` and click **Save** (or press **Enter** inside the form) | Exactly one `PATCH /api/admin/classes/{N}` is observed with body `{ name, shortName, color, maxSizeM }` (full body per Risk-2 mitigation); on 200/2xx `` re-fetches via `GET /api/annotations/classes` and row N re-renders read-only with the new values (AC-3) | **Pass criteria**: zero PATCH calls before step 4; exactly one PATCH in step 4 with the complete editable shape; URL pattern `^/api/admin/classes/\d+$`; success-path refresh observed via the existing `GET /api/annotations/classes` builder (no new endpoint introduced — `endpoints.admin.class(id)` reused per task constraint). **Max execution time**: 5s. **Expected result source**: `_docs/02_tasks/done/AZ-512_admin_edit_detection_class.md` AC-1..AC-3. --- ### FT-N-18: AdminPage class edit — error paths (Cancel, validation, 5xx) **Traces to**: O9 (P12), O10 (B4 anti-pattern: no `alert()`) — landed cycle 4 / 2026-05-13 by AZ-512. **Profile**: fast **Input data**: `` mounted with at least one class loaded; the row's edit form is open. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Modify any field; click **Cancel** (or press **Escape** in the form) | Zero PATCH observed; row reverts to original read-only values (AC-4) | | 2 | Clear `name`; click Save | Zero PATCH observed; inline `role="alert"` element renders `admin.classes.nameRequired` (en / ua localized) (AC-5) | | 3 | Set `maxSizeM ≤ 0` or NaN; click Save | Zero PATCH observed; inline `role="alert"` renders `admin.classes.maxSizeMustBePositive` (AC-5) | | 4 | Stub PATCH to return 500; click Save with valid fields | Exactly one PATCH observed (counterpart to FT-P-62 step 4); form stays open with the user's edits intact; inline `role="alert"` renders `admin.classes.updateFailed`; `window.alert` is NEVER called (AC-6 — Finding B4 anti-pattern enforced) | **Pass criteria**: every error path produces exactly the documented network footprint and exactly the documented inline error key; `window.alert` is spied and asserted-zero across the entire scenario (the STC-SEC7 static check independently guards the no-`alert()` invariant in production source). **Max execution time**: 10s. **Expected result source**: `_docs/02_tasks/done/AZ-512_admin_edit_detection_class.md` AC-4 / AC-5 / AC-6. --- ## Notes carried into Phase 3 - All tests tagged `quarantined` correspond to features either pending a Step 4 fix (e.g., AC-13 i18n detector, AC-21 panel persistence, AC-22 role-gate, AC-26/27 form hygiene, AC-39 split surface, AC-40 tile zoom) or pending Phase B implementation (AC-11 bundle gate, AC-24 SSE refresh, AC-25 async video, AC-40 tile zoom). The test is written so it activates the day the implementation lands; Phase 3 will surface them for downgrade or accept. - FT-P-13 / FT-P-14 / FT-P-37 / FT-P-38 / FT-P-51 / FT-P-54 / FT-P-55 / FT-N-03 / FT-N-05 / FT-N-11 / FT-N-12 / FT-N-13 / FT-N-14 are all on the quarantine list above. - Tests that depend on the enum-spec numeric snapshot (`AC-04` rows 15-17) are blocked on populating `enum_spec_snapshot.json` from the suite spec — Phase 3 surfaces this as the blocking input. - AC-37 row 79 depends on backend ordering returning `[0..N-1, 20..20+N-1, 40..40+N-1]`. If the seed reveals otherwise, this test fails — fix can land either side per `data_parameters.md`.