From 3265db1217446df5aeed754efbfe4ff3413f9f65 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Fri, 14 Aug 2026 22:17:32 +0200 Subject: [PATCH 1/3] fix(timer): keep pump running when extension wraps past midnight The temperature-based runtime extension was lost whenever the extended end time wrapped past midnight while the base timer stayed within a single day (e.g. base 16:00-20:00 extended to 04:00 next day). Step 2 keyed the midnight-crossing check on the base timer only. For a same-day base timer whose extension wrapped past midnight, the normalized end (% 1440) is earlier than the base window, so "nowMinutes < normalizedEnd" was false during the base window: the extension was reset on every loop and the pump turned off at the base end. Key the wrap detection on the base timer OR the extended end (>= 1440) and bound the same-day case with the base start time. Regression tests cover wrap-past-midnight, same-day, and midnight-crossing base timers. --- src/Rule.hpp | 12 +- test/native/mocks/TestTime.hpp | 12 ++ test/native/mocks/stubs.cpp | 12 +- test/native/tests/test_rules.cpp | 250 +++++++++++++++++++++++++++++++ 4 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 test/native/mocks/TestTime.hpp diff --git a/src/Rule.hpp b/src/Rule.hpp index 5bde1b24..e12724ef 100644 --- a/src/Rule.hpp +++ b/src/Rule.hpp @@ -217,12 +217,20 @@ class Rule { // Normalize extended end to 0-1439 for comparison with nowMinutes uint16_t normalizedEnd = _activeEndMinutes % 1440; + // The extended window wraps past midnight when the base timer crosses + // midnight OR when the temperature extension itself pushes the end past + // midnight (e.g. base 16:00-20:00 extended to 04:00 next day). Keying + // this check on the base timer alone was wrong: it reset the extension + // during the base window and the pump turned off at the base end. + bool windowWraps = crossesMidnight || (_activeEndMinutes >= 1440); + bool inExtendedWindow; - if (crossesMidnight) { + if (windowWraps) { // Extended window with midnight crossing inExtendedWindow = (nowMinutes >= baseStartMinutes || nowMinutes <= normalizedEnd); } else { - inExtendedWindow = (nowMinutes < normalizedEnd); + // Same-day extended window + inExtendedWindow = (nowMinutes >= baseStartMinutes && nowMinutes < normalizedEnd); } if (inExtendedWindow) { diff --git a/test/native/mocks/TestTime.hpp b/test/native/mocks/TestTime.hpp new file mode 100644 index 00000000..202e0158 --- /dev/null +++ b/test/native/mocks/TestTime.hpp @@ -0,0 +1,12 @@ +#pragma once +#include + +/** + * @brief Test-only hook to control the mock clock used by getCurrentDateTime(). + * + * The native test harness stubs getTimeFor() (see stubs.cpp) to return a + * controllable epoch. Tests that need a specific wall-clock time build the + * epoch via mktime() from a tm struct, so the localtime_r() round-trip inside + * getCurrentDateTime() is independent of the host timezone. + */ +void setMockTime(time_t t); \ No newline at end of file diff --git a/test/native/mocks/stubs.cpp b/test/native/mocks/stubs.cpp index ba3be016..9b3d3319 100644 --- a/test/native/mocks/stubs.cpp +++ b/test/native/mocks/stubs.cpp @@ -92,10 +92,20 @@ uint8_t getTimeDegradationRedHours() { return 24; } +// Controllable mock clock for timer-window tests. Defaults to epoch 0 +// (1970-01-01 00:00 UTC) to preserve existing test behavior; tests that need +// a specific wall-clock time call setMockTime() with an epoch built via +// mktime() so the localtime_r() round-trip is host-timezone independent. +static time_t g_mockTime = 0; + +void setMockTime(time_t t) { + g_mockTime = t; +} + time_t getTimeFor(int tzIndex, TimeChangeRule **tcr) { if (tcr) *tcr = nullptr; - return 0; + return g_mockTime; } String getTimeInfoFor(int) { return String("UTC"); diff --git a/test/native/tests/test_rules.cpp b/test/native/tests/test_rules.cpp index e2da06fc..6981dbc1 100644 --- a/test/native/tests/test_rules.cpp +++ b/test/native/tests/test_rules.cpp @@ -13,6 +13,7 @@ #include "RuleManu.hpp" #include "RuleBoost.hpp" #include "RuleTimer.hpp" +#include "TestTime.hpp" // Test framework extern void test_begin(const char *suite, const char *name); @@ -20,6 +21,37 @@ extern void test_pass(const char *file, int line); extern void test_fail(const char *file, int line, const char *msg); extern void test_suite_end(const char *name, int passed, int failed); +/** + * @brief Test harness exposing the protected pump-timer logic. + */ +class PumpTimerHarness : public Rule { +public: + using Rule::checkPoolPumpTimer; // expose both overloads + using Rule::setTimerSetting; + using Rule::getActiveEndMinutes; + const char *getMode() override { return "test"; } + void loop() override {} +}; + +/** + * @brief Set the mock wall-clock time used by getCurrentDateTime(). + * + * The epoch is built with mktime() from a tm struct, so the localtime_r() + * round-trip inside getCurrentDateTime() yields the same wall-clock time + * regardless of the host timezone. + */ +static void setWallClock(int hour, int min) { + tm t = {}; + t.tm_year = 2026 - 1900; + t.tm_mon = 5; // June — avoids DST ambiguity + t.tm_mday = 15; + t.tm_hour = hour; + t.tm_min = min; + t.tm_sec = 0; + t.tm_isdst = -1; + setMockTime(mktime(&t)); +} + #define ASSERT_TRUE(cond) \ do { \ if (!(cond)) { \ @@ -184,5 +216,223 @@ int run_rule_tests() { test_suite_end("RuleTimer::extended", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); } + // ── Test: temp extension wrapping past midnight with same-day base timer ── + // Base 16:00-20:00 (does NOT cross midnight), pool 40.0°C: + // threshold 24.0, factor 30 → extra = 16*30 = 480 + // baseRuntime = 240, totalRuntime = min(240+480, 720) = 720 + // extended = 960 + 720 = 1680 → 04:00 next day (wraps past midnight) + // Regression: Step 2 previously keyed the midnight check on the BASE timer + // (crossesMidnight=false), so during the base window the extension was reset + // and the pump turned OFF at 20:00 instead of running until 04:00. + { + test_begin("PumpTimer", "wrap-past-midnight extension keeps pump ON"); + + PumpTimerHarness timer; + TimerSetting ts; + ts.timerStartHour = 16; + ts.timerStartMinutes = 0; + ts.timerEndHour = 20; + ts.timerEndMinutes = 0; + timer.setTimerSetting(ts); + + setWallClock(18, 0); // inside base window → extension applied + bool on = timer.checkPoolPumpTimer(40.0f); + bool extSet = (timer.getActiveEndMinutes() == 1680); + rc = (on && extSet) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 1 and 1680)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + setWallClock(20, 30); // past base end, still inside extended window + on = timer.checkPoolPumpTimer(40.0f); + rc = (on) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 20:30 — extension was reset (bug)"); + failed++; + } + + setWallClock(2, 0); // 02:00 next day, still inside extended window + on = timer.checkPoolPumpTimer(40.0f); + rc = (on) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 02:00 — expected extension active until 04:00"); + failed++; + } + + setWallClock(4, 30); // past extended end → extension expired + on = timer.checkPoolPumpTimer(40.0f); + rc = (!on && timer.getActiveEndMinutes() == 0) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 0 and 0)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + test_suite_end("PumpTimer::wrap_past_midnight", rc == 0 ? 4 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: same-day extension still works (no regression) ── + // Base 08:00-18:00, pool 28.0°C → extra = 4*30 = 120, total = 720, + // extended = 480 + 720 = 1200 (20:00, no wrap). + { + test_begin("PumpTimer", "same-day extension keeps pump ON"); + + PumpTimerHarness timer; + TimerSetting ts; + ts.timerStartHour = 8; + ts.timerStartMinutes = 0; + ts.timerEndHour = 18; + ts.timerEndMinutes = 0; + timer.setTimerSetting(ts); + + setWallClock(10, 0); // inside base window → extension applied + bool on = timer.checkPoolPumpTimer(28.0f); + bool extSet = (timer.getActiveEndMinutes() == 1200); + rc = (on && extSet) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 1 and 1200)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + setWallClock(19, 0); // past base end, inside extended window (until 20:00) + on = timer.checkPoolPumpTimer(28.0f); + rc = (on) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 19:00 — expected extension active until 20:00"); + failed++; + } + + setWallClock(20, 30); // past extended end → extension expired + on = timer.checkPoolPumpTimer(28.0f); + rc = (!on && timer.getActiveEndMinutes() == 0) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 0 and 0)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + test_suite_end("PumpTimer::same_day", rc == 0 ? 3 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: base timer crossing midnight without extension (no regression) ── + // Base 22:00-06:00, pool 20.0°C (below threshold) → no extension. + // Window must still be active at e.g. 02:00 and inactive at 12:00. + { + test_begin("PumpTimer", "midnight-crossing base timer without extension"); + + PumpTimerHarness timer; + TimerSetting ts; + ts.timerStartHour = 22; + ts.timerStartMinutes = 0; + ts.timerEndHour = 6; + ts.timerEndMinutes = 0; + timer.setTimerSetting(ts); + + setWallClock(2, 0); // inside base window (after midnight) + bool on = timer.checkPoolPumpTimer(20.0f); + rc = (on) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 02:00 — expected base window active"); + failed++; + } + + setWallClock(12, 0); // outside base window + on = timer.checkPoolPumpTimer(20.0f); + rc = (!on) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump ON at 12:00 — expected outside base window"); + failed++; + } + + test_suite_end("PumpTimer::base_crosses_midnight", rc == 0 ? 2 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: base timer crossing midnight WITH extension (no regression) ── + // Base 22:00-06:00, pool 30.0°C → extra = 6*30 = 180, total = 480+180 = 660, + // extended = 1320 + 660 = 1980 → 09:00 next day (wraps). + { + test_begin("PumpTimer", "midnight-crossing base timer with extension"); + + PumpTimerHarness timer; + TimerSetting ts; + ts.timerStartHour = 22; + ts.timerStartMinutes = 0; + ts.timerEndHour = 6; + ts.timerEndMinutes = 0; + timer.setTimerSetting(ts); + + setWallClock(23, 0); // inside base window → extension applied + bool on = timer.checkPoolPumpTimer(30.0f); + bool extSet = (timer.getActiveEndMinutes() == 1980); + rc = (on && extSet) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 1 and 1980)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + setWallClock(8, 0); // after base end, inside extended window (until 09:00) + on = timer.checkPoolPumpTimer(30.0f); + rc = (on) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 08:00 — expected extension active until 09:00"); + failed++; + } + + setWallClock(10, 0); // past extended end → extension expired + on = timer.checkPoolPumpTimer(30.0f); + rc = (!on) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump ON at 10:00 — expected extension expired"); + failed++; + } + + test_suite_end("PumpTimer::crossing_with_extension", rc == 0 ? 3 : 0, rc != 0 ? 1 : 0); + } + return passed + failed; } From 5e8d29d850241de1e59b57c2a94c12f21d6187f1 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 15 Aug 2026 22:01:00 +0200 Subject: [PATCH 2/3] fix(rule): expire full-day temp extension at next cycle Anchor the temperature extension to an absolute expiry time computed from the current cycle's base start, instead of comparing minutes since midnight. With the maximum runtime (1440 min) the extended end equals the next base start, so the normalized wrap predicate "now >= start || now <= end" was true at every minute of the day: the extension never expired and the pump stayed on even after the water cooled. Minutes alone cannot distinguish a wrap-past-midnight end from the next cycle's start. Step 1 now stores the absolute expiry (cycle start + runtime) in _activeEndTime, anchored to the previous day for the post-midnight part of crossing timers; Step 2 simply checks now < _activeEndTime. Extends the mock wall clock with a day offset so tests express real next-day times. --- src/Rule.hpp | 52 ++++++++--------- test/native/tests/test_rules.cpp | 96 ++++++++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 32 deletions(-) diff --git a/src/Rule.hpp b/src/Rule.hpp index e12724ef..292c471e 100644 --- a/src/Rule.hpp +++ b/src/Rule.hpp @@ -54,7 +54,10 @@ class Rule { /** * @brief Reset temperature-based runtime extension (call on mode change). */ - void resetTemperatureExtension() { _activeEndMinutes = 0; } + void resetTemperatureExtension() { + _activeEndMinutes = 0; + _activeEndTime = 0; + } /** * @brief Get current temperature-extended end time in minutes since midnight. @@ -195,14 +198,27 @@ class Rule { timerActive = (difftime(now, start) >= 0) && (difftime(now, end) <= 0); } - uint16_t nowMinutes = time.tm_hour * 60 + time.tm_min; - // Step 1: If timer is active, apply temperature extension if (timerActive && poolTemp > 0.0f && poolTemp == poolTemp) { uint16_t extendedEnd = calculateEffectiveEndMinutes(baseStartMinutes, baseEndMinutes, poolTemp); - // Only extend, never shorten - if (extendedEnd > _activeEndMinutes) { + // Absolute expiry anchored to the current cycle's base start. Minutes + // since midnight alone cannot tell a wrap-past-midnight end apart from + // the next cycle's start — e.g. a full-day extension (max runtime 1440) + // ends exactly at the next base start and would otherwise never expire, + // keeping the pump on even after the water cools. + time_t cycleStart = start; + if (crossesMidnight && now < start) { + // Post-midnight part of a midnight-crossing window: the cycle started + // on the previous day. + cycleStart = start - 86400; + } + int totalRuntime = static_cast(extendedEnd) - static_cast(baseStartMinutes); + time_t expiry = cycleStart + static_cast(totalRuntime) * 60; + + // Only extend, never shorten (within the current cycle) + if (expiry > _activeEndTime) { + _activeEndTime = expiry; _activeEndMinutes = extendedEnd; uint8_t eh = (_activeEndMinutes / 60) % 24; uint8_t em = _activeEndMinutes % 60; @@ -213,28 +229,10 @@ class Rule { } // Step 2: Check if we're within the extended window - if (_activeEndMinutes > 0) { - // Normalize extended end to 0-1439 for comparison with nowMinutes - uint16_t normalizedEnd = _activeEndMinutes % 1440; - - // The extended window wraps past midnight when the base timer crosses - // midnight OR when the temperature extension itself pushes the end past - // midnight (e.g. base 16:00-20:00 extended to 04:00 next day). Keying - // this check on the base timer alone was wrong: it reset the extension - // during the base window and the pump turned off at the base end. - bool windowWraps = crossesMidnight || (_activeEndMinutes >= 1440); - - bool inExtendedWindow; - if (windowWraps) { - // Extended window with midnight crossing - inExtendedWindow = (nowMinutes >= baseStartMinutes || nowMinutes <= normalizedEnd); - } else { - // Same-day extended window - inExtendedWindow = (nowMinutes >= baseStartMinutes && nowMinutes < normalizedEnd); - } - - if (inExtendedWindow) { + if (_activeEndTime > 0) { + if (now < _activeEndTime) { #ifdef DEBUG_RULE_TIMER + uint16_t normalizedEnd = _activeEndMinutes % 1440; Serial.printf( " checkPoolPumpTimer = true (extended to %02d:%02d)\n", (uint8_t)(normalizedEnd / 60), (uint8_t)(normalizedEnd % 60)); #endif @@ -242,6 +240,7 @@ class Rule { } // Extended window expired — reset + _activeEndTime = 0; _activeEndMinutes = 0; } @@ -336,4 +335,5 @@ class Rule { TimerSetting _timerSetting; uint16_t _activeEndMinutes = 0; ///< Temperature-extended end in minutes since midnight + time_t _activeEndTime = 0; ///< Absolute expiry of the temperature extension (0 = none) }; diff --git a/test/native/tests/test_rules.cpp b/test/native/tests/test_rules.cpp index 6981dbc1..3d9def6f 100644 --- a/test/native/tests/test_rules.cpp +++ b/test/native/tests/test_rules.cpp @@ -8,6 +8,7 @@ // Mock includes (these are picked up via -I mocks/) #include "Arduino.h" +#include "ConfigManager.hpp" #include "Rule.hpp" #include "RuleAuto.hpp" #include "RuleManu.hpp" @@ -40,11 +41,11 @@ class PumpTimerHarness : public Rule { * round-trip inside getCurrentDateTime() yields the same wall-clock time * regardless of the host timezone. */ -static void setWallClock(int hour, int min) { +static void setWallClock(int hour, int min, int dayOffset = 0) { tm t = {}; t.tm_year = 2026 - 1900; t.tm_mon = 5; // June — avoids DST ambiguity - t.tm_mday = 15; + t.tm_mday = 15 + dayOffset; t.tm_hour = hour; t.tm_min = min; t.tm_sec = 0; @@ -260,7 +261,7 @@ int run_rule_tests() { failed++; } - setWallClock(2, 0); // 02:00 next day, still inside extended window + setWallClock(2, 0, 1); // 02:00 next day, still inside extended window on = timer.checkPoolPumpTimer(40.0f); rc = (on) ? 0 : 1; if (rc == 0) { @@ -271,7 +272,7 @@ int run_rule_tests() { failed++; } - setWallClock(4, 30); // past extended end → extension expired + setWallClock(4, 30, 1); // 04:30 next day → extension expired on = timer.checkPoolPumpTimer(40.0f); rc = (!on && timer.getActiveEndMinutes() == 0) ? 0 : 1; if (rc == 0) { @@ -409,7 +410,7 @@ int run_rule_tests() { failed++; } - setWallClock(8, 0); // after base end, inside extended window (until 09:00) + setWallClock(8, 0, 1); // next day 08:00, inside extended window (until 09:00) on = timer.checkPoolPumpTimer(30.0f); rc = (on) ? 0 : 1; if (rc == 0) { @@ -420,7 +421,7 @@ int run_rule_tests() { failed++; } - setWallClock(10, 0); // past extended end → extension expired + setWallClock(10, 0, 1); // next day 10:00 → extension expired on = timer.checkPoolPumpTimer(30.0f); rc = (!on) ? 0 : 1; if (rc == 0) { @@ -434,5 +435,88 @@ int run_rule_tests() { test_suite_end("PumpTimer::crossing_with_extension", rc == 0 ? 3 : 0, rc != 0 ? 1 : 0); } + // ── Test: full-day extension (max runtime 1440) expires at the next cycle ── + // Base 16:00-20:00, pool 64.0°C, tempCircMaxRuntime = 1440: + // extra = 40*30 = 1200, baseRuntime = 240, total = min(1440, 1440) = 1440 + // extended = 960 + 1440 = 2400 → 16:00 next day (full 24 h cycle) + // Regression: normalizedEnd (2400 % 1440 = 960) equals baseStartMinutes, so + // the old wrap predicate "now >= start || now <= start" was true at every + // minute — the extension never expired, even after the water cooled. + { + test_begin("PumpTimer", "full-day extension expires at next cycle"); + + PoolController::ConfigManager::getSettings().tempCircMaxRuntime = 1440; + + PumpTimerHarness timer; + TimerSetting ts; + ts.timerStartHour = 16; + ts.timerStartMinutes = 0; + ts.timerEndHour = 20; + ts.timerEndMinutes = 0; + timer.setTimerSetting(ts); + + int suiteFailed = 0; + + setWallClock(18, 0); // inside base window → full-day extension applied + bool on = timer.checkPoolPumpTimer(64.0f); + bool extSet = (timer.getActiveEndMinutes() == 2400); + rc = (on && extSet) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 1 and 2400)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + setWallClock(10, 0, 1); // next day 10:00 — still within the 24 h extension + on = timer.checkPoolPumpTimer(64.0f); + rc = (on) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 10:00 — expected full-day extension active"); + failed++; + } + + setWallClock(16, 0, 1); // next cycle start, water cooled below threshold + on = timer.checkPoolPumpTimer(20.0f); + bool reset = (timer.getActiveEndMinutes() == 1200); // reset to base end + rc = (on && reset) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 1 and 1200)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + setWallClock(22, 0, 1); // next day 22:00 → extension must be gone + on = timer.checkPoolPumpTimer(20.0f); + rc = (!on && timer.getActiveEndMinutes() == 0) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 0 and 0)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + PoolController::ConfigManager::getSettings().tempCircMaxRuntime = 720; + + test_suite_end("PumpTimer::full_day_expiry", suiteFailed == 0 ? 4 : 0, suiteFailed != 0 ? 1 : 0); + } + return passed + failed; } From 69680d7f9b5aa77a6f4ca3939feec0e76dfd8752 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 16 Aug 2026 13:55:32 +0200 Subject: [PATCH 3/3] test(rule): aggregate per-suite failures in pump-timer tests Each check overwrote the shared rc variable, so a failing assertion was masked when a later check in the same suite passed. test_main.cpp exits on the suite counter (g_testsFailed), not the assertion counter, so CI could pass despite failed assertions. Track suiteFailed (sticky OR of all per-check results) in the five PumpTimer suites and report the suite as failed when any check failed. Verified: a mid-suite failure now yields exit code 1. --- test/native/tests/test_rules.cpp | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/test/native/tests/test_rules.cpp b/test/native/tests/test_rules.cpp index 3d9def6f..84a39ece 100644 --- a/test/native/tests/test_rules.cpp +++ b/test/native/tests/test_rules.cpp @@ -228,6 +228,8 @@ int run_rule_tests() { { test_begin("PumpTimer", "wrap-past-midnight extension keeps pump ON"); + int suiteFailed = 0; + PumpTimerHarness timer; TimerSetting ts; ts.timerStartHour = 16; @@ -240,6 +242,7 @@ int run_rule_tests() { bool on = timer.checkPoolPumpTimer(40.0f); bool extSet = (timer.getActiveEndMinutes() == 1680); rc = (on && extSet) ? 0 : 1; + suiteFailed |= rc; if (rc == 0) { test_pass(__FILE__, __LINE__); passed++; @@ -253,6 +256,7 @@ int run_rule_tests() { setWallClock(20, 30); // past base end, still inside extended window on = timer.checkPoolPumpTimer(40.0f); rc = (on) ? 0 : 1; + suiteFailed |= rc; if (rc == 0) { test_pass(__FILE__, __LINE__); passed++; @@ -264,6 +268,7 @@ int run_rule_tests() { setWallClock(2, 0, 1); // 02:00 next day, still inside extended window on = timer.checkPoolPumpTimer(40.0f); rc = (on) ? 0 : 1; + suiteFailed |= rc; if (rc == 0) { test_pass(__FILE__, __LINE__); passed++; @@ -275,6 +280,7 @@ int run_rule_tests() { setWallClock(4, 30, 1); // 04:30 next day → extension expired on = timer.checkPoolPumpTimer(40.0f); rc = (!on && timer.getActiveEndMinutes() == 0) ? 0 : 1; + suiteFailed |= rc; if (rc == 0) { test_pass(__FILE__, __LINE__); passed++; @@ -285,7 +291,7 @@ int run_rule_tests() { failed++; } - test_suite_end("PumpTimer::wrap_past_midnight", rc == 0 ? 4 : 0, rc != 0 ? 1 : 0); + test_suite_end("PumpTimer::wrap_past_midnight", suiteFailed == 0 ? 4 : 0, suiteFailed != 0 ? 1 : 0); } // ── Test: same-day extension still works (no regression) ── @@ -294,6 +300,8 @@ int run_rule_tests() { { test_begin("PumpTimer", "same-day extension keeps pump ON"); + int suiteFailed = 0; + PumpTimerHarness timer; TimerSetting ts; ts.timerStartHour = 8; @@ -306,6 +314,7 @@ int run_rule_tests() { bool on = timer.checkPoolPumpTimer(28.0f); bool extSet = (timer.getActiveEndMinutes() == 1200); rc = (on && extSet) ? 0 : 1; + suiteFailed |= rc; if (rc == 0) { test_pass(__FILE__, __LINE__); passed++; @@ -319,6 +328,7 @@ int run_rule_tests() { setWallClock(19, 0); // past base end, inside extended window (until 20:00) on = timer.checkPoolPumpTimer(28.0f); rc = (on) ? 0 : 1; + suiteFailed |= rc; if (rc == 0) { test_pass(__FILE__, __LINE__); passed++; @@ -330,6 +340,7 @@ int run_rule_tests() { setWallClock(20, 30); // past extended end → extension expired on = timer.checkPoolPumpTimer(28.0f); rc = (!on && timer.getActiveEndMinutes() == 0) ? 0 : 1; + suiteFailed |= rc; if (rc == 0) { test_pass(__FILE__, __LINE__); passed++; @@ -340,7 +351,7 @@ int run_rule_tests() { failed++; } - test_suite_end("PumpTimer::same_day", rc == 0 ? 3 : 0, rc != 0 ? 1 : 0); + test_suite_end("PumpTimer::same_day", suiteFailed == 0 ? 3 : 0, suiteFailed != 0 ? 1 : 0); } // ── Test: base timer crossing midnight without extension (no regression) ── @@ -349,6 +360,8 @@ int run_rule_tests() { { test_begin("PumpTimer", "midnight-crossing base timer without extension"); + int suiteFailed = 0; + PumpTimerHarness timer; TimerSetting ts; ts.timerStartHour = 22; @@ -360,6 +373,7 @@ int run_rule_tests() { setWallClock(2, 0); // inside base window (after midnight) bool on = timer.checkPoolPumpTimer(20.0f); rc = (on) ? 0 : 1; + suiteFailed |= rc; if (rc == 0) { test_pass(__FILE__, __LINE__); passed++; @@ -371,6 +385,7 @@ int run_rule_tests() { setWallClock(12, 0); // outside base window on = timer.checkPoolPumpTimer(20.0f); rc = (!on) ? 0 : 1; + suiteFailed |= rc; if (rc == 0) { test_pass(__FILE__, __LINE__); passed++; @@ -379,7 +394,7 @@ int run_rule_tests() { failed++; } - test_suite_end("PumpTimer::base_crosses_midnight", rc == 0 ? 2 : 0, rc != 0 ? 1 : 0); + test_suite_end("PumpTimer::base_crosses_midnight", suiteFailed == 0 ? 2 : 0, suiteFailed != 0 ? 1 : 0); } // ── Test: base timer crossing midnight WITH extension (no regression) ── @@ -388,6 +403,8 @@ int run_rule_tests() { { test_begin("PumpTimer", "midnight-crossing base timer with extension"); + int suiteFailed = 0; + PumpTimerHarness timer; TimerSetting ts; ts.timerStartHour = 22; @@ -400,6 +417,7 @@ int run_rule_tests() { bool on = timer.checkPoolPumpTimer(30.0f); bool extSet = (timer.getActiveEndMinutes() == 1980); rc = (on && extSet) ? 0 : 1; + suiteFailed |= rc; if (rc == 0) { test_pass(__FILE__, __LINE__); passed++; @@ -413,6 +431,7 @@ int run_rule_tests() { setWallClock(8, 0, 1); // next day 08:00, inside extended window (until 09:00) on = timer.checkPoolPumpTimer(30.0f); rc = (on) ? 0 : 1; + suiteFailed |= rc; if (rc == 0) { test_pass(__FILE__, __LINE__); passed++; @@ -424,6 +443,7 @@ int run_rule_tests() { setWallClock(10, 0, 1); // next day 10:00 → extension expired on = timer.checkPoolPumpTimer(30.0f); rc = (!on) ? 0 : 1; + suiteFailed |= rc; if (rc == 0) { test_pass(__FILE__, __LINE__); passed++; @@ -432,7 +452,7 @@ int run_rule_tests() { failed++; } - test_suite_end("PumpTimer::crossing_with_extension", rc == 0 ? 3 : 0, rc != 0 ? 1 : 0); + test_suite_end("PumpTimer::crossing_with_extension", suiteFailed == 0 ? 3 : 0, suiteFailed != 0 ? 1 : 0); } // ── Test: full-day extension (max runtime 1440) expires at the next cycle ──