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)
}
}
}