Files
EMS/internal/actuator/actuator_test.go
Lutz Finsterle f45d23df97 Fix Shelly unreachable: roll back engine state on failed actions
Without this, a failed Execute() left engine state diverged from hardware.
SyncHardwareState would then misread the mismatch as a manual override and
apply a 1-hour lockout — causing either a stuck-on or stuck-off loop.

Now Execute() returns per-action []error. The control loop calls
Engine.RollbackAction() for each failed action, keeping engine state in
sync with hardware so the next cycle simply retries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:44:08 +02:00

181 lines
4.6 KiB
Go

package actuator
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"github.com/tb/ems/internal/config"
"github.com/tb/ems/internal/engine"
)
// mockShelly simulates both Gen1 and Gen2 Shelly HTTP APIs.
type mockShelly struct {
state bool // current relay state
calls []string
}
func (m *mockShelly) handler() http.Handler {
mux := http.NewServeMux()
// Gen1: GET /relay/0?turn=on|off or GET /relay/0 (read state)
mux.HandleFunc("/relay/0", func(w http.ResponseWriter, r *http.Request) {
m.calls = append(m.calls, r.Method+" "+r.URL.String())
if turn := r.URL.Query().Get("turn"); turn != "" {
m.state = turn == "on"
}
json.NewEncoder(w).Encode(map[string]interface{}{"ison": m.state})
})
// Gen2: POST /rpc/Switch.Set or POST /rpc/Switch.GetStatus
mux.HandleFunc("/rpc/Switch.Set", func(w http.ResponseWriter, r *http.Request) {
m.calls = append(m.calls, r.Method+" "+r.URL.Path)
var body struct {
On bool `json:"on"`
}
json.NewDecoder(r.Body).Decode(&body)
wasOn := m.state
m.state = body.On
json.NewEncoder(w).Encode(map[string]interface{}{"was_on": wasOn})
})
mux.HandleFunc("/rpc/Switch.GetStatus", func(w http.ResponseWriter, r *http.Request) {
m.calls = append(m.calls, r.Method+" "+r.URL.Path)
json.NewEncoder(w).Encode(map[string]interface{}{"output": m.state})
})
return mux
}
func (m *mockShelly) start(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(m.handler())
t.Cleanup(srv.Close)
return srv
}
// ipFrom extracts host:port from an httptest server URL.
func ipFrom(srv *httptest.Server) string {
u, _ := url.Parse(srv.URL)
return u.Host
}
func testActuator(t *testing.T, sgReadyIP, wallboxAIP, wallboxBIP string) *Actuator {
t.Helper()
cfg := &config.Config{
Shelly: config.ShellyConfig{
SGReady: config.ShellyDevice{IP: sgReadyIP, Gen: 1},
WallboxA: config.ShellyDevice{IP: wallboxAIP, Gen: 2},
WallboxB: config.ShellyDevice{IP: wallboxBIP, Gen: 2},
},
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
return NewActuator(cfg, nil, logger)
}
func TestExecuteTurnOnSGReady(t *testing.T) {
sg := &mockShelly{}
sgSrv := sg.start(t)
dummy := &mockShelly{}
dummySrv := dummy.start(t)
act := testActuator(t, ipFrom(sgSrv), ipFrom(dummySrv), ipFrom(dummySrv))
errs := act.Execute(context.Background(), []engine.Action{
{Consumer: engine.ConsumerSGReady, TurnOn: true, Reason: "test"},
})
if errs[0] != nil {
t.Fatalf("Execute failed: %v", errs[0])
}
if !sg.state {
t.Error("SG-Ready should be ON after TurnOn action")
}
if len(sg.calls) != 1 || !strings.Contains(sg.calls[0], "turn=on") {
t.Errorf("expected one GET /relay/0?turn=on call, got %v", sg.calls)
}
}
func TestExecuteTurnOffWallboxA(t *testing.T) {
wbA := &mockShelly{state: true} // starts ON
wbASrv := wbA.start(t)
dummy := &mockShelly{}
dummySrv := dummy.start(t)
act := testActuator(t, ipFrom(dummySrv), ipFrom(wbASrv), ipFrom(dummySrv))
errs := act.Execute(context.Background(), []engine.Action{
{Consumer: engine.ConsumerWallboxA, TurnOn: false, Reason: "import"},
})
if errs[0] != nil {
t.Fatalf("Execute failed: %v", errs[0])
}
if wbA.state {
t.Error("Wallbox A should be OFF after TurnOff action")
}
}
func TestReadAllStates(t *testing.T) {
sg := &mockShelly{state: true}
sgSrv := sg.start(t)
wbA := &mockShelly{state: false}
wbASrv := wbA.start(t)
wbB := &mockShelly{state: true}
wbBSrv := wbB.start(t)
act := testActuator(t, ipFrom(sgSrv), ipFrom(wbASrv), ipFrom(wbBSrv))
states, err := act.ReadAllStates(context.Background())
if err != nil {
t.Fatalf("ReadAllStates failed: %v", err)
}
if !states[engine.ConsumerSGReady].On {
t.Error("SG-Ready should be ON")
}
if states[engine.ConsumerWallboxA].On {
t.Error("Wallbox A should be OFF")
}
if !states[engine.ConsumerWallboxB].On {
t.Error("Wallbox B should be ON")
}
}
func TestExecuteMultipleActions(t *testing.T) {
sg := &mockShelly{}
sgSrv := sg.start(t)
wbA := &mockShelly{}
wbASrv := wbA.start(t)
wbB := &mockShelly{}
wbBSrv := wbB.start(t)
act := testActuator(t, ipFrom(sgSrv), ipFrom(wbASrv), ipFrom(wbBSrv))
for i, err := range act.Execute(context.Background(), []engine.Action{
{Consumer: engine.ConsumerSGReady, TurnOn: true},
{Consumer: engine.ConsumerWallboxA, TurnOn: true},
{Consumer: engine.ConsumerWallboxB, TurnOn: true},
}) {
if err != nil {
t.Fatalf("Execute action %d failed: %v", i, err)
}
}
if !sg.state || !wbA.state || !wbB.state {
t.Error("all consumers should be ON")
}
}