From 230acb7f3ca3d0d0e563ffb8d11aa890997b0118 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 11:49:13 +0000 Subject: [PATCH 1/2] test: add assertion-based unit tests for pure-logic subsystems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 112 checks across five suites: - temperature: cToF/fToC/toUnits conversions and round-trips - weather_alerts: all five alert kinds, threshold boundary cases, priority ordering, case-insensitive snow detection - weather_cache: shouldFetchNow, isStale, shouldAlert state machine, exponential backoff schedule (30/60/120…600 s cap) - display_rotation: screen cycling, stale asterisk, alert screen insertion, Celsius mode, line-length clipping, no-forecast fallback - config: JSON overrides, env-var priority, all sanity guards test/stubs/weather_stub.cpp replaces src/weather.cpp so the unit build has no libcurl dependency and runs without network access. CI: unit tests run before the existing visualization harnesses in the dev-build job (no new apt packages required). --- .github/workflows/build.yml | 5 + test/Makefile.unit | 34 ++ test/stubs/weather_stub.cpp | 17 + test/test_unit.cpp | 779 ++++++++++++++++++++++++++++++++++++ 4 files changed, 835 insertions(+) create mode 100644 test/Makefile.unit create mode 100644 test/stubs/weather_stub.cpp create mode 100644 test/test_unit.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 60c9866..f4ed10d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,11 @@ jobs: - name: Build mock binary (Makefile.mac) run: make -f test/Makefile.mac + - name: Run unit tests (assertion-based) + run: | + make -f test/Makefile.unit + ./test_unit + - name: Run celestial math tests run: | make -f test/Makefile.celestial diff --git a/test/Makefile.unit b/test/Makefile.unit new file mode 100644 index 0000000..25c71d3 --- /dev/null +++ b/test/Makefile.unit @@ -0,0 +1,34 @@ +# Assertion-based unit tests for pure-logic subsystems. +# No hardware drivers, no real HTTP calls. +# +# From the project root: +# make -f test/Makefile.unit # build +# make -f test/Makefile.unit run # build + run +# make -f test/Makefile.unit clean + +CXX = g++ +CXXFLAGS = -std=c++17 -D_DEFAULT_SOURCE -Iinclude -Wall -Wextra -g + +# Only the pure-logic source files — no LCD, OLED, SPI, I2C, ring, or main. +# test/stubs/weather_stub.cpp replaces src/weather.cpp so that +# weather_cache.cpp can be compiled without a libcurl dependency. +# An empty API key makes the stub return an invalid report immediately, +# which is how we drive the cache failure state machine in tests. +SRCS = \ + src/weather_alerts.cpp \ + src/weather_cache.cpp \ + test/stubs/weather_stub.cpp \ + src/display_rotation.cpp \ + src/config.cpp + +TARGET = test_unit + +$(TARGET): test/test_unit.cpp $(SRCS) + $(CXX) $(CXXFLAGS) -o $(TARGET) test/test_unit.cpp $(SRCS) + +run: $(TARGET) + ./$(TARGET) + +.PHONY: run clean +clean: + rm -f $(TARGET) diff --git a/test/stubs/weather_stub.cpp b/test/stubs/weather_stub.cpp new file mode 100644 index 0000000..2ee9252 --- /dev/null +++ b/test/stubs/weather_stub.cpp @@ -0,0 +1,17 @@ +// Replaces src/weather.cpp in the unit test build. +// No libcurl dependency — returns an invalid report for any call. +// The empty-key path is what test_weather_cache.cpp uses to drive failures. +#include "weather.h" +#include "log.h" + +WeatherReport getWeatherReport(const std::string& apiKey, + const std::string& city, + std::string* /*rawResponse*/) { + WeatherReport report; + if (apiKey.empty()) { + LOG_ERROR("getWeatherReport: empty API key"); + return report; + } + LOG_ERROR("getWeatherReport: no HTTP in unit-test build (key=" << city << ")"); + return report; +} diff --git a/test/test_unit.cpp b/test/test_unit.cpp new file mode 100644 index 0000000..8d661d5 --- /dev/null +++ b/test/test_unit.cpp @@ -0,0 +1,779 @@ +// test_unit.cpp — assertion-based unit tests for pure-logic subsystems. +// Exits 0 on pass, 1 on any failure. No third-party framework. + +#include +#include +#include +#include +#include +#include + +#include "temperature.h" +#include "weather_alerts.h" +#include "weather_cache.h" +#include "display_rotation.h" +#include "config.h" + +// --------------------------------------------------------------------------- +// Minimal test runner +// --------------------------------------------------------------------------- + +static int s_pass = 0; +static int s_fail = 0; +static const char* s_suite = "?"; + +#define SUITE(n) s_suite = (n) + +#define CHECK(cond) \ + do { \ + if (cond) { \ + ++s_pass; \ + } else { \ + ++s_fail; \ + std::fprintf(stderr, " FAIL [%s] %s:%d: %s\n", \ + s_suite, __FILE__, __LINE__, #cond); \ + } \ + } while (0) + +static bool approxEq(float a, float b, float tol = 0.01f) { + return std::fabs(a - b) < tol; +} + +static int finish() { + if (s_fail == 0) { + std::printf("OK All %d checks passed.\n", s_pass); + return 0; + } + std::printf("FAIL %d/%d checks failed.\n", s_fail, s_pass + s_fail); + return 1; +} + +// --------------------------------------------------------------------------- +// Helpers to build test WeatherReports +// --------------------------------------------------------------------------- + +static WeatherReport makeReport(float tempC, + const std::string& currentDesc, + float todayHiC, float todayLoC, + float tmrwHiC, float tmrwLoC, + int rainPct = 0, + bool willSnow = false, + const std::string& tmrwDesc = "Cloudy") { + WeatherReport r; + r.valid = true; + r.fetchedAt = 1000; + + r.current.tempC = tempC; + r.current.description = currentDesc; + + DailyForecast today; + today.highC = todayHiC; + today.lowC = todayLoC; + today.description = "Sunny"; + + DailyForecast tomorrow; + tomorrow.highC = tmrwHiC; + tomorrow.lowC = tmrwLoC; + tomorrow.chanceOfRain = rainPct; + tomorrow.willItSnow = willSnow; + tomorrow.description = tmrwDesc; + + r.forecast.push_back(today); + r.forecast.push_back(tomorrow); + return r; +} + +static weather_alerts::AlertSet noAlerts() { return {}; } + +// --------------------------------------------------------------------------- +// temperature +// --------------------------------------------------------------------------- + +static void test_temperature() { + SUITE("temperature"); + + CHECK(approxEq(temperature::cToF(0.0f), 32.0f)); + CHECK(approxEq(temperature::cToF(100.0f), 212.0f)); + CHECK(approxEq(temperature::cToF(-40.0f), -40.0f)); // crossover point + + CHECK(approxEq(temperature::fToC(32.0f), 0.0f)); + CHECK(approxEq(temperature::fToC(212.0f), 100.0f)); + CHECK(approxEq(temperature::fToC(-40.0f), -40.0f)); + + // round-trips + CHECK(approxEq(temperature::fToC(temperature::cToF(25.0f)), 25.0f)); + CHECK(approxEq(temperature::cToF(temperature::fToC(77.0f)), 77.0f)); + + // toUnits + CHECK(approxEq(temperature::toUnits(20.0f, "F"), 68.0f)); + CHECK(approxEq(temperature::toUnits(20.0f, "C"), 20.0f)); + CHECK(approxEq(temperature::toUnits(0.0f, "X"), 0.0f)); // unknown unit → Celsius +} + +// --------------------------------------------------------------------------- +// weather_alerts +// --------------------------------------------------------------------------- + +static void test_weather_alerts() { + SUITE("weather_alerts"); + weather_alerts::Thresholds t; // defaults: 80% rain, 15 F swing + + // Invalid report → nothing + { + WeatherReport invalid; + auto a = weather_alerts::detect(invalid, t); + CHECK(!a.hasAny()); + CHECK(a.primary() == nullptr); + } + + // Normal conditions → no alerts + { + // today 20 C (~68 F), tomorrow 20 C, 0% rain, no snow + auto r = makeReport(20.0f, "Clear", 20.0f, 10.0f, 20.0f, 10.0f); + auto a = weather_alerts::detect(r, t); + CHECK(!a.hasAny()); + } + + // API alert → SevereWeather with correct headline + { + auto r = makeReport(20.0f, "Stormy", 20.0f, 10.0f, 20.0f, 10.0f); + WeatherAlert wa; + wa.event = "Tornado Warning"; + wa.headline = "Tornado Warning issued for Cook County"; + r.alerts.push_back(wa); + + auto a = weather_alerts::detect(r, t); + CHECK(a.hasAny()); + CHECK(a.primary() != nullptr); + CHECK(a.primary()->kind == weather_alerts::Kind::SevereWeather); + CHECK(a.primary()->headline == "Tornado Warning"); + } + + // API alert with empty event → falls back to "Weather Alert" + { + auto r = makeReport(20.0f, "Stormy", 20.0f, 10.0f, 20.0f, 10.0f); + WeatherAlert wa; + wa.event = ""; + wa.headline = "Severe weather in effect"; + r.alerts.push_back(wa); + + auto a = weather_alerts::detect(r, t); + CHECK(a.primary()->headline == "Weather Alert"); + } + + // Heavy rain: exactly at threshold (80%) → triggers + { + auto r = makeReport(20.0f, "Clear", 20.0f, 10.0f, 18.0f, 10.0f, 80); + auto a = weather_alerts::detect(r, t); + bool found = false; + for (const auto& al : a.active) + if (al.kind == weather_alerts::Kind::HeavyRainComing) found = true; + CHECK(found); + } + + // Heavy rain: above threshold (85%) + { + auto r = makeReport(20.0f, "Clear", 20.0f, 10.0f, 18.0f, 10.0f, 85); + auto a = weather_alerts::detect(r, t); + bool found = false; + for (const auto& al : a.active) + if (al.kind == weather_alerts::Kind::HeavyRainComing) found = true; + CHECK(found); + } + + // Heavy rain: below threshold (79%) → no alert + { + auto r = makeReport(20.0f, "Clear", 20.0f, 10.0f, 18.0f, 10.0f, 79); + auto a = weather_alerts::detect(r, t); + bool found = false; + for (const auto& al : a.active) + if (al.kind == weather_alerts::Kind::HeavyRainComing) found = true; + CHECK(!found); + } + + // Snow via willItSnow flag + { + auto r = makeReport(1.0f, "Cold", 2.0f, -3.0f, -2.0f, -8.0f, + 0, /*willSnow=*/true); + auto a = weather_alerts::detect(r, t); + bool found = false; + for (const auto& al : a.active) + if (al.kind == weather_alerts::Kind::HeavySnowComing) found = true; + CHECK(found); + } + + // Snow via description (case-insensitive) + { + auto r = makeReport(1.0f, "Cold", 2.0f, -3.0f, -2.0f, -8.0f, + 0, false, "Heavy SNOW Showers"); + auto a = weather_alerts::detect(r, t); + bool found = false; + for (const auto& al : a.active) + if (al.kind == weather_alerts::Kind::HeavySnowComing) found = true; + CHECK(found); + } + + // No snow: description without "snow", flag false + { + auto r = makeReport(1.0f, "Cold", 2.0f, -3.0f, -2.0f, -8.0f, + 0, false, "Partly cloudy"); + auto a = weather_alerts::detect(r, t); + bool found = false; + for (const auto& al : a.active) + if (al.kind == weather_alerts::Kind::HeavySnowComing) found = true; + CHECK(!found); + } + + // BigTempDrop: today 20 C (68 F), tomorrow 10 C (50 F) → delta = -18 F (≥ 15 F) + { + auto r = makeReport(20.0f, "Nice", 20.0f, 10.0f, 10.0f, 0.0f); + auto a = weather_alerts::detect(r, t); + bool found = false; + for (const auto& al : a.active) + if (al.kind == weather_alerts::Kind::BigTempDrop) found = true; + CHECK(found); + } + + // BigTempJump: today 10 C (50 F), tomorrow 20 C (68 F) → delta = +18 F + { + auto r = makeReport(10.0f, "Mild", 10.0f, 5.0f, 20.0f, 10.0f); + auto a = weather_alerts::detect(r, t); + bool found = false; + for (const auto& al : a.active) + if (al.kind == weather_alerts::Kind::BigTempJump) found = true; + CHECK(found); + } + + // Small swing (5 C = 9 F < 15 F threshold) → no temp alert + { + auto r = makeReport(20.0f, "Mild", 20.0f, 15.0f, 15.0f, 10.0f); + auto a = weather_alerts::detect(r, t); + bool found = false; + for (const auto& al : a.active) + if (al.kind == weather_alerts::Kind::BigTempDrop || + al.kind == weather_alerts::Kind::BigTempJump) + found = true; + CHECK(!found); + } + + // Priority: SevereWeather appears before HeavyRainComing + { + auto r = makeReport(20.0f, "Stormy", 20.0f, 10.0f, 18.0f, 10.0f, 90); + WeatherAlert wa; + wa.event = "Severe Thunderstorm Warning"; + wa.headline = "Severe storm..."; + r.alerts.push_back(wa); + + auto a = weather_alerts::detect(r, t); + CHECK(a.active.size() >= 2u); + CHECK(a.active[0].kind == weather_alerts::Kind::SevereWeather); + CHECK(a.active[1].kind == weather_alerts::Kind::HeavyRainComing); + } +} + +// --------------------------------------------------------------------------- +// weather_cache — state machine (HTTP mocked via empty API key) +// +// getWeatherReport() returns an invalid report immediately when apiKey is +// empty (no HTTP call), so we can drive the failure state machine without +// network access. +// --------------------------------------------------------------------------- + +static void test_weather_cache() { + SUITE("weather_cache"); + + // Fresh cache always wants to fetch + { + WeatherCache c; + c.configure(600, 1800, 6); + CHECK(c.shouldFetchNow(1000)); + CHECK(c.shouldFetchNow(99999)); + } + + // isStale when no successful fetch yet + { + WeatherCache c; + c.configure(600, 1800, 6); + CHECK(c.isStale(1000)); + } + + // Failure: empty key → tryFetch returns false, failureCount increments + { + WeatherCache c; + c.configure(600, 1800, 6); + bool ok = c.tryFetch("", "Westmont, IL", 1000); + CHECK(!ok); + CHECK(c.consecutiveFailures() == 1); + CHECK(c.nextFetchTime() > 1000); + CHECK(c.isStale(1000)); // still stale — never succeeded + } + + // shouldAlert: stays false below threshold + { + WeatherCache c; + c.configure(600, 1800, 3); + c.tryFetch("", "city", 1000); + c.tryFetch("", "city", 1000); + CHECK(c.consecutiveFailures() == 2); + CHECK(!c.shouldAlert(1000)); + } + + // shouldAlert: true once threshold reached (and data is stale) + { + WeatherCache c; + c.configure(600, 1800, 3); + c.tryFetch("", "city", 1000); + c.tryFetch("", "city", 1000); + c.tryFetch("", "city", 1000); + CHECK(c.consecutiveFailures() == 3); + CHECK(c.shouldAlert(1000)); + } + + // Exponential backoff: first failure → 30 s wait + { + WeatherCache c; + c.configure(600, 1800, 6); + c.tryFetch("", "city", 1000); + CHECK(c.nextFetchTime() == 1030); + } + + // Exponential backoff: second failure → 60 s wait + { + WeatherCache c; + c.configure(600, 1800, 6); + c.tryFetch("", "city", 1000); + c.tryFetch("", "city", 1000); + CHECK(c.nextFetchTime() == 1060); + } + + // Backoff capped at refreshInterval (600 s) + { + WeatherCache c; + c.configure(600, 1800, 6); + for (int i = 0; i < 25; ++i) + c.tryFetch("", "city", 1000); + CHECK(c.nextFetchTime() <= 1600); // 1000 + 600 + } + + // shouldFetchNow respects the backoff window + { + WeatherCache c; + c.configure(600, 1800, 6); + c.tryFetch("", "city", 1000); + // nextFetchTime = 1030 → should not fetch at 1029 + CHECK(!c.shouldFetchNow(1029)); + // At exactly 1030, should fetch + CHECK(c.shouldFetchNow(1030)); + } +} + +// --------------------------------------------------------------------------- +// display_rotation +// --------------------------------------------------------------------------- + +// Use a start time well above 0 to avoid the screenStartedAt_==0 first-call +// edge case (the tick() guard uses 0 as the "not yet started" sentinel). +static constexpr std::time_t T0 = 1000; + +static void test_display_rotation() { + SUITE("display_rotation"); + + DisplayRotation::Durations d; + d.currentSec = 10; + d.todaySec = 10; + d.tomorrowSec = 10; + d.alertSec = 10; + + // No valid report → "Connecting... / Please wait" regardless of screen + { + DisplayRotation r; + r.configure(d, "F"); + WeatherReport invalid; + r.setData(invalid, noAlerts(), false); + + auto lines = r.tick(T0); + CHECK(lines.line1 == "Connecting..."); + CHECK(lines.line2 == "Please wait"); + } + + // First tick → Current screen (temp + description) + { + DisplayRotation r; + r.configure(d, "F"); + // 20 C = 68 F + auto report = makeReport(20.0f, "Sunny", 25.0f, 10.0f, 22.0f, 8.0f); + r.setData(report, noAlerts(), false); + + auto lines = r.tick(T0); + CHECK(lines.line1 == "Temp: 68F"); + CHECK(lines.line2 == "Sunny"); + } + + // Celsius mode + { + DisplayRotation r; + r.configure(d, "C"); + auto report = makeReport(20.0f, "Sunny", 25.0f, 10.0f, 22.0f, 8.0f); + r.setData(report, noAlerts(), false); + + auto lines = r.tick(T0); + CHECK(lines.line1 == "Temp: 20C"); + } + + // Stale flag → asterisk appended on Current line1 + { + DisplayRotation r; + r.configure(d, "F"); + auto report = makeReport(20.0f, "Sunny", 25.0f, 10.0f, 22.0f, 8.0f); + r.setData(report, noAlerts(), /*isStale=*/true); + + auto lines = r.tick(T0); + CHECK(lines.line1 == "Temp: 68F*"); + } + + // After currentSec, advances to Today screen + { + DisplayRotation r; + r.configure(d, "F"); + // 25 C = 77 F, 10 C = 50 F + auto report = makeReport(20.0f, "Sunny", 25.0f, 10.0f, 22.0f, 8.0f); + r.setData(report, noAlerts(), false); + + r.tick(T0); // Current — sets screenStartedAt_ = T0 + auto lines = r.tick(T0 + 10); // elapsed = 10 ≥ 10 → Today + CHECK(lines.line1 == "Today H77 L50"); + CHECK(lines.line2 == "Sunny"); + } + + // Stale flag on Today + { + DisplayRotation r; + r.configure(d, "F"); + auto report = makeReport(20.0f, "Sunny", 25.0f, 10.0f, 22.0f, 8.0f); + r.setData(report, noAlerts(), /*isStale=*/true); + + r.tick(T0); + auto lines = r.tick(T0 + 10); // Today + CHECK(lines.line1 == "Today H77 L50*"); + } + + // Rain chance ≥ 30% appended to Today line2 + { + DisplayRotation r; + r.configure(d, "F"); + auto report = makeReport(20.0f, "Sunny", 25.0f, 10.0f, 22.0f, 8.0f); + report.forecast[0].chanceOfRain = 35; + report.forecast[0].description = "Cloudy"; + r.setData(report, noAlerts(), false); + + r.tick(T0); + auto lines = r.tick(T0 + 10); // Today + CHECK(lines.line2.find("35%") != std::string::npos); + } + + // Rain chance < 30% NOT appended + { + DisplayRotation r; + r.configure(d, "F"); + auto report = makeReport(20.0f, "Sunny", 25.0f, 10.0f, 22.0f, 8.0f); + report.forecast[0].chanceOfRain = 20; + report.forecast[0].description = "Partly cloudy"; + r.setData(report, noAlerts(), false); + + r.tick(T0); + auto lines = r.tick(T0 + 10); // Today + CHECK(lines.line2 == "Partly cloudy"); + } + + // After Today → Tomorrow screen + { + DisplayRotation r; + r.configure(d, "F"); + // 22 C = 71.6 → 72 F; 8 C = 46.4 → 46 F + auto report = makeReport(20.0f, "Sunny", 25.0f, 10.0f, 22.0f, 8.0f); + r.setData(report, noAlerts(), false); + + r.tick(T0); // Current + r.tick(T0 + 10); // Today + auto lines = r.tick(T0 + 20); // Tomorrow + CHECK(lines.line1.find("Tmrw") != std::string::npos); + CHECK(lines.line1.find("H72") != std::string::npos); + CHECK(lines.line1.find("L46") != std::string::npos); + } + + // No alerts: after Tomorrow cycles back to Current (not Alert) + { + DisplayRotation r; + r.configure(d, "F"); + auto report = makeReport(20.0f, "Sunny", 25.0f, 10.0f, 22.0f, 8.0f); + r.setData(report, noAlerts(), false); + + r.tick(T0); // Current + r.tick(T0 + 10); // Today + r.tick(T0 + 20); // Tomorrow + auto lines = r.tick(T0 + 30); // back to Current + CHECK(lines.line1.find("Temp:") != std::string::npos); + } + + // With alerts: after Tomorrow shows Alert screen + { + DisplayRotation r; + r.configure(d, "F"); + auto report = makeReport(20.0f, "Stormy", 25.0f, 10.0f, 22.0f, 8.0f); + + weather_alerts::AlertSet alerts; + weather_alerts::Alert al; + al.kind = weather_alerts::Kind::SevereWeather; + al.headline = "Tornado Warning"; + al.detail = "Take shelter immediately"; + alerts.active.push_back(al); + + r.setData(report, alerts, false); + + r.tick(T0); // Current + r.tick(T0 + 10); // Today + r.tick(T0 + 20); // Tomorrow + auto lines = r.tick(T0 + 30); // Alert + CHECK(lines.line1.find("Tornado") != std::string::npos); + CHECK(lines.line2.find("shelter") != std::string::npos); + } + + // Alert screen then wraps back to Current + { + DisplayRotation r; + r.configure(d, "F"); + auto report = makeReport(20.0f, "Stormy", 25.0f, 10.0f, 22.0f, 8.0f); + + weather_alerts::AlertSet alerts; + weather_alerts::Alert al; + al.kind = weather_alerts::Kind::SevereWeather; + al.headline = "Tornado Warning"; + al.detail = "Take shelter"; + alerts.active.push_back(al); + + r.setData(report, alerts, false); + + r.tick(T0); // Current + r.tick(T0 + 10); // Today + r.tick(T0 + 20); // Tomorrow + r.tick(T0 + 30); // Alert + auto lines = r.tick(T0 + 40); // back to Current + CHECK(lines.line1.find("Temp:") != std::string::npos); + } + + // Line length clamped to 16 characters + { + DisplayRotation r; + r.configure(d, "F"); + auto report = makeReport(20.0f, + "A very long weather condition description", + 25.0f, 10.0f, 22.0f, 8.0f); + r.setData(report, noAlerts(), false); + + auto lines = r.tick(T0); + CHECK(lines.line2.size() <= 16u); + } + + // No forecast data → "Today: no data" fallback + { + DisplayRotation r; + r.configure(d, "F"); + WeatherReport empty; + empty.valid = true; + empty.fetchedAt = T0; + empty.current.tempC = 20.0f; + empty.current.description = "Clear"; + // no forecast entries + + r.setData(empty, noAlerts(), false); + r.tick(T0); // Current + auto lines = r.tick(T0 + 10); // Today + CHECK(lines.line1 == "Today: no data"); + } +} + +// --------------------------------------------------------------------------- +// config +// --------------------------------------------------------------------------- + +static void test_config() { + SUITE("config"); + + // Default values when no file and no env vars + { + Config c; + c.load("/nonexistent/path/config.json"); + CHECK(c.units == "F"); + CHECK(c.updateInterval == 600); + CHECK(c.ledCount == 16); + CHECK(c.oledFormat == "II:MM"); + CHECK(approxEq(static_cast(c.latitude), 41.7958f, 0.001f)); + CHECK(c.ledSleepStart == -1); + CHECK(c.ledSleepEnd == -1); + CHECK(c.staleThresholdSec == 1800); + CHECK(c.alertAfterFailures == 6); + CHECK(c.heavyRainPercent == 80); + } + + // JSON overrides defaults + { + const char* tmp = "/tmp/test_config_unit_json.json"; + { + std::ofstream f(tmp); + f << R"({ + "UNITS": "C", + "UPDATE_INTERVAL": 300, + "location": { "latitude": 37.77, "longitude": -122.42 }, + "led": { + "count": 24, + "brightness": 0.2, + "sleep_start": 22, + "sleep_end": 7 + }, + "oled": { "format": "HH:MM:SS" }, + "weather": { + "heavy_rain_percent": 70, + "big_temp_delta_f": 10.0 + } + })"; + } + Config c; + c.load(tmp); + CHECK(c.units == "C"); + CHECK(c.updateInterval == 300); + CHECK(approxEq(static_cast(c.latitude), 37.77f, 0.01f)); + CHECK(approxEq(static_cast(c.longitude), -122.42f, 0.01f)); + CHECK(c.ledCount == 24); + CHECK(approxEq(static_cast(c.ledBrightness), 0.2f)); + CHECK(c.ledSleepStart == 22); + CHECK(c.ledSleepEnd == 7); + CHECK(c.oledFormat == "HH:MM:SS"); + CHECK(c.heavyRainPercent == 70); + CHECK(approxEq(c.bigTempDeltaF, 10.0f)); + std::remove(tmp); + } + + // Env vars override JSON (and no-file defaults) + { + setenv("UNITS", "C", 1); + setenv("UPDATE_INTERVAL", "120", 1); + setenv("OLED_FORMAT", "HH:MM", 1); + setenv("ENGINEERING_MODE","1", 1); + + Config c; + c.load("/nonexistent/path/config.json"); + CHECK(c.units == "C"); + CHECK(c.updateInterval == 120); + CHECK(c.oledFormat == "HH:MM"); + CHECK(c.engineeringMode == true); + + unsetenv("UNITS"); + unsetenv("UPDATE_INTERVAL"); + unsetenv("OLED_FORMAT"); + unsetenv("ENGINEERING_MODE"); + } + + // ENGINEERING_MODE: all truthy spellings accepted case-insensitively + { + for (const char* val : {"1", "true", "TRUE", "yes", "YES", "on", "ON"}) { + setenv("ENGINEERING_MODE", val, 1); + Config c; + c.load("/nonexistent/path/config.json"); + CHECK(c.engineeringMode == true); + unsetenv("ENGINEERING_MODE"); + } + } + + // ENGINEERING_MODE: falsy values + { + for (const char* val : {"0", "false", "no", "off", ""}) { + setenv("ENGINEERING_MODE", val, 1); + Config c; + c.load("/nonexistent/path/config.json"); + CHECK(c.engineeringMode == false); + unsetenv("ENGINEERING_MODE"); + } + } + + // Sanity guard: bad sleep hours clamped to -1 + { + const char* tmp = "/tmp/test_config_unit_sleep.json"; + { + std::ofstream f(tmp); + f << R"({"led": {"sleep_start": 25, "sleep_end": -5}})"; + } + Config c; + c.load(tmp); + CHECK(c.ledSleepStart == -1); + CHECK(c.ledSleepEnd == -1); + std::remove(tmp); + } + + // Sanity guard: valid sleep hours accepted + { + const char* tmp = "/tmp/test_config_unit_sleepok.json"; + { + std::ofstream f(tmp); + f << R"({"led": {"sleep_start": 22, "sleep_end": 0}})"; + } + Config c; + c.load(tmp); + CHECK(c.ledSleepStart == 22); + CHECK(c.ledSleepEnd == 0); + std::remove(tmp); + } + + // Sanity guard: I2C address out of 7-bit range → default restored + { + const char* tmp = "/tmp/test_config_unit_i2c.json"; + { + std::ofstream f(tmp); + // 0x01 and 0x00 are below the 7-bit I2C minimum (0x08) + f << R"({"lcd": {"i2c_address": 1}, "oled": {"i2c_address": 0}})"; + } + Config c; + c.load(tmp); + CHECK(c.lcdI2CAddr == 0x27); + CHECK(c.oledI2CAddr == 0x3C); + std::remove(tmp); + } + + // Sanity guard: zero updateInterval → default 600 + { + const char* tmp = "/tmp/test_config_unit_interval.json"; + { + std::ofstream f(tmp); + f << R"({"UPDATE_INTERVAL": 0})"; + } + Config c; + c.load(tmp); + CHECK(c.updateInterval == 600); + std::remove(tmp); + } + + // Sanity guard: brightness clamped to [0, 1] + { + const char* tmp = "/tmp/test_config_unit_bright.json"; + { + std::ofstream f(tmp); + f << R"({"led": {"brightness": 5.0}})"; + } + Config c; + c.load(tmp); + CHECK(approxEq(static_cast(c.ledBrightness), 1.0f)); + std::remove(tmp); + } +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +int main() { + test_temperature(); + test_weather_alerts(); + test_weather_cache(); + test_display_rotation(); + test_config(); + return finish(); +} From 34cc2f379cc38e15864f0bd1d7f0261dc4f348cf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 11:50:27 +0000 Subject: [PATCH 2/2] chore: add test_unit to .gitignore Matches the pattern already established for other test binaries. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a08155d..6f0fd9d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ test_oled test_celestial test_neopixel test_ring +test_unit weather_display_mac # Test output