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>
25 KiB
status, createdAt, reviewedBy
| status | createdAt | reviewedBy | |||
|---|---|---|---|---|---|
| reviewed | 2026-05-06 |
|
pictureFrame — Comprehensive Test Plan
0. Scope and Goals
This plan covers unit, integration, and functional tests for all three layers of the pictureFrame system:
- Server — Symfony 8 / PHP 8.4 backend (controllers, services, message handlers, repositories)
- Frontend — Vue 3 SPA (stores, components, views)
- 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
RotationServiceandDeviceImageController— 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):
# 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:
<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:
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:
[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:
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 functionprovisioning.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.
# 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)
composer require --dev symfony/test-pack dama/doctrine-test-bundle doctrine/fixtures-bundle- Register dama extension in
phpunit.dist.xml - Add
MESSENGER_TRANSPORT_DSN=in-memory://to.env.test - Create + migrate test DB
- Write
DeviceImageControllerTest(I-01 through I-11) — covers the firmware API contract - Write
RotationServiceTest(R-01 through R-09) — pure unit, no DB - Write remaining controller + service tests
Phase 2 — Firmware native tests (second priority — protects the one thing that requires a physical reflash)
- Extract
normal_operation_impl<HTTP>()template intooperation.cpp - Add
[env:native-test]toplatformio.ini(test_build_src = no) - Write mock headers in
firmware/test/mocks/ - Write
test_normal_operation.cpp(FW-01 through FW-11, FW-02 is the regression test) - Write
test_provisioning.cpp(FW-12 through FW-16) - Write
test_reset_button.cpp(FW-17 through FW-18)
Phase 3 — Frontend unit tests
- Add Vitest +
@vue/test-utils+ MSW topackage.json - Add
testblock tovite.config.ts - Write store tests (no DOM needed — fastest to write)
- Write component tests
Phase 4 — CI pipeline
- Add Gitea Actions workflow
- Wire all three test commands in sequence
8. Known Gaps (explicitly out of scope for V1 tests)
CropEditor.vueandStickerCanvas.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