# 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 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 `<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.

---

### 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 `<AdminPage>` 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 `<AdminPage>` 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**: `<AdminPage>` 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`.
