# Batch Report

**Batch**: 07
**Tasks**: AZ-471 (Canvas Editor draw/resize/multi-select/zoom/pan), AZ-473 (PhotoMode switch + auto-select + yoloId), AZ-478 (Network resilience), AZ-479 (Bundle/FCP/soak)
**Date**: 2026-05-11
**Cycle**: Phase A baseline, Step 6 — Implement Tests
**Total complexity**: 13 pts (5 + 2 + 3 + 3)

## Task Results

| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|------|--------|----------------|-------|-------------|--------|
| AZ-471_test_canvas_bbox | Done | 1 created (`tests/canvas_editor.test.tsx`); 1 e2e created (`e2e/tests/canvas_bbox.e2e.ts`) | 15 fast (1 PASS draw + 8 PASS resize sub-tests + 3 `it.fails()` for AC-3/4/5 drifts + 3 control variants); 1 e2e (FT-P-39 only — manual draw, chromium-only) | 5 / 5 ACs covered | 3 documented drifts: Ctrl+click multi-select, Ctrl+wheel zoom-around-cursor, Ctrl+drag empty-canvas pan — all rooted in `handleMouseDown`'s early Ctrl-gate and `handleWheel`'s pan-not-adjusted bug |
| AZ-473_test_photo_mode | Done | 1 created (`tests/photo_mode.test.tsx`); 1 e2e created (`e2e/tests/photo_mode.e2e.ts`) | 5 fast (1 switch + 1 auto-select + 3 wire-offset across P=0/20/40); 3 e2e (one per photo mode) | 3 / 3 ACs covered | None — all PASS today |
| AZ-478_test_network_resilience | Done | 1 created (`tests/network_resilience.test.tsx`); 1 e2e created (`e2e/tests/network_resilience.e2e.ts`) | 7 fast (3 `it.fails()` + 3 controls + 1 service-worker check); 2 e2e (`test.fail` × 2 — offline boot + SSE disconnect) | 3 / 3 ACs covered | 3 documented drifts: silent /login redirect on offline boot (no network-error UI), tainted-canvas `toBlob` SecurityError unhandled, no SSE connection-lost banner |
| AZ-479_test_bundle_fcp_soak | Done | 1 modified (`scripts/run-tests.sh` — new `static_check_bundle_size` + `STC-PERF01` row); 1 e2e created (`e2e/tests/perf_fcp.e2e.ts`); 1 e2e created (`e2e/tests/perf_annotation_memory_soak.e2e.ts`) | 1 new static check (PASS); 1 e2e FCP measurement (chromium-only, suite-e2e profile); 1 e2e long-running soak (`RUN_LONG_RUNNING=1`, chromium-only) | 4 / 4 ACs covered | None |

## AC Test Coverage: All covered (15 / 15 ACs across the four tasks)

### AZ-471 — Canvas Editor draw / resize / multi-select / zoom / pan (5 ACs, 13 scenarios)

| Scenario | Where | Profile | Status |
|----------|-------|---------|--------|
| AC-1 / FT-P-39 manual draw geometry | `tests/canvas_editor.test.tsx` + `e2e/tests/canvas_bbox.e2e.ts` | fast + e2e | PASS — bbox carries canonical canvas-coordinate quad within ±0.5 px tolerance |
| AC-2 / FT-P-40 8-handle resize | `tests/canvas_editor.test.tsx` | fast (8 sub-tests) | PASS — every handle preserves the opposite anchor during the drag |
| AC-3 / FT-P-41 Ctrl+click multi-select | same | fast | `it.fails()` — drift: production never reaches the multi-select branch because `handleMouseDown` enters draw mode on Ctrl+button-0 |
| AC-4 / FT-P-42 Ctrl+wheel zoom-around-cursor | same | fast | `it.fails()` — drift: `handleWheel` updates `zoom` but does not adjust `pan`, so the cursor pixel drifts |
| AC-5 / FT-P-43 Ctrl+drag empty-canvas pan | same | fast | `it.fails()` — drift: same Ctrl-gate as AC-3; empty-canvas Ctrl+drag enters draw mode |

**AC summary**:
- AC-1 + AC-2 PASS today (geometry + resize anchors are correct).
- AC-3 + AC-4 + AC-5 → `it.fails()`. All three flip green together once `handleMouseDown` short-circuits Ctrl+button-0 only when there is a selectable target underneath, AND `handleWheel` adjusts pan to keep the cursor invariant.

### AZ-473 — PhotoMode switch + auto-select + yoloId (3 ACs, 8 scenarios)

