From e9f2ec0629c90586cb27a0a84f3b2b412c11c440 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Thu, 14 May 2026 16:25:00 -0400 Subject: [PATCH] test(firmware): broaden native-test coverage + gcov instrumentation Extract the pre-first-image retry from normal_operation() into a templated bootstrap_loop() helper in operation.h so the loop body becomes unit-testable. Add four tests against it: two that verify the X-Panel-Id header is sent on every poll and matches the compile-time PANEL_ID (a silent server-side mis-routing risk if dropped), and two that exercise the loop's exit-on-deep-sleep vs. iterate-while-204 behaviour. Wire --coverage into env:native-test (compile + link via a post-script) so `gcovr -r . --filter src/` produces a real number, and ignore the stray *.gcov files gcovr drops at the repo root. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + platformio.ini | 9 ++- scripts/native_coverage_link.py | 6 ++ src/main.cpp | 8 +-- src/operation.h | 20 ++++++ test/mocks/config.h | 1 + test/test_normal_operation/test_main.cpp | 78 ++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 scripts/native_coverage_link.py diff --git a/.gitignore b/.gitignore index 66fc7a3..6ba4672 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .pio/ +*.gcov diff --git a/platformio.ini b/platformio.ini index 1ee51cc..96ec996 100644 --- a/platformio.ini +++ b/platformio.ini @@ -150,9 +150,16 @@ build_flags = -DBOARD_HAS_PSRAM ; ── Native unit tests — no hardware, uses test/mocks/ ── +; --coverage instruments gcov on both compile and link. After a test run, +; .gcda files land in .pio/build/native-test/test/... — gcovr aggregates +; them against src/. To regenerate the number: +; pio test -e native-test +; gcovr -r . --filter src/ --print-summary [env:native-test] platform = native lib_deps = throwtheswitch/Unity@^2.6 -build_flags = -DUNIT_TEST -std=c++17 -iquote test/mocks -iquote test -Itest/mocks -Itest +build_flags = -DUNIT_TEST -std=c++17 -iquote test/mocks -iquote test -Itest/mocks -Itest --coverage -O0 -g +build_unflags = -O2 test_build_src = no +extra_scripts = post:scripts/native_coverage_link.py diff --git a/scripts/native_coverage_link.py b/scripts/native_coverage_link.py new file mode 100644 index 0000000..8e8edd7 --- /dev/null +++ b/scripts/native_coverage_link.py @@ -0,0 +1,6 @@ +# Adds --coverage to the link step for env:native-test, so gcov's runtime +# (libgcov) gets linked alongside the instrumented objects. build_flags +# already pushes --coverage through compile; the linker needs it too or +# the program fails with undefined references to __gcov_init / etc. +Import("env") +env.Append(LINKFLAGS=["--coverage"]) diff --git a/src/main.cpp b/src/main.cpp index 9d05bd7..aae0b0e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -292,13 +292,13 @@ static void normal_operation(const String& mac) { // we've received our first image. While in the pre-image window it // returns instead, so we keep WiFi up and retry on a short interval — // way faster end-to-end than waiting through a deep-sleep + reconnect - // for every "no image yet" poll. - while (true) { + // for every "no image yet" poll. The loop body is extracted into + // bootstrap_loop() (operation.h) so it's unit-testable. + bootstrap_loop([&]() { HTTPClient http; http.begin(client, url); normal_operation_impl(mac, http, url, prefs); - delay(BOOTSTRAP_RETRY_INTERVAL_MS); - } + }); } // ── Setup ───────────────────────────────────────────────────────────────────── diff --git a/src/operation.h b/src/operation.h index 911b29e..17f4f31 100644 --- a/src/operation.h +++ b/src/operation.h @@ -356,3 +356,23 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre esp_sleep_enable_ext0_wakeup((gpio_num_t)PIN_BTN_RESET, 0); esp_deep_sleep_start(); } + +// ── Bootstrap-stay-awake loop ──────────────────────────────────────────────── +// Wraps normal_operation_impl in a retry loop for the pre-first-image window. +// Once an image arrives, impl calls esp_deep_sleep_start() and never returns — +// in production that's literal silicon-level halt; in unit tests the mock just +// sets g_deep_sleep_started and returns, so we check that flag to break out. +// Caller passes a callable that runs one poll iteration (instantiates a fresh +// HTTPClient bound to the WiFiClient, calls normal_operation_impl, etc.). +template +inline void bootstrap_loop(PollOnce poll_once) { + while (true) { + poll_once(); +#ifdef UNIT_TEST + // Production: esp_deep_sleep_start never returns. Tests: it sets the + // flag and returns, so without this guard the loop spins forever. + if (g_deep_sleep_started) return; +#endif + delay(BOOTSTRAP_RETRY_INTERVAL_MS); + } +} diff --git a/test/mocks/config.h b/test/mocks/config.h index cc2d9ac..e79f59a 100644 --- a/test/mocks/config.h +++ b/test/mocks/config.h @@ -13,6 +13,7 @@ #define SLEEP_CLAMP_MIN_MS 30000ULL #define SLEEP_CLAMP_MAX_MS (25ULL * 60ULL * 60ULL * 1000ULL) #define FIRST_IMAGE_POLL_INTERVAL_MS 15000ULL +#define BOOTSTRAP_RETRY_INTERVAL_MS 2000ULL #define WIFI_TIMEOUT_MS 30000 #define RESET_HOLD_MS 5000 #define AP_IP "192.168.4.1" diff --git a/test/test_normal_operation/test_main.cpp b/test/test_normal_operation/test_main.cpp index ebe7595..21bb2dc 100644 --- a/test/test_normal_operation/test_main.cpp +++ b/test/test_normal_operation/test_main.cpp @@ -534,6 +534,80 @@ void test_fw17b_missing_sha256_header_skips_verification() { TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_ERR_BORDER, -1)); } +// FW-PANEL-A: X-Panel-Id header sent on every poll. V2 (13.3") shipped with +// this header but no test covered it; if a future refactor accidentally +// drops the addHeader call, the server's DeviceModel routing breaks silently +// (a 13.3" frame would be served 7.3" images cropped to 800x480). +void test_fw_panel_id_header_sent_on_every_poll() { + g_http_response_headers["X-Image-Id"] = "1"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_TRUE_MESSAGE( + g_http_request_headers.find("X-Panel-Id") != g_http_request_headers.end(), + "X-Panel-Id header must be present on every poll"); +} + +// FW-PANEL-B: Header value matches the compile-time PANEL_ID. In the native- +// test build PANEL_ID is "unknown" (no env -D flag) — that's the fallback +// path; the real production envs set the panel-specific id via build_flags. +void test_fw_panel_id_header_value_matches_macro() { + g_http_response_headers["X-Image-Id"] = "1"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL_STRING(PANEL_ID, g_http_request_headers["X-Panel-Id"].c_str()); +} + +// FW-BOOT-LOOP-A: bootstrap_loop exits once the wrapped poll deep-sleeps +// (i.e. an image has arrived and impl entered esp_deep_sleep_start). In +// production esp_deep_sleep_start never returns; the test mock just sets +// g_deep_sleep_started and returns. Without the in-test break the loop +// would spin forever. +void test_fw_bootstrap_loop_exits_after_deep_sleep() { + g_http_response_headers["X-Image-Id"] = "1"; + g_http_response_headers["X-Interval-Ms"] = "300000"; + g_http_body = "BINDATA"; + + int iterations = 0; + bootstrap_loop([&]() { + iterations++; + normal_operation_impl(String("mac"), http, String("url"), prefs); + }); + + // One poll succeeded → deep_sleep flagged → loop returned. If the loop + // didn't honor the flag we'd never reach this assertion (test timeout). + TEST_ASSERT_EQUAL(1, iterations); + TEST_ASSERT_TRUE(g_deep_sleep_started); +} + +// FW-BOOT-LOOP-B: pre-image-arrived path — impl returns WITHOUT deep_sleep, +// so the loop would iterate in production. In the test we drive the +// iteration count via a side channel: the callable bumps a counter and +// flips the response code mid-run, exercising the retry behaviour without +// trapping the test forever. +void test_fw_bootstrap_loop_iterates_when_no_image() { + g_http_get_code = 204; // no image available yet + // No img_id in NVS, so impl returns without arming deep sleep. + + int iterations = 0; + bootstrap_loop([&]() { + iterations++; + normal_operation_impl(String("mac"), http, String("url"), prefs); + // After the third no-image poll, simulate the image arriving so the + // loop can exit cleanly. In production the loop runs until the + // server flips to 200 — we just compress the timeline. + if (iterations == 3) { + g_http_get_code = 200; + g_http_response_headers["X-Image-Id"] = "7"; + g_http_response_headers["X-Interval-Ms"] = "300000"; + g_http_body = "BINDATA"; + } + }); + + // Three 204 spins + one 200 spin = 4 total iterations before the loop + // saw the deep-sleep flag and broke out. Locks in that the loop does + // NOT exit on a no-image return (would freeze the device on the QR). + TEST_ASSERT_EQUAL(4, iterations); + TEST_ASSERT_TRUE(g_deep_sleep_started); +} + // FW-12/13: AP SSID derivation via ap_ssid_from_mac() void test_fw12_ap_ssid_from_mac_aabbcc() { String ssid = ap_ssid_from_mac(String("AA:BB:CC:DD:EE:FF")); @@ -583,5 +657,9 @@ int main(int argc, char** argv) { RUN_TEST(test_fw16_304_with_draw_needed_redraws); RUN_TEST(test_fw17a_sha256_mismatch_skips_draw_and_keeps_old_img_id); RUN_TEST(test_fw17b_missing_sha256_header_skips_verification); + RUN_TEST(test_fw_panel_id_header_sent_on_every_poll); + RUN_TEST(test_fw_panel_id_header_value_matches_macro); + RUN_TEST(test_fw_bootstrap_loop_exits_after_deep_sleep); + RUN_TEST(test_fw_bootstrap_loop_iterates_when_no_image); return UNITY_END(); }