package econetui import ( "context" "net/http/httptest" "strings" "testing" pb "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config" ) func newTestServer(t *testing.T) *EconetUIServer { t.Helper() // grpc is nil: the render/view helpers under test never touch it. return NewEconetUIServer(context.Background(), &config.ServiceConfig{}, nil) } func sampleDevice() *pb.Device { return &pb.Device{ SerialNumber: "SN123", FriendlyName: "Garage Water Heater", GenericType: "heatpumpWaterHeater", Connected: true, Mode: "energy-saving", Running: true, RunningState: "compressor-running", Setpoint: 120, HotWaterAvailability: 66, AlertCount: 1, SupportedModes: []pb.Mode{ pb.Mode_MODE_HEAT_PUMP, pb.Mode_MODE_ENERGY_SAVING, pb.Mode_MODE_HIGH_DEMAND, }, } } func TestCardRendersWithPending(t *testing.T) { s := newTestServer(t) d := sampleDevice() // Simulate an in-flight change to a mode different from the reported one. s.pending.set(d.SerialNumber, "heat-pump") cv := s.toCardView(d, true) if cv.Pending != "Heat Pump" { t.Fatalf("pending = %q, want %q", cv.Pending, "Heat Pump") } if cv.Icon != "/static/icons/heatpump.svg" { t.Fatalf("icon = %q", cv.Icon) } rec := httptest.NewRecorder() s.render(rec, "card", cv) body := rec.Body.String() for _, want := range []string{ `id="card-SN123"`, "Garage Water Heater", `hx-post="/ui/devices/SN123/mode"`, "badge-pending", "Updating", // "Updating → Heat Pump…" "Energy Saving", "Heat Pump", "High Demand", "120°F", "66%", "fill-meter", // fill-level graph "fill-high", // 66% → high bucket (>=66) } { if !strings.Contains(body, want) { t.Errorf("card body missing %q\n---\n%s", want, body) } } } func TestCardConvergenceClearsPending(t *testing.T) { s := newTestServer(t) d := sampleDevice() d.Mode = "heat-pump" // reported now matches the desired s.pending.set(d.SerialNumber, "heat-pump") cv := s.toCardView(d, true) if cv.Pending != "" { t.Fatalf("pending should clear once reported catches up, got %q", cv.Pending) } if _, ok := s.pending.get(d.SerialNumber); ok { t.Fatalf("pending entry should have been removed after convergence") } } func TestDevicesEmptyState(t *testing.T) { s := newTestServer(t) rec := httptest.NewRecorder() s.render(rec, "devices", pageView{}) if !strings.Contains(rec.Body.String(), "No EcoNet devices loaded yet") { t.Errorf("empty state not rendered:\n%s", rec.Body.String()) } } func TestIconForLoose(t *testing.T) { cases := map[string]string{ "heatpumpWaterHeater": "/static/icons/heatpump.svg", "heatpumpWaterHeaterGen5": "/static/icons/heatpump.svg", "HeatPump": "/static/icons/heatpump.svg", "Hybrid Electric": "/static/icons/heatpump.svg", "heat_pump_water_heater": "/static/icons/heatpump.svg", "gasWaterHeater": "/static/icons/gas.svg", "tanklessWaterHeater": "/static/icons/tankless.svg", "electricWaterHeater": "/static/icons/electric.svg", "somethingElse": "/static/icons/unknown.svg", "": "/static/icons/unknown.svg", } for in, want := range cases { if got := iconFor(in); got != want { t.Errorf("iconFor(%q) = %q, want %q", in, got, want) } } } func TestDebugTemplateEscapes(t *testing.T) { s := newTestServer(t) rec := httptest.NewRecorder() s.render(rec, "debug", `{"@NAME":"x"}`) body := rec.Body.String() if strings.Contains(body, "") { t.Errorf("debug output not escaped: %s", body) } if !strings.Contains(body, "<b>") { t.Errorf("expected escaped JSON, got: %s", body) } } func TestPrettyMode(t *testing.T) { cases := map[string]string{ "heat-pump": "Heat Pump", "energy-saving": "Energy Saving", "off": "Off", "": "Unknown", } for in, want := range cases { if got := prettyMode(in); got != want { t.Errorf("prettyMode(%q) = %q, want %q", in, got, want) } } }