/** * @file test_meter_fault_count.cpp * @brief Unit test: verifies that the meter fault counter increments once per * stale-data event, NOT once per catch-up tick. * * Regression test for the ~200 errors/hour bug where LoRa TX blocking caused * the sampling catch-up loop to fire note_fault() for every missed 1s tick. * * Run on target with: pio test -e lilygo-t3-v1-6-1-test -f test_meter_fault_count */ #include #include #include "data_model.h" // ---------- Minimal stubs replicating the fixed fault-counting logic ---------- static FaultCounters test_faults = {}; static FaultType test_last_error = FaultType::None; static uint32_t test_last_error_utc = 0; static uint32_t test_last_error_ms = 0; static void note_fault_stub(FaultCounters &counters, FaultType &last_type, uint32_t &last_ts_utc, uint32_t &last_ts_ms, FaultType type) { if (type == FaultType::MeterRead) { counters.meter_read_fail++; } else if (type == FaultType::Decode) { counters.decode_fail++; } else if (type == FaultType::LoraTx) { counters.lora_tx_fail++; } last_type = type; last_ts_utc = millis() / 1000; last_ts_ms = millis(); } static void reset_test_faults() { test_faults = {}; test_last_error = FaultType::None; test_last_error_utc = 0; test_last_error_ms = 0; } // ---------- Simulate the FIXED sampling loop logic ---------- static constexpr uint32_t SAMPLE_INTERVAL_MS = 1000; /** * Simulates the fixed sender_loop sampling section. * * @param last_sample_ms Tracks the last sample tick (in/out). * @param now_ms Current millis(). * @param meter_ok Whether the meter snapshot is fresh. * @param time_jump_pending Whether a time-jump event is pending (in/out). * @param faults Fault counters (in/out). * @return Number of samples generated in the catch-up loop. */ static uint32_t simulate_fixed_sampling( uint32_t &last_sample_ms, uint32_t now_ms, bool meter_ok, bool &time_jump_pending, FaultCounters &faults) { FaultType last_error = FaultType::None; uint32_t last_error_utc = 0; uint32_t last_error_ms = 0; bool meter_fault_noted = false; // Time-jump: one fault per event, outside loop. if (time_jump_pending) { time_jump_pending = false; note_fault_stub(faults, last_error, last_error_utc, last_error_ms, FaultType::MeterRead); meter_fault_noted = true; } // Stale meter: one fault per contiguous stale period, outside loop. if (!meter_ok && !meter_fault_noted) { note_fault_stub(faults, last_error, last_error_utc, last_error_ms, FaultType::MeterRead); } uint32_t samples = 0; while (now_ms - last_sample_ms >= SAMPLE_INTERVAL_MS) { last_sample_ms += SAMPLE_INTERVAL_MS; samples++; } return samples; } /** * Simulates the OLD (buggy) sampling loop for comparison. */ static uint32_t simulate_buggy_sampling( uint32_t &last_sample_ms, uint32_t now_ms, bool meter_ok, bool &time_jump_pending, FaultCounters &faults) { FaultType last_error = FaultType::None; uint32_t last_error_utc = 0; uint32_t last_error_ms = 0; uint32_t samples = 0; while (now_ms - last_sample_ms >= SAMPLE_INTERVAL_MS) { last_sample_ms += SAMPLE_INTERVAL_MS; samples++; if (!meter_ok) { note_fault_stub(faults, last_error, last_error_utc, last_error_ms, FaultType::MeterRead); } if (time_jump_pending) { time_jump_pending = false; note_fault_stub(faults, last_error, last_error_utc, last_error_ms, FaultType::MeterRead); } } return samples; } // ---------- Tests ---------- /** * Normal operation: meter is fresh, no blocking. 1 tick per call. * Should produce 0 faults. */ static void test_no_fault_when_meter_fresh() { FaultCounters faults = {}; uint32_t last_sample_ms = 0; bool time_jump = false; // Simulate 60 consecutive 1s ticks with fresh meter data. for (int i = 1; i <= 60; i++) { simulate_fixed_sampling(last_sample_ms, i * 1000, true, time_jump, faults); } TEST_ASSERT_EQUAL_UINT32(0, faults.meter_read_fail); } /** * LoRa TX blocks for 10 seconds while meter is stale. * OLD code: 10 faults. FIXED code: 1 fault. */ static void test_single_fault_after_blocking_stale() { FaultCounters faults = {}; uint32_t last_sample_ms = 0; bool time_jump = false; // 5 normal ticks with fresh data. for (int i = 1; i <= 5; i++) { simulate_fixed_sampling(last_sample_ms, i * 1000, true, time_jump, faults); } TEST_ASSERT_EQUAL_UINT32(0, faults.meter_read_fail); // LoRa TX blocks for 10s → meter goes stale. // now_ms = 15000, last_sample_ms = 5000 → 10 catch-up ticks. uint32_t samples = simulate_fixed_sampling(last_sample_ms, 15000, false, time_jump, faults); TEST_ASSERT_EQUAL_UINT32(10, samples); // 10 ticks caught up. TEST_ASSERT_EQUAL_UINT32(1, faults.meter_read_fail); // But only 1 fault! } /** * Demonstrate the OLD buggy behavior: same scenario produces 10 faults. */ static void test_buggy_produces_many_faults() { FaultCounters faults = {}; uint32_t last_sample_ms = 0; bool time_jump = false; for (int i = 1; i <= 5; i++) { simulate_buggy_sampling(last_sample_ms, i * 1000, true, time_jump, faults); } TEST_ASSERT_EQUAL_UINT32(0, faults.meter_read_fail); simulate_buggy_sampling(last_sample_ms, 15000, false, time_jump, faults); TEST_ASSERT_EQUAL_UINT32(10, faults.meter_read_fail); // Buggy: 10 faults for one event. } /** * Time-jump event should produce exactly 1 additional fault, * regardless of how many ticks are caught up. */ static void test_time_jump_single_fault() { FaultCounters faults = {}; uint32_t last_sample_ms = 0; bool time_jump = true; // Pending time-jump. // 8 catch-up ticks with stale meter AND time jump pending. uint32_t samples = simulate_fixed_sampling(last_sample_ms, 8000, false, time_jump, faults); TEST_ASSERT_EQUAL_UINT32(8, samples); // Time jump counted as 1, stale suppressed because meter_fault_noted == true. TEST_ASSERT_EQUAL_UINT32(1, faults.meter_read_fail); TEST_ASSERT_FALSE(time_jump); } /** * Repeated stale periods should count 1 fault per call to the sampling function, * not 1 per tick. After 3600s at 1 call/s with meter stale every call, * the FIXED code should produce ≤ 3600 faults (1 per call). * The OLD code would produce the same number (since 1 tick per call). * The difference is when blocking causes N>1 ticks per call. */ static void test_sustained_stale_1hz_no_blocking() { FaultCounters faults = {}; uint32_t last_sample_ms = 0; bool time_jump = false; // Simulate 1 hour at 1 Hz with meter always stale (no blocking, 1 tick/call). for (uint32_t i = 1; i <= 3600; i++) { simulate_fixed_sampling(last_sample_ms, i * 1000, false, time_jump, faults); } // 1 fault per call = 3600 faults. This correctly reflects 3600 distinct evaluations // where the meter was stale. TEST_ASSERT_EQUAL_UINT32(3600, faults.meter_read_fail); } /** * Worst-case: 1 hour, main loop blocked for 10s every 30s (batch TX + ACK). * Each blocking event catches up 10 ticks with stale meter. * * OLD: 10 faults per blocking event × 120 blocks = 1200 faults, * + 20 normal stale ticks between blocks × 120 = 2400 → total ~3600. * * FIXED: 1 fault per blocking event + 1 per non-blocked stale call. * 120 blocking events + 2400 normal calls = 2520. * (Still correctly counts each loop iteration where meter was stale.) */ static void test_periodic_blocking_reduces_faults() { FaultCounters faults_fixed = {}; FaultCounters faults_buggy = {}; uint32_t last_fixed = 0; uint32_t last_buggy = 0; bool tj_fixed = false; bool tj_buggy = false; uint32_t t = 0; for (int cycle = 0; cycle < 120; cycle++) { // 20s of normal 1Hz polling, meter stale. for (int s = 0; s < 20; s++) { t += 1000; simulate_fixed_sampling(last_fixed, t, false, tj_fixed, faults_fixed); simulate_buggy_sampling(last_buggy, t, false, tj_buggy, faults_buggy); } // 10s blocking (LoRa TX + ACK), meter stale. t += 10000; simulate_fixed_sampling(last_fixed, t, false, tj_fixed, faults_fixed); simulate_buggy_sampling(last_buggy, t, false, tj_buggy, faults_buggy); } // Both produce 3600 samples total. // Buggy: 20*120 normal + 10*120 from catch-up = 3600 faults. TEST_ASSERT_EQUAL_UINT32(3600, faults_buggy.meter_read_fail); // Fixed: 20*120 normal + 1*120 from catch-up = 2520 faults. TEST_ASSERT_EQUAL_UINT32(2520, faults_fixed.meter_read_fail); // Significant reduction: fixed < buggy. TEST_ASSERT_TRUE(faults_fixed.meter_read_fail < faults_buggy.meter_read_fail); } /** * Real scenario: meter works fine most of the time; occasional 5-10s stale * during LoRa TX. With fresh meter otherwise, faults should be minimal. * * 1h = 120 batch cycles of 30s. * Each cycle: 20s meter OK → 10s TX blocking (stale) → continue. * FIXED: 120 faults/h (one per TX stale event). * OLD: ~1200 faults/h (10 per TX stale event). */ static void test_realistic_scenario_mostly_fresh() { FaultCounters faults_fixed = {}; FaultCounters faults_buggy = {}; uint32_t last_fixed = 0; uint32_t last_buggy = 0; bool tj_fixed = false; bool tj_buggy = false; uint32_t t = 0; for (int cycle = 0; cycle < 120; cycle++) { // 20s of fresh meter data. for (int s = 0; s < 20; s++) { t += 1000; simulate_fixed_sampling(last_fixed, t, true, tj_fixed, faults_fixed); simulate_buggy_sampling(last_buggy, t, true, tj_buggy, faults_buggy); } // 10s LoRa blocking, meter goes stale. t += 10000; simulate_fixed_sampling(last_fixed, t, false, tj_fixed, faults_fixed); simulate_buggy_sampling(last_buggy, t, false, tj_buggy, faults_buggy); } // Fixed: 0 faults during fresh + 1 per stale event = 120 faults/h. TEST_ASSERT_EQUAL_UINT32(120, faults_fixed.meter_read_fail); // Buggy: 0 faults during fresh + 10 per stale event = 1200 faults/h. TEST_ASSERT_EQUAL_UINT32(1200, faults_buggy.meter_read_fail); } void setup() { UNITY_BEGIN(); RUN_TEST(test_no_fault_when_meter_fresh); RUN_TEST(test_single_fault_after_blocking_stale); RUN_TEST(test_buggy_produces_many_faults); RUN_TEST(test_time_jump_single_fault); RUN_TEST(test_sustained_stale_1hz_no_blocking); RUN_TEST(test_periodic_blocking_reduces_faults); RUN_TEST(test_realistic_scenario_mostly_fresh); UNITY_END(); } void loop() {}