Files
pictureFrame/_bmad-output/planning-artifacts/test-plan.md
T
football2801 4002ff9fbf
CI / test (push) Has been cancelled
chore: stage all in-progress work before repo split
Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:11:31 -04:00

533 lines
25 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.
---
status: reviewed
createdAt: '2026-05-06'
reviewedBy: [ProductOwner, Architect, DevLead]
---
# pictureFrame — Comprehensive Test Plan
## 0. Scope and Goals
This plan covers unit, integration, and functional tests for all three layers of the pictureFrame system:
1. **Server** — Symfony 8 / PHP 8.4 backend (controllers, services, message handlers, repositories)
2. **Frontend** — Vue 3 SPA (stores, components, views)
3. **Firmware** — ESP32 Arduino C++ (control flow, protocol correctness)
Primary goals:
- Prevent regressions in the firmware ↔ server API contract (any break requires physical reflash of every deployed device)
- Cover all branching logic in `RotationService` and `DeviceImageController` — the most complex, failure-prone code
- FW-02 is the explicit regression test for the NVS header-read-after-end bug (headers read after `http.end()` → NVS never updated → 304 never fires)
- Give CI a green/red signal before any server deploy
---
## 1. Tooling and Infrastructure
### 1.1 PHP / Server
| Tool | Role | Already present? |
|---|---|---|
| PHPUnit 13.1 | Unit + integration + functional test runner | Yes (`phpunit.dist.xml`, `tests/bootstrap.php`) |
| `symfony/test-pack` | `WebTestCase`, `KernelTestCase`, test client | Needs adding |
| `dama/doctrine-test-bundle` | Wraps each test in a rolled-back transaction (fast isolation) | Needs adding |
| `doctrine/fixtures-bundle` | Seed fixture objects without raw SQL | Needs adding |
| Separate test database | `_test` suffix already in `doctrine.yaml` | Configured, DB needs creating |
**Required setup steps (all inside `ddev exec`):**
```bash
# Install test dependencies
ddev exec composer require --dev symfony/test-pack dama/doctrine-test-bundle doctrine/fixtures-bundle
# Create test DB and run migrations
ddev exec php bin/console doctrine:database:create --env=test
ddev exec php bin/console doctrine:migrations:migrate --no-interaction --env=test
```
**`phpunit.dist.xml` — add dama extension:**
```xml
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
```
**`.env.test.local`** (not committed — each developer creates this):
```
DATABASE_URL="pgsql://db:db@db:5432/pictureframe_test"
```
**`.env.test`** — add Messenger transport override so dispatched messages are observable in integration tests:
```
MESSENGER_TRANSPORT_DSN=in-memory://
```
Without this, `RenderImageMessage` dispatch assertions (IMG-01, SH-03, IMG-06) will silently not fire.
**Filesystem teardown for message handler tests:** `dama/doctrine-test-bundle` rolls back DB writes but NOT filesystem writes. Tests in `RenderImageMessageHandlerTest` that write `.bin` files to `storage/images/` must call a teardown that deletes the test image directory after each test. Use `setUp()`/`tearDown()` with a dedicated `storage/test/` prefix, or run handler tests in a separate suite without dama.
### 1.2 Frontend
| Tool | Role | Already present? |
|---|---|---|
| Vitest 2.x | Unit + component test runner (Vite-native) | Needs adding |
| `@vue/test-utils` | Mount Vue components in JSDOM | Needs adding |
| `@vitest/coverage-v8` | Coverage reporting | Needs adding |
| MSW 2.x | Intercept `fetch()` calls in tests | Needs adding |
Add to `frontend/package.json` devDependencies, then add `test` block to `vite.config.ts`:
```ts
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
}
```
**Known gap:** `CropEditor.vue` and `StickerCanvas.vue` depend on Konva.js canvas APIs unavailable in JSDOM. These two components are excluded from component tests and do NOT count toward the 60% component coverage target. They require Playwright E2E or manual testing.
### 1.3 Firmware
| Tool | Role | Already present? |
|---|---|---|
| PlatformIO `native` env | Compiles C++ for host, runs Unity tests | Needs adding |
| Unity (bundled) | Assertions and test runner macros | Bundled |
| Hand-rolled Arduino mocks | Stubs for Arduino/ESP APIs | Needs writing |
**`platformio.ini` additions:**
```ini
[env:native-test]
platform = native
lib_deps =
throwtheswitch/Unity@^2.6
build_flags = -DUNIT_TEST -Itest/mocks
test_build_src = no
```
`test_build_src = no` is **required**`yes` would compile `setup()` and `loop()` from `main.cpp` for the host, colliding with PlatformIO's native `main()` shim (ODR violation / duplicate symbol linker error).
**HTTPClient injection — template, not polymorphism:** Arduino's `HTTPClient` methods are not virtual, so `normal_operation()` cannot accept an `HTTPClient&` base reference. Use a compile-time template instead:
```cpp
template<typename HTTP>
static void normal_operation_impl(const String& mac, HTTP& http) { ... }
// Production:
static void normal_operation(const String& mac) {
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
http.begin(client, url);
normal_operation_impl(mac, http);
}
```
The mock `HTTP` type and the real `HTTPClient` share no base class — the template resolves at compile time. Production code is unchanged in behavior; tests instantiate with a mock type.
**Mock surface** — all files in `firmware/test/mocks/` (included before system headers via `-Itest/mocks` build flag):
| Mock file | Stubs | Notes |
|---|---|---|
| `Arduino.h` | `millis()`, `delay()`, `pinMode()`, `digitalRead()`, `String`, `Serial` | |
| `MockHTTPClient.h` | `begin()`, `GET()`, `header()`, `end()`, `writeToStream()`, `addHeader()`, `collectHeaders()` | `header()` must return empty string after `end()` is called — this is the FW-02 behavior |
| `WiFi.h` | `macAddress()`, `status()`, `begin()`, `mode()`, `softAP()`, `softAPIP()`, `localIP()` | |
| `WiFiClientSecure.h` | No-op constructor, `setInsecure()` | |
| `Preferences.h` | In-memory `std::map`-backed `getInt()`, `putInt()`, `getString()`, `putString()`, `clear()`, `begin()`, `end()` | |
| `LittleFS.h` / `FS.h` | In-memory file store; `writeToStream()` in `MockHTTPClient` pumps into this store | The wiring between HTTPClient mock and LittleFS mock must be explicit — mock HTTP holds a pointer to mock FS |
| `epd.h` | No-op stubs + call counters for each `epd_*` function | Shadows real header via `-Itest/mocks` — do NOT put mocks in `firmware/src/` |
| `esp_sleep.h` | Captures `sleepUs` in a global; separate `g_deepSleepStarted` boolean | Both `esp_sleep_enable_timer_wakeup` and `esp_deep_sleep_start` must be mocked |
---
## 2. Server Tests
### Test taxonomy
| Label used in this plan | Symfony base class | Scope |
|---|---|---|
| **Unit** | `PHPUnit\Framework\TestCase` | Pure PHP logic, mock repositories — no DB, no container |
| **Integration** | `KernelTestCase` + dama | Real DB, real container, no HTTP routing |
| **Functional** | `WebTestCase` + dama | Full HTTP stack: routing, firewall, kernel |
### 2.1 Unit Tests — `RotationService`
File: `tests/Unit/Service/RotationServiceTest.php`
Uses `PHPUnit\Framework\TestCase` with mock `EntityManager` and `DeviceImageHistoryRepository`. No DB, no container — pure selection algorithm logic.
| # | Test | What it asserts |
|---|---|---|
| R-01 | `advance_returns_null_when_pool_empty` | No ready RenderedAssets → `advance()` returns `null` |
| R-02 | `advance_picks_oldest_image_with_no_history` | Pool of 3 images, no history → returns oldest by `uploadedAt` |
| R-03 | `advance_skips_recently_shown_image` | Image A shown last → Image B returned next |
| R-04 | `advance_resets_when_all_candidates_in_window` | All pool images in uniqueness window → picks from full pool (no infinite loop) |
| R-05 | `advance_respects_uniqueness_window_size` | Window=2, pool=5 → only 2 IDs excluded |
| R-06 | `advance_writes_DeviceImageHistory_record` | `em->persist()` called with a `DeviceImageHistory` for the returned image |
| R-07 | `advance_updates_device_current_image` | `$device->getCurrentImage()` matches returned image |
| R-08 | `advance_only_considers_ready_assets` | Image with `pending` RenderedAsset excluded from pool |
| R-09 | `advance_respects_device_approved_images_only` | Image not approved for this device excluded from pool |
| R-10 | `advance_not_called_when_device_has_locked_image` | Controller bypasses `advance()` entirely when `lockedImage` set — assert `advance()` is never reached (test via controller, see I-06) |
### 2.2 Unit Tests — `TokenService`
File: `tests/Unit/Service/TokenServiceTest.php`
| # | Test | What it asserts |
|---|---|---|
| T-01 | `issue_creates_token_with_correct_type` | Returns `Token` with matching `TokenType` |
| T-02 | `issue_sets_expiry_in_future` | `expiresAt > now()` |
| T-03 | `consume_marks_token_used` | `usedAt` is set on the persisted entity |
| T-04 | `consume_returns_token_entity` | Return value is the `Token` object |
| T-05 | `consume_throws_on_expired_token` | Token past `expiresAt` → exception |
| T-06 | `consume_throws_on_already_used_token` | `usedAt` already set → exception |
| T-07 | `consume_throws_on_type_mismatch` | Requesting wrong `TokenType` → exception |
### 2.3 Unit Tests — `DeviceService`
File: `tests/Unit/Service/DeviceServiceTest.php`
| # | Test | What it asserts |
|---|---|---|
| D-01 | `link_associates_device_with_user` | `$device->getUser()` matches after link |
| D-02 | `link_idempotent_for_same_user` | Calling link twice does not throw or create duplicates |
| D-03 | `purgeHistory_removes_records_beyond_window` | History older than uniqueness window count is deleted |
| D-04 | `re_provision_clears_history_and_transfers_ownership` | Physical reset (clear credentials) wipes `DeviceImageHistory` and sets `user = null` on device — FR9 security invariant |
### 2.4 Functional Tests — `DeviceImageController`
File: `tests/Functional/Controller/DeviceImageControllerTest.php`
Uses `WebTestCase`. This is the highest-value test class — it covers the entire firmware-facing API contract.
| # | Route | Setup | Expected response |
|---|---|---|---|
| I-01 | `GET /api/device/{unknownMac}/image` | MAC not in DB | 404 |
| I-02 | `GET /api/device/{mac}/image` | Device exists, no approved images | 204 |
| I-03 | `GET /api/device/{mac}/image` | One ready image, no `X-Current-Image-Id` | 200, body is binary, `X-Image-Id` set, `X-Interval-Ms` set |
| I-04 | `GET /api/device/{mac}/image` + `X-Current-Image-Id: {id}` | ID matches current image | 304, no body, headers still set |
| I-05 | `GET /api/device/{mac}/image` + `X-Current-Image-Id: {staleId}` | ID does NOT match current image | 200, new image served |
| I-06 | `GET /api/device/{mac}/image` | Locked image set, no `X-Current-Image-Id` | 200, locked image served; `RotationService::advance()` NOT called (spy/mock) |
| I-07 | `GET /api/device/{mac}/image` + `X-Current-Image-Id: {lockedId}` | Locked image = current image | 304 |
| I-08 | `GET /api/device/{mac}/image` twice, second call changes image | Rotation advances: first call returns Image A, second call (different `X-Current-Image-Id`) returns Image B | Use a single-request test asserting `currentImage` changed in DB, not two HTTP calls through dama rollback |
| I-09 | `GET /api/device/{mac}/image` | `X-Interval-Ms` value | `rotationIntervalMinutes * 60 * 1000`, bounded by server ceiling |
| I-10 | `GET /api/device/{mac}/image` 200 response | `lastSeenAt` side effect | `device.lastSeenAt` updated after 200 poll |
| I-11 | `GET /api/device/{mac}/image` 304 response | `lastSeenAt` side effect | `device.lastSeenAt` also updated after 304 — a 304 is a successful poll |
### 2.5 Functional Tests — `DeviceApiController`
File: `tests/Functional/Controller/DeviceApiControllerTest.php`
| # | Route | Notes |
|---|---|---|
| A-01 | `GET /api/devices` (authenticated) | Returns only requesting user's devices |
| A-02 | `GET /api/devices` (unauthenticated) | 401 |
| A-03 | `PATCH /api/devices/{id}` | Updates name, orientation, rotationIntervalMinutes |
| A-04 | `PATCH /api/devices/{id}` — wrong user | 403 / 404 |
| A-05 | `PUT /api/devices/{id}/lock` | Sets `lockedImage`, response includes `lockedImageId` |
| A-06 | `PUT /api/devices/{id}/lock` — image not approved for device | 422 |
| A-07 | `DELETE /api/devices/{id}/lock` | Clears `lockedImage`, response has `lockedImageId: null` |
| A-08 | `PUT /api/devices/{id}/lock` — wrong user's device | 403 |
### 2.6 Functional Tests — `ImageApiController`
File: `tests/Functional/Controller/ImageApiControllerTest.php`
| # | Route | Notes |
|---|---|---|
| IMG-01 | `POST /api/images` | Valid upload → 201, `Image` in DB, `RenderImageMessage` in in-memory transport |
| IMG-02 | `POST /api/images` | No file → 422 |
| IMG-03 | `GET /api/images` | Returns authenticated user's images with thumbnail/original URLs |
| IMG-04 | `DELETE /api/images/{id}` | Soft-deletes image (`deletedAt` set, row still exists) |
| IMG-05 | `DELETE /api/images/{id}` — wrong user | 403 |
| IMG-06 | `POST /api/images/{id}/reprocess` | New `RenderImageMessage` in in-memory transport |
### 2.7 Functional Tests — `SharedImageApiController`
File: `tests/Functional/Controller/SharedImageApiControllerTest.php`
| # | Route | Notes |
|---|---|---|
| SH-01 | `GET /api/shared` | Paginated result, `totalPages`/`total`/`page` fields correct |
| SH-02 | `GET /api/shared?status=pending` | Filters by status |
| SH-03 | `PUT /api/shared/{id}/approve` | Status → approved, `RenderImageMessage` in in-memory transport |
| SH-04 | `PUT /api/shared/{id}/decline` | Status → declined |
| SH-05 | `PUT /api/shared/{id}/approve` — wrong user | 403 |
| SH-06 | Approve → image enters rotation pool | After approval + render, image appears in `RotationService::readyPool()` for the recipient's device |
### 2.8 Functional Tests — `TokenActionController`
File: `tests/Functional/Controller/TokenActionControllerTest.php`
| # | Test | Notes |
|---|---|---|
| TK-01 | Valid `share_approve` token | Approval performed, token marked used, redirected |
| TK-02 | Expired token | 404 or 410 |
| TK-03 | Already-used token | 404 or 410 |
| TK-04 | Wrong token type | 404 |
| TK-05 | `hard_delete_confirm` token | Image hard-deleted from DB and storage |
### 2.9 Functional Tests — `SetupController`
File: `tests/Functional/Controller/SetupControllerTest.php`
| # | Route | Notes |
|---|---|---|
| S-01 | `GET /setup/{mac}` — new device | Shows registration/login form |
| S-02 | `GET /setup/{mac}` — already-linked device | Redirects to configure step |
| S-03 | `POST /setup/{mac}/register` | Creates User, links Device to User |
| S-04 | `POST /setup/{mac}/register` — duplicate email | Validation error |
| S-05 | `POST /setup/{mac}/configure` | Saves `name`, `orientation`, `rotationIntervalMinutes` |
### 2.10 Functional Tests — `SecurityController`
File: `tests/Functional/Controller/SecurityControllerTest.php`
| # | Route | Notes |
|---|---|---|
| SEC-01 | `POST /login` valid credentials | Session cookie set, redirected |
| SEC-02 | `POST /login` wrong password | Error message, no session |
| SEC-03 | `POST /register` | Creates user with hashed password |
| SEC-04 | `POST /register` duplicate email | Form error |
### 2.11 Integration Tests — Message Handlers
**Note:** These tests run without `dama/doctrine-test-bundle` transaction wrapping because they write to the filesystem. Run in a separate PHPUnit suite (`tests/Integration/`) with their own teardown strategy: each test uses a unique `storage/test/{uuid}/` path, cleaned up in `tearDown()`.
File: `tests/Integration/MessageHandler/RenderImageMessageHandlerTest.php`
Requires Imagick installed (present in DDEV via `php8.4-imagick`). Run `ddev exec` — not on host.
| # | Test | Notes |
|---|---|---|
| MH-01 | Valid image + device model | `RenderedAsset.status` = `ready`, `.bin` file written to storage path |
| MH-01b | **4bpp palette contract** | Reads first bytes of written `.bin`; asserts palette indices match Spectra 6 map (BLACK=0x0, WHITE=0x1, YELLOW=0x2, RED=0x3, BLUE=0x5, GREEN=0x6) — prevents a server-side Imagick palette bug from silently producing a garbled display |
| MH-02 | Missing source image file | `RenderedAsset.status` = `failed`, no uncaught exception |
| MH-03 | Both orientations | Two `RenderedAsset` rows for same image (landscape + portrait) |
File: `tests/Integration/MessageHandler/RunImageCleanupMessageHandlerTest.php`
| # | Test | Notes |
|---|---|---|
| CL-01 | Image past retention with approvals | Soft-delete flag set |
| CL-02 | Image past retention with no approvals | Hard-deleted from DB and storage |
| CL-03 | Recent image | Untouched |
---
## 3. Frontend Tests
### 3.1 Store Tests
**`tests/frontend/stores/devices.test.ts`**
| # | Test | Notes |
|---|---|---|
| DS-01 | `fetchDevices` success | `devices` populated, `loading` false |
| DS-02 | `fetchDevices` network error | `error` set, `devices` empty |
| DS-03 | `updateDevice` patches local array | `devices[idx]` updated after PATCH |
| DS-04 | `lockImage` sets `lockedImageId` on local device | Updated from server response |
| DS-05 | `unlockImage` clears `lockedImageId` | `null` in local state |
**`tests/frontend/stores/images.test.ts`**
| # | Test | Notes |
|---|---|---|
| IM-01 | `fetchImages` success | `images` populated |
| IM-02 | Upload workflow state transitions | `setFile``setCrop``setStickers``setDevices` in correct order |
| IM-03 | `pendingCount` computed | Reflects `status === 'pending'` images |
**`tests/frontend/stores/auth.test.ts`**
| # | Test | Notes |
|---|---|---|
| AU-01 | Bootstraps from `window.__BOOTSTRAP_USER__` | `user` set, `isAuthenticated` true |
| AU-02 | No bootstrap data | `isAuthenticated` false |
**`tests/frontend/stores/toast.test.ts`**
| # | Test | Notes |
|---|---|---|
| TO-01 | `push` adds message | Queue length +1 |
| TO-02 | Auto-dismiss after 2.5s | Queue empty after timeout (Vitest fake timers) |
### 3.2 Component Tests
**`tests/frontend/components/FrameCard.test.ts`**
| # | Test | Notes |
|---|---|---|
| FC-01 | Status "ok" renders green badge | `lastSeenAt` recent |
| FC-02 | Status "sync-fail" renders yellow badge | `lastSeenAt` >2× interval ago |
| FC-03 | Status "no-wifi" badge | `lastSeenAt` null |
**`tests/frontend/components/BaseButton.test.ts`**
| # | Test | Notes |
|---|---|---|
| BB-01 | Loading spinner when `loading=true` | Label hidden |
| BB-02 | Disabled when `disabled=true` | `disabled` attribute present |
| BB-03 | Emits `click` when not disabled | |
**`tests/frontend/components/DevicePicker.test.ts`**
| # | Test | Notes |
|---|---|---|
| DP-01 | Selecting device emits `update:modelValue` | |
| DP-02 | Deselecting removes from list | |
| DP-03 | Pre-selected devices shown as checked | |
**`tests/frontend/components/ShareSheet.test.ts`**
| # | Test | Notes |
|---|---|---|
| SS-01 | Valid email submits share API call | `POST /api/share` intercepted by MSW |
| SS-02 | Empty email shows validation error, no API call | |
| SS-03 | Success closes sheet | |
### 3.3 View Integration Tests
**`tests/frontend/views/LibraryView.test.ts`**
| # | Test | Notes |
|---|---|---|
| LV-01 | Mounted in "uploaded" tab | Image grid renders |
| LV-02 | Switch to "shared" tab | `fetchShared` called, ApproveCards render |
| LV-03 | Lock chip shown for approved devices | Chip present below image |
| LV-04 | Unlocked chip click calls `lockImage` | Store action called |
| LV-05 | Locked chip (solid) click calls `unlockImage` | Store action called |
| LV-06 | Share button opens ShareSheet | `shareSheetOpen` true |
| LV-07 | Empty library state | Empty-state prompt visible when `images.length === 0` |
**`tests/frontend/views/HomeView.test.ts`**
| # | Test | Notes |
|---|---|---|
| HV-01 | Renders FrameCard for each device | N cards for N devices |
| HV-02 | Empty state shown when no devices | Prompt/empty message visible |
---
## 4. Firmware Tests
### 4.1 Architecture
PlatformIO `native` env (`test_build_src = no`) compiles only `firmware/test/` test files and the specific source files they include explicitly. `setup()` and `loop()` from `main.cpp` are excluded — they would collide with PlatformIO's native `main()` shim.
The testable logic in `main.cpp` is extracted into:
- `operation.cpp` / `operation.h``normal_operation_impl<HTTP>(mac, http)` template function
- `provisioning.cpp` / `provisioning.h``ap_ssid_from_mac()`, `attempt_wifi()`
`epd.h` is shadowed by `firmware/test/mocks/epd.h` via the `-Itest/mocks` build flag in `[env:native-test]`.
### 4.2 Unit Tests — `normal_operation_impl()` control flow
File: `firmware/test/test_normal_operation.cpp`
| # | Setup | Expected |
|---|---|---|
| FW-01 | HTTP 200, `X-Image-Id: 42`, `X-Interval-Ms: 60000` | File written to mock FS, `epd_draw_image_from_file` call count = 1, NVS `img_id = 42`, sleep = 60000ms, `g_deepSleepStarted = true` |
| FW-02 | HTTP 200, assert header read order | `newId` is `"42"` (non-empty) — verifies headers read BEFORE `http.end()`. **Regression test for NVS bug.** |
| FW-03 | HTTP 304 | `epd_init` call count = 0, `epd_draw_image_from_file` call count = 0, sleep set from `X-Interval-Ms`, `g_deepSleepStarted = true` |
| FW-04 | HTTP 204 | `show_setup_qr` (or `epd_draw_setup_screen`) call count = 1, `epd_draw_image_from_file` call count = 0 |
| FW-05 | HTTP 404 | `show_setup_qr` call count = 1 |
| FW-06 | HTTP 500 | `epd_fill(COLOR_YELLOW)` called |
| FW-07 | NVS has saved `img_id = 99` | `X-Current-Image-Id: 99` present in request headers sent by mock HTTP |
| FW-08 | NVS `img_id` at default (-1) | `X-Current-Image-Id` header NOT added to request |
| FW-09 | `X-Interval-Ms: 120000`, `FETCH_INTERVAL_MS = 300000` | `sleepMs = 120000` (server value honored, within ceiling) |
| FW-10 | `X-Interval-Ms: 600000`, `FETCH_INTERVAL_MS = 300000` | `sleepMs = 300000` (capped at firmware ceiling) |
| FW-11 | `X-Interval-Ms` absent | `sleepMs = FETCH_INTERVAL_MS` (default) |
### 4.3 Unit Tests — Provisioning
File: `firmware/test/test_provisioning.cpp`
| # | Test | Expected |
|---|---|---|
| FW-12 | MAC `AA:BB:CC:DD:EE:FF` | SSID = `PictureFrame-EEFF` |
| FW-13 | MAC `1C:C3:AB:D1:91:F8` | SSID = `PictureFrame-91F8` |
| FW-14 | WiFi connects within timeout | `attempt_wifi()` returns `true` |
| FW-15 | WiFi never connects | `attempt_wifi()` returns `false` after `WIFI_TIMEOUT_MS` |
| FW-16 | WiFi connect fails → re-provisioning | `loop()` on `attempt_wifi()` failure: `epd_fill(COLOR_RED)` called, then `enter_provisioning()` called — confirms provisioning state machine re-enters correctly |
### 4.4 Unit Tests — Button-hold re-provisioning
File: `firmware/test/test_reset_button.cpp`
| # | Test | Expected |
|---|---|---|
| FW-17 | `digitalRead(PIN_BTN_RESET)` returns `LOW` for ≥ `RESET_HOLD_MS` | `clear_creds = true`, `prefs.clear()` called, provisioning mode entered |
| FW-18 | Button released before `RESET_HOLD_MS` | `clear_creds = false`, normal boot continues |
---
## 5. Coverage Targets
| Layer | Target | Priority |
|---|---|---|
| `RotationService` | 100% line | Critical |
| `DeviceImageController` | 100% line | Critical |
| `TokenService` | 95%+ | High |
| All other `src/Service/` | 80%+ | High |
| All `src/Controller/` | 80%+ | High |
| `src/MessageHandler/` | 85%+ | High (rendering pipeline is high-risk) |
| Frontend stores | 80%+ | High |
| Frontend components | 60%+ (excluding CropEditor, StickerCanvas) | Medium |
| Firmware control flow (`operation.cpp`) | 90%+ branches | High |
---
## 6. CI Integration
All three suites must pass before merge to `master`.
```bash
# Server
ddev exec php bin/phpunit --coverage-text
# Firmware
cd firmware && pio test -e native-test
# Frontend
cd frontend && npx vitest run --coverage
```
---
## 7. Implementation Order
**Phase 1 — Server functional + integration tests** (highest ROI, minimal new tooling)
1. `composer require --dev symfony/test-pack dama/doctrine-test-bundle doctrine/fixtures-bundle`
2. Register dama extension in `phpunit.dist.xml`
3. Add `MESSENGER_TRANSPORT_DSN=in-memory://` to `.env.test`
4. Create + migrate test DB
5. Write `DeviceImageControllerTest` (I-01 through I-11) — covers the firmware API contract
6. Write `RotationServiceTest` (R-01 through R-09) — pure unit, no DB
7. Write remaining controller + service tests
**Phase 2 — Firmware native tests** (second priority — protects the one thing that requires a physical reflash)
1. Extract `normal_operation_impl<HTTP>()` template into `operation.cpp`
2. Add `[env:native-test]` to `platformio.ini` (`test_build_src = no`)
3. Write mock headers in `firmware/test/mocks/`
4. Write `test_normal_operation.cpp` (FW-01 through FW-11, FW-02 is the regression test)
5. Write `test_provisioning.cpp` (FW-12 through FW-16)
6. Write `test_reset_button.cpp` (FW-17 through FW-18)
**Phase 3 — Frontend unit tests**
1. Add Vitest + `@vue/test-utils` + MSW to `package.json`
2. Add `test` block to `vite.config.ts`
3. Write store tests (no DOM needed — fastest to write)
4. Write component tests
**Phase 4 — CI pipeline**
1. Add Gitea Actions workflow
2. Wire all three test commands in sequence
---
## 8. Known Gaps (explicitly out of scope for V1 tests)
- `CropEditor.vue` and `StickerCanvas.vue` — Konva.js canvas not testable in JSDOM; require Playwright E2E
- Admin moderation panel (`AdminModerationController`) — not yet implemented
- Collections approval (FR21) — not yet implemented
- E-ink display pixel-level rendering correctness — no practical automated test; validated by eye on hardware
- Interval timing drift accuracy (±5 min PRD requirement) — requires a time-series integration test; deferred to post-V1