diff --git a/src/Rule.hpp b/src/Rule.hpp index 5bde1b24..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,20 +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; - - bool inExtendedWindow; - if (crossesMidnight) { - // Extended window with midnight crossing - inExtendedWindow = (nowMinutes >= baseStartMinutes || nowMinutes <= normalizedEnd); - } else { - inExtendedWindow = (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 @@ -234,6 +240,7 @@ class Rule { } // Extended window expired — reset + _activeEndTime = 0; _activeEndMinutes = 0; } @@ -328,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/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..84a39ece 100644 --- a/test/native/tests/test_rules.cpp +++ b/test/native/tests/test_rules.cpp @@ -8,11 +8,13 @@ // Mock includes (these are picked up via -I mocks/) #include "Arduino.h" +#include "ConfigManager.hpp" #include "Rule.hpp" #include "RuleAuto.hpp" #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 +22,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, int dayOffset = 0) { + tm t = {}; + t.tm_year = 2026 - 1900; + t.tm_mon = 5; // June — avoids DST ambiguity + t.tm_mday = 15 + dayOffset; + 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 +217,326 @@ 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"); + + int suiteFailed = 0; + + 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; + 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 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; + suiteFailed |= rc; + 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, 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++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 02:00 — expected extension active until 04:00"); + failed++; + } + + 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++; + } 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", suiteFailed == 0 ? 4 : 0, suiteFailed != 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"); + + int suiteFailed = 0; + + 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; + 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(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++; + } 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; + 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++; + } + + test_suite_end("PumpTimer::same_day", suiteFailed == 0 ? 3 : 0, suiteFailed != 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"); + + int suiteFailed = 0; + + 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; + suiteFailed |= rc; + 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; + suiteFailed |= rc; + 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", suiteFailed == 0 ? 2 : 0, suiteFailed != 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"); + + int suiteFailed = 0; + + 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; + 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 1980)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + 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++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 08:00 — expected extension active until 09:00"); + failed++; + } + + 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++; + } else { + test_fail(__FILE__, __LINE__, "Pump ON at 10:00 — expected extension expired"); + failed++; + } + + 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 ── + // 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; }