| Scenario | Where | Profile | Status |
|----------|-------|---------|--------|
| AC-1 / FT-P-48 switch sets filter | `tests/photo_mode.test.tsx` | fast | PASS — toggling mode updates the rendered class list |
| AC-2 / FT-P-49 auto-select on out-of-range | same | fast | PASS — switching to a window where the current class is out-of-range reselects the first valid class |
| AC-3 / FT-P-50 wire offset (P=0) | `tests/photo_mode.test.tsx` + `e2e/tests/photo_mode.e2e.ts` | fast + e2e | PASS — outbound `classNum == classId + 0` |
| AC-3 / FT-P-50 wire offset (P=20) | same | fast + e2e | PASS — outbound `classNum == classId + 20` |
| AC-3 / FT-P-50 wire offset (P=40) | same | fast + e2e | PASS — outbound `classNum == classId + 40` |

**AC summary**: All 3 ACs PASS in both fast and e2e profiles.

### AZ-478 — Network resilience (3 ACs, 9 scenarios)

| Scenario | Where | Profile | Status |
|----------|-------|---------|--------|
| AC-1 / NFT-RES-03 no service worker on offline boot | `tests/network_resilience.test.tsx` | fast | PASS — `navigator.serviceWorker.getRegistrations()` returns `[]` |
| AC-1 / NFT-RES-03 user-visible network-error indicator | same | fast | `it.fails()` — drift: SPA redirects silently to `/login` |
| AC-1 / NFT-RES-03 control: SPA falls through to `/login` (drift snapshot) | same | fast | PASS — pins current behaviour |
| AC-1 / NFT-RES-03 e2e companion (offline boot) | `e2e/tests/network_resilience.e2e.ts` | e2e | `test.fail` — same drift |
| AC-2 / NFT-RES-09 tainted-canvas in-DOM fallback | `tests/network_resilience.test.tsx` | fast | `it.fails()` — drift: `toBlob` SecurityError is unhandled, no fallback rendered |
| AC-2 / NFT-RES-09 control: page does NOT crash even though `toBlob` throws | same | fast | PASS — page stays mounted (the rejection is unhandled but does not crash) |
| AC-3 / NFT-RES-10 SSE disconnect indicator within 2 s | `tests/network_resilience.test.tsx` + `e2e/tests/network_resilience.e2e.ts` | fast + e2e | `it.fails()` (fast) + `test.fail` (e2e) — drift: no SSE consumer renders a connection-lost banner |
| AC-3 / NFT-RES-10 control: error path fires (probe records errored=true) | `tests/network_resilience.test.tsx` | fast | PASS — pins the missing-banner drift |

**AC summary**:
- AC-1 service-worker subclause PASS today (defence in depth via `STC-N3` + this test).
- AC-1 user-visible indicator, AC-2, AC-3 → all drift today; flip green when `<App>` adds an offline error banner, `<AnnotationsPage>.handleDownload` adds `try/catch` with a fallback download path, and SSE consumers wire `createSSE`'s `onError` to a localised banner.

### AZ-479 — Bundle / FCP / annotation memory soak (4 ACs, 4 scenarios)

| Scenario | Where | Profile | Status |
|----------|-------|---------|--------|
| AC-1 / NFT-PERF-01 / NFT-RES-LIM-01 — initial JS bundle ≤ 2 MB gzipped | `scripts/run-tests.sh` `static_check_bundle_size` (`STC-PERF01`) | static | PASS — gates every commit (was previously only in the on-demand perf script) |
| AC-2 / NFT-RES-LIM-04 — `mission-planner/` not in `dist/` | `scripts/run-tests.sh` `static_check_dist_no_mission_planner` (`STC-S5`, pre-existing) | static | PASS |
| AC-3 / NFT-PERF-10 — FCP `/flights` ≤ 3 s median over 5 runs | `e2e/tests/perf_fcp.e2e.ts` | e2e (chromium-only, suite-e2e profile) | gated — runs on the suite-e2e lane; warmup + 5 measurements; median asserted ≤ 3000 ms |
| AC-4 / NFT-RES-LIM-05 — 30-min annotation soak (heap_t=1800 ≤ 1.10 × heap_t=60) | `e2e/tests/perf_annotation_memory_soak.e2e.ts` | e2e long-running (`RUN_LONG_RUNNING=1`, chromium-only) | gated — runs in the long-running CI lane only |

