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) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 16:25:00 -04:00
parent 013e49d859
commit e9f2ec0629
7 changed files with 118 additions and 5 deletions
+1
View File
@@ -1 +1,2 @@
.pio/
*.gcov
+8 -1
View File
@@ -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
+6
View File
@@ -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"])
+4 -4
View File
@@ -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 ─────────────────────────────────────────────────────────────────────
+20
View File
@@ -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<typename PollOnce>
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);
}
}
+1
View File
@@ -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"
+78
View File
@@ -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();
}