package monitor import ( "errors" "testing" "time" "git.mial.net/mokhtar/x1208-exporter/internal/sensor" ) func shutdownConfig() Config { return Config{ Shutdown: true, Volts: 3.5, SOC: 15, Samples: 4, MinUptimeTicks: 2, RequireDischarge: true, } } // onBattery returns a low reading that falls by 10 mV per step, which is what // a real discharge looks like. func onBattery(step int) sensor.Reading { return sensor.Reading{ ACPresent: false, Volts: 3.40 - float64(step)*0.01, SOC: 10, At: time.Unix(1754661600+int64(step)*30, 0), } } func onMains() sensor.Reading { return sensor.Reading{ACPresent: true, Volts: 4.1, SOC: 100, At: time.Unix(1754661600, 0)} } func events(r Result) []Event { out := make([]Event, 0, len(r.Logs)) for _, l := range r.Logs { out = append(out, l.Event) } return out } func contains(list []Event, want Event) bool { for _, e := range list { if e == want { return true } } return false } // settle runs enough healthy mains ticks to clear the startup inhibition. func settle(t *testing.T, m *Monitor) { t.Helper() for i := 0; i <= m.cfg.MinUptimeTicks; i++ { if res := m.Tick(onMains(), nil); res.Shutdown { t.Fatal("shutdown fired while on mains") } } } func TestHealthyTicksLogNothingAfterStartup(t *testing.T) { m := New(Config{}) first := m.Tick(onMains(), nil) if !contains(events(first), EventStartup) { t.Error("first tick must log startup") } for i := 0; i < 5; i++ { if res := m.Tick(onMains(), nil); len(res.Logs) != 0 { t.Errorf("healthy tick %d logged %v, want nothing", i, events(res)) } } } func TestACTransitionLogsOncePerChange(t *testing.T) { m := New(Config{}) m.Tick(onMains(), nil) res := m.Tick(onBattery(0), nil) if !contains(events(res), EventACChanged) { t.Fatal("losing AC must log a transition") } if res := m.Tick(onBattery(1), nil); len(res.Logs) != 0 { t.Errorf("staying on battery logged %v, want nothing", events(res)) } if res := m.Tick(onMains(), nil); !contains(events(res), EventACChanged) { t.Error("regaining AC must log a transition") } } func TestFailedTickKeepsPreviousSampleAndCounts(t *testing.T) { m := New(Config{}) m.Tick(onMains(), nil) res := m.Tick(sensor.Reading{}, errors.New("i2c wedged")) if res.Healthy { t.Error("failed tick must report unhealthy") } if res.Failures != 1 { t.Errorf("failures = %d, want 1", res.Failures) } if !res.HaveSample || res.Sample.Volts != 4.1 { t.Error("failed tick must carry the previous complete sample forward") } if !contains(events(res), EventErrorEnter) { t.Error("first failure must log error_enter") } if res := m.Tick(sensor.Reading{}, errors.New("still wedged")); contains(events(res), EventErrorEnter) { t.Error("a continuing error must not log error_enter again") } if res := m.Tick(onMains(), nil); !contains(events(res), EventErrorExit) { t.Error("recovery must log error_exit") } } func TestShutdownDisabledByDefault(t *testing.T) { m := New(Config{}) for i := 0; i < 20; i++ { if m.Tick(onBattery(i), nil).Shutdown { t.Fatal("shutdown fired with an empty Config; it must be opt-in") } } } func TestShutdownFiresOnSustainedDischarge(t *testing.T) { m := New(shutdownConfig()) settle(t, m) for i := 0; i < 3; i++ { if m.Tick(onBattery(i), nil).Shutdown { t.Fatalf("shutdown fired on qualifying tick %d, before the sample count", i+1) } } res := m.Tick(onBattery(3), nil) if !res.Shutdown { t.Fatal("shutdown must fire on the fourth consecutive qualifying tick") } if !contains(events(res), EventShutdownTriggered) { t.Error("firing must log shutdown_triggered") } if m.Tick(onBattery(4), nil).Shutdown { t.Error("shutdown must fire only once") } } // A wedged sensor repeats one plausible low value. Consecutive identical // samples are one fault observed four times, not four confirmations. func TestStuckSensorNeverTriggersShutdown(t *testing.T) { m := New(shutdownConfig()) settle(t, m) stuck := sensor.Reading{ACPresent: false, Volts: 3.40, SOC: 10, At: time.Unix(1754661600, 0)} for i := 0; i < 50; i++ { if m.Tick(stuck, nil).Shutdown { t.Fatalf("shutdown fired at tick %d on a constant voltage", i) } } } // "low, low, long outage, low, low" is not a sustained low battery. func TestErrorResetsArmingWindow(t *testing.T) { m := New(shutdownConfig()) settle(t, m) m.Tick(onBattery(0), nil) m.Tick(onBattery(1), nil) res := m.Tick(sensor.Reading{}, errors.New("bus error")) if !contains(events(res), EventShutdownDisarmed) { t.Error("a failed tick must disarm, not pause, the shutdown window") } for i := 2; i < 5; i++ { if m.Tick(onBattery(i), nil).Shutdown { t.Fatalf("shutdown fired at tick %d; the window must restart after an error", i) } } } func TestACReturnDisarms(t *testing.T) { m := New(shutdownConfig()) settle(t, m) m.Tick(onBattery(0), nil) m.Tick(onBattery(1), nil) res := m.Tick(onMains(), nil) if !contains(events(res), EventShutdownDisarmed) { t.Error("AC returning must disarm") } for i := 2; i < 5; i++ { if m.Tick(onBattery(i), nil).Shutdown { t.Fatalf("shutdown fired at tick %d; the window must restart after AC returned", i) } } } // A flapping AC line must not accumulate toward a poweroff. func TestFlappingACNeverAccumulates(t *testing.T) { m := New(shutdownConfig()) settle(t, m) for i := 0; i < 40; i++ { var res Result if i%2 == 0 { res = m.Tick(onBattery(i), nil) } else { res = m.Tick(onMains(), nil) } if res.Shutdown { t.Fatalf("shutdown fired at tick %d on a flapping AC line", i) } } } // A restart loop must not power the machine off using readings taken before // the hardware settled. func TestStartupInhibitionBlocksImmediateShutdown(t *testing.T) { m := New(shutdownConfig()) for i := 0; i < 2; i++ { if m.Tick(onBattery(i), nil).Shutdown { t.Fatalf("shutdown fired at tick %d, inside the startup inhibition", i+1) } } } // The worst remaining failure: the AC line reads 0 while mains is actually // connected. The charger then holds cell voltage flat or rising, which is what // the discharge guard exists to catch. Without it a single stuck GPIO line // powers the machine off during normal operation. func TestFalseACLossWhileChargingNeverTriggersShutdown(t *testing.T) { m := New(shutdownConfig()) settle(t, m) for i := 0; i < 60; i++ { r := sensor.Reading{ ACPresent: false, // the line is lying Volts: 3.40 + float64(i)*0.005, // but the charger is working SOC: 10, } if m.Tick(r, nil).Shutdown { t.Fatalf("shutdown fired at tick %d while voltage was RISING; "+ "a false AC reading must not power the machine off", i) } } } // Guard against a future change that quietly drops the discharge requirement: // with it disabled, the same false reading DOES power the machine off. This // test documents the consequence rather than endorsing the setting. func TestWithoutDischargeGuardAFalseACReadingIsEnough(t *testing.T) { cfg := shutdownConfig() cfg.RequireDischarge = false m := New(cfg) settle(t, m) fired := false for i := 0; i < 10 && !fired; i++ { r := sensor.Reading{ACPresent: false, Volts: 3.40, SOC: 10} fired = m.Tick(r, nil).Shutdown } if !fired { t.Error("expected the unguarded config to act on a constant low reading; " + "if this changed, update the RequireDischarge documentation") } } // A failed poweroff must not count as a completed one. If it latched, the // battery would reach cell cutoff and the HAT would hard-cut the machine — // exactly what the feature prevents. func TestFailedActuationRetriesOnTheNextTick(t *testing.T) { m := New(shutdownConfig()) settle(t, m) for i := 0; i < 3; i++ { m.Tick(onBattery(i), nil) } if !m.Tick(onBattery(3), nil).Shutdown { t.Fatal("expected the first shutdown") } // systemctl poweroff failed: polkit denied it, or D-Bus was busy. m.ActuationFailed() res := m.Tick(onBattery(4), nil) if !res.Shutdown { t.Fatal("a failed poweroff must be retried on the next qualifying tick") } if m.Retries() != 1 { t.Errorf("Retries() = %d, want 1", m.Retries()) } // Still latched while the retry is outstanding. if m.Tick(onBattery(5), nil).Shutdown { t.Error("must not fire again before the second attempt is reported failed") } } // Retrying must not defeat the safety guards: if AC returns between attempts, // the machine must stay up. func TestRetryStillRespectsACReturn(t *testing.T) { m := New(shutdownConfig()) settle(t, m) for i := 0; i < 3; i++ { m.Tick(onBattery(i), nil) } if !m.Tick(onBattery(3), nil).Shutdown { t.Fatal("expected the first shutdown") } m.ActuationFailed() if m.Tick(onMains(), nil).Shutdown { t.Fatal("AC returned; a pending retry must not power the machine off") } for i := 4; i < 7; i++ { if m.Tick(onBattery(i), nil).Shutdown { t.Fatalf("fired at tick %d; the window must restart after AC returned", i) } } } func TestLowSOCAloneCanArm(t *testing.T) { m := New(shutdownConfig()) settle(t, m) // Voltage stays above the threshold; only state of charge is low. for i := 0; i < 3; i++ { r := sensor.Reading{ACPresent: false, Volts: 3.9 - float64(i)*0.01, SOC: 5} if m.Tick(r, nil).Shutdown { t.Fatalf("fired early at tick %d", i) } } r := sensor.Reading{ACPresent: false, Volts: 3.87, SOC: 5} if !m.Tick(r, nil).Shutdown { t.Error("a sustained low state of charge must trigger shutdown") } }