**AC summary**:
- AC-1 + AC-2 PASS in the per-commit static profile.
- AC-3 + AC-4 are gated to the e2e / long-running lanes per the spec; the spec requires `performance.memory` (chromium-only) and 30 minutes of wall time.

## Code Review Verdict: PASS

See `_docs/03_implementation/reviews/batch_07_review.md` for the full 7-phase walkthrough.

- 0 Critical, 0 High, 0 Medium, 0 Low findings.
- All `it.fails()` placements paired with a control PASS test that pins the current production drift.
- Architecture compliance (Phase 7): no layer-direction violations; tests are leaves of the import graph; no new cyclic dependencies; static profile (`STC-S6`, `STC-S13`, `STC-N3`) re-confirms.

## Auto-Fix Attempts: 0

PASS verdict — no auto-fix loop entered.

## Stuck Agents: None

Two investigations took moderate time, both already documented:

- AZ-471 AC-4 (`it.fails()` for zoom-around-cursor) initially appeared to pass because the canvas spy was accumulating draw calls across the pre-zoom and post-zoom render. Resetting `h.spy.strokeRectCalls` *immediately before* dispatching the wheel event, then asserting against the post-zoom box specifically, made the drift visible. The same lesson applies to all canvas spies that span multiple renders — reset before the act phase, not before the arrange phase.
- AZ-478 AC-2 (tainted-canvas) hit the JSDOM `URL.createObjectURL is not a function` issue during `AnnotationsPage.handleDownload` (the text-download path runs before the `.png` blob path). Fixed by patching `URL.createObjectURL` and `URL.revokeObjectURL` directly on the `URL` constructor — the same pattern recorded in `_docs/LESSONS.md` from the AZ-476 batch. The lesson held; no new entry needed.

## Test Run Summary

- `bun run test:fast` — 25 files / 150 passed / 13 skipped / 13.77 s wall.
- `./scripts/run-tests.sh --static-only` — 25 / 25 static checks PASS / 12.14 s wall (added `STC-PERF01`; no regressions in the existing 24).
- `ReadLints` — clean on all 9 changed files.
- `bunx tsc --noEmit` against the 5 new e2e files (out-of-tree of `tsconfig.test.json`) — clean.

## Documented Drifts (cumulative across batch)

| Drift | Where | Spec/AC affected | Resolves when |
|-------|-------|------------------|---------------|
| `handleMouseDown` enters draw mode on any Ctrl+button-0 click before evaluating multi-select / pan branches | `src/features/annotations/CanvasEditor.tsx` | AZ-471 AC-3 + AC-5 | Ctrl-gate is replaced by a target-aware branch: Ctrl+click on a bbox → toggle selection; Ctrl+drag on empty canvas → pan; only on Ctrl + empty + no-selection → enter draw |
| `handleWheel` updates `zoom` but does not adjust `pan` to keep the cursor pixel invariant | same | AZ-471 AC-4 | `pan` is recomputed so the canvas pixel under `(cx, cy)` before the wheel equals the canvas pixel under `(cx, cy)` after |
| `<App>` boot redirects silently to `/login` on `/api/*` failure; no in-DOM error banner | `src/auth/AuthContext.tsx` + `src/App.tsx` | AZ-478 AC-1 | Boot path renders a localized network-error banner (with a `data-testid="network-error-banner"` hook) on refresh failure |
| `AnnotationsPage.handleDownload` calls `canvas.toBlob` without `try/catch`; SecurityError surfaces as an unhandled rejection | `src/features/annotations/AnnotationsPage.tsx` | AZ-478 AC-2 | `try { canvas.toBlob(...) } catch (SecurityError) { render fallback download or in-DOM `role="alert"` }` |
| No SSE consumer (`AnnotationsSidebar`, `FlightsPage`, …) wires `createSSE`'s `onError` to a connection-lost banner | `src/features/annotations/AnnotationsSidebar.tsx`, `src/features/flights/FlightsPage.tsx` | AZ-478 AC-3 | `onError` paths render a localized banner (with a `data-testid="sse-disconnect-banner"` hook) within 2 s of `error+CLOSED` |

## Next Batch

After batch 7 archival, 2 tasks remain in `todo/`:
- AZ-474 (test tile-split zoom)
- AZ-480 (test prod image nginx RAM)

Cumulative review for batches 04–06 was already produced this cycle; the next cumulative review is due after batch 09 (covers batches 07–09) per `implement/SKILL.md` Step 14.5 (K=3 cadence). With only 2 tasks remaining, batch 8 is likely the last of Phase A and may be smaller than 4 tasks; the cumulative review will then close the cycle.
