Files
ui/_docs/02_document/tests/blackbox-tests.md
T
Oleksandr Bezdieniezhnykh f7dd6c98d8
ci/woodpecker/push/build-arm Pipeline failed
[AZ-501] [AZ-502] Cycle 2 Step 14 security audit + inline fixes
Security audit (5 phases) → reports under _docs/05_security/.

AZ-501 (F-SAST-1, HIGH): Externalize hardcoded Google Geocode key
from mission-planner/src/config.ts to VITE_GOOGLE_GEOCODE_KEY via
new GeocodeService.ts; fail-soft warn when unset; STC-SEC1D static
deny-list gate; +5 unit tests in tests/mission_planner_geocode.test.ts.

AZ-502 (F-DEP-1, HIGH): Force vite>=6.4.2 and postcss>=8.5.10 via
package.json overrides in both roots; clean reinstall clears all
bun audit advisories.

Test-spec sync (Step 12) + Update Docs (Step 13) deltas: AC-43, AC-44,
NFT-SEC-09b, FT-P-61, FT-N-17, ripple log, batch_12 report.

Pending user actions: revoke Google + OWM keys (AC-6 / AZ-499 AC-7).

229 PASS / 13 SKIP / 0 FAIL on static + fast suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 05:31:11 +03:00

1658 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<AuthContext>` 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 `<App>` (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 — `<ProtectedRoute>` 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 `<FlightsPage>` is mounted — `results_report.md` row 11.
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Mount `/flights`; record render counter on `<FlightsPage>` | 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 — `<ProtectedRoute>` 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 `<AnnotationsPage>` (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 `<AnnotationsPage>`.
**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 `<AnnotationsPage>` 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 `<AnnotationsSidebar>` 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: <int>}` |
| 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 `<CanvasEditor>`)
**Input data**: `currentTime = T`, annotation at `T - 30 ms``results_report.md` row 29.
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Render `<CanvasEditor>` 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 `<CanvasEditor>` 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(<id>)``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: <id>}` (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 `<Header>` | 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 `<DatasetPage>` | selection state observable |
| 2 | Click Validate | one POST observed |
| 3 | Inspect URL + body | `/api/annotations/dataset/bulk-status`; body `{ids: <length-N>, 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 `<Header>` 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 `<AdminPage>` and Confirm — `results_report.md` row 49.
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Click Delete | `<ConfirmDialog>` 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 | `<ConfirmDialog>` 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 `<ConfirmDialog open>``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 `<Header>` 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 `<ProtectedRoute>` 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 `<CanvasEditor>`
**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 `<DetectionClasses>` | 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 19 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 `<AnnotationsSidebar>` | `<CanvasEditor>` 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`; `<AdminPage>` 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`; `<SettingsPage>` 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 `<MediaList>` | 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 | `<ConfirmDialog>` 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 `<ConfirmDialog>` 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 `<FlightMap>` with the env unset; inspect rendered `<TileLayer>` `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: `<TileLayer crossOrigin="use-credentials">` 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**: `<FlightMap>` and `<MiniMap>` mounted with the default tile URL.
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Mount `<FlightMap>`; inspect rendered `<TileLayer>` `data-cross-origin` attribute | `=== "use-credentials"` |
| 2 | Mount `<MiniMap pointPosition={…}>`; inspect rendered `<TileLayer>` `data-cross-origin` attribute | `=== "use-credentials"` |
| 3 | (e2e — gated) Issue `GET <VITE_SATELLITE_TILE_URL substituted with /tiles/1/0/0>` from the rendered map; inspect outbound request | `request.credentials === "include"` (browser attaches the same-origin auth cookie) |
**Pass criteria**: every `<TileLayer>` 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**: `<FlightMap>` mounted with the default tile URL; `<MiniMap>` mounted with only `pointPosition` (no `mapType` prop).
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Mount `<FlightMap>`; query `screen.queryByRole('button', { name: /satellite|classic/i })` | returns `null` |
| 2 | Mount `<FlightMap>`; query `screen.getAllByTestId('tile-layer')` | length `=== 1` (no per-mode branching, single layer) |
| 3 | Compile-time check: instantiate `<MiniMap pointPosition={…}>` 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 `<TileLayer>` | 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.
---
## 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`.