Add server-side rendered setup UI accessible via `heatwave web`. The dashboard is now re-rendered per request and includes a nav bar linking to the new /setup page. Setup provides full CRUD for profiles, rooms, devices, occupants, AC units (with room assignment), scenario toggles, and forecast fetching — all via POST/redirect/GET forms. - Add ShowNav field to DashboardData for conditional nav bar - Extract fetchForecastForProfile() for reuse by web handler - Create setup.html.tmpl with Tailwind-styled entity sections - Create web_handlers.go with 15 route handlers and flash cookies - Switch web.go from pre-rendered to per-request dashboard rendering - Graceful dashboard fallback when no forecast data exists
497 lines
12 KiB
Go
497 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
s, err := New(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("failed to create test store: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Close() })
|
|
return s
|
|
}
|
|
|
|
func TestNewStore(t *testing.T) {
|
|
s := newTestStore(t)
|
|
if s == nil {
|
|
t.Fatal("store is nil")
|
|
}
|
|
}
|
|
|
|
// --- Profile CRUD ---
|
|
|
|
func TestProfileCRUD(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
p, err := s.CreateProfile("home", 52.52, 13.41, "Europe/Berlin")
|
|
if err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
if p.Name != "home" || p.Latitude != 52.52 || p.Longitude != 13.41 {
|
|
t.Errorf("unexpected profile: %+v", p)
|
|
}
|
|
|
|
got, err := s.GetProfile(p.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetProfile: %v", err)
|
|
}
|
|
if got.Name != "home" {
|
|
t.Errorf("GetProfile name = %s, want home", got.Name)
|
|
}
|
|
|
|
gotByName, err := s.GetProfileByName("home")
|
|
if err != nil {
|
|
t.Fatalf("GetProfileByName: %v", err)
|
|
}
|
|
if gotByName.ID != p.ID {
|
|
t.Errorf("GetProfileByName ID = %d, want %d", gotByName.ID, p.ID)
|
|
}
|
|
|
|
if err := s.UpdateProfile(p.ID, "name", "office"); err != nil {
|
|
t.Fatalf("UpdateProfile: %v", err)
|
|
}
|
|
updated, _ := s.GetProfile(p.ID)
|
|
if updated.Name != "office" {
|
|
t.Errorf("updated name = %s, want office", updated.Name)
|
|
}
|
|
|
|
profiles, err := s.ListProfiles()
|
|
if err != nil {
|
|
t.Fatalf("ListProfiles: %v", err)
|
|
}
|
|
if len(profiles) != 1 {
|
|
t.Errorf("ListProfiles len = %d, want 1", len(profiles))
|
|
}
|
|
|
|
if err := s.DeleteProfile(p.ID); err != nil {
|
|
t.Fatalf("DeleteProfile: %v", err)
|
|
}
|
|
_, err = s.GetProfile(p.ID)
|
|
if err == nil {
|
|
t.Error("expected error after delete")
|
|
}
|
|
}
|
|
|
|
func TestProfileDuplicateName(t *testing.T) {
|
|
s := newTestStore(t)
|
|
_, err := s.CreateProfile("home", 52.52, 13.41, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = s.CreateProfile("home", 48.13, 11.58, "")
|
|
if err == nil {
|
|
t.Error("expected error for duplicate name")
|
|
}
|
|
}
|
|
|
|
func TestProfileInvalidField(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
|
err := s.UpdateProfile(p.ID, "nonexistent", "value")
|
|
if err == nil {
|
|
t.Error("expected error for invalid field")
|
|
}
|
|
}
|
|
|
|
// --- Room CRUD ---
|
|
|
|
func TestRoomCRUD(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
|
|
|
r, err := s.CreateRoom(p.ID, "office", 15, 3, "S", "shutters", 0.3, "natural", "average", DefaultRoomParams())
|
|
if err != nil {
|
|
t.Fatalf("CreateRoom: %v", err)
|
|
}
|
|
if r.Name != "office" || r.AreaSqm != 15 || r.Orientation != "S" || r.ShadingFactor != 0.3 {
|
|
t.Errorf("unexpected room: %+v", r)
|
|
}
|
|
|
|
rooms, err := s.ListRooms(p.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListRooms: %v", err)
|
|
}
|
|
if len(rooms) != 1 {
|
|
t.Errorf("ListRooms len = %d, want 1", len(rooms))
|
|
}
|
|
|
|
if err := s.UpdateRoom(r.ID, "name", "bedroom"); err != nil {
|
|
t.Fatalf("UpdateRoom: %v", err)
|
|
}
|
|
updated, _ := s.GetRoom(r.ID)
|
|
if updated.Name != "bedroom" {
|
|
t.Errorf("updated name = %s, want bedroom", updated.Name)
|
|
}
|
|
|
|
if err := s.DeleteRoom(r.ID); err != nil {
|
|
t.Fatalf("DeleteRoom: %v", err)
|
|
}
|
|
_, err = s.GetRoom(r.ID)
|
|
if err == nil {
|
|
t.Error("expected error after delete")
|
|
}
|
|
}
|
|
|
|
func TestRoomPhysicsFields(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
|
|
|
params := RoomParams{
|
|
CeilingHeightM: 3.0,
|
|
VentilationACH: 1.5,
|
|
WindowFraction: 0.20,
|
|
SHGC: 0.45,
|
|
}
|
|
r, err := s.CreateRoom(p.ID, "office", 15, 3, "S", "shutters", 0.3, "natural", "average", params)
|
|
if err != nil {
|
|
t.Fatalf("CreateRoom: %v", err)
|
|
}
|
|
if r.CeilingHeightM != 3.0 {
|
|
t.Errorf("CeilingHeightM = %v, want 3.0", r.CeilingHeightM)
|
|
}
|
|
if r.VentilationACH != 1.5 {
|
|
t.Errorf("VentilationACH = %v, want 1.5", r.VentilationACH)
|
|
}
|
|
if r.WindowFraction != 0.20 {
|
|
t.Errorf("WindowFraction = %v, want 0.20", r.WindowFraction)
|
|
}
|
|
if r.SHGC != 0.45 {
|
|
t.Errorf("SHGC = %v, want 0.45", r.SHGC)
|
|
}
|
|
}
|
|
|
|
func TestRoomACCapacity(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
|
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
|
|
|
|
// No AC assigned yet
|
|
cap, err := s.GetRoomACCapacity(r.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetRoomACCapacity: %v", err)
|
|
}
|
|
if cap != 0 {
|
|
t.Errorf("expected 0 BTU/h, got %v", cap)
|
|
}
|
|
|
|
// Assign AC
|
|
ac, _ := s.CreateACUnit(p.ID, "Portable", "portable", 8000, false, 10)
|
|
s.AssignACToRoom(ac.ID, r.ID)
|
|
|
|
cap, err = s.GetRoomACCapacity(r.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetRoomACCapacity: %v", err)
|
|
}
|
|
if cap != 8000 {
|
|
t.Errorf("expected 8000 BTU/h, got %v", cap)
|
|
}
|
|
}
|
|
|
|
// --- Device CRUD ---
|
|
|
|
func TestDeviceCRUD(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
|
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
|
|
|
|
d, err := s.CreateDevice(r.ID, "Gaming PC", "pc", 65, 200, 450, 1.0)
|
|
if err != nil {
|
|
t.Fatalf("CreateDevice: %v", err)
|
|
}
|
|
if d.Name != "Gaming PC" || d.WattsTypical != 200 {
|
|
t.Errorf("unexpected device: %+v", d)
|
|
}
|
|
|
|
devices, err := s.ListDevices(r.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListDevices: %v", err)
|
|
}
|
|
if len(devices) != 1 {
|
|
t.Errorf("ListDevices len = %d, want 1", len(devices))
|
|
}
|
|
|
|
allDevices, err := s.ListAllDevices(p.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListAllDevices: %v", err)
|
|
}
|
|
if len(allDevices) != 1 {
|
|
t.Errorf("ListAllDevices len = %d, want 1", len(allDevices))
|
|
}
|
|
|
|
if err := s.DeleteDevice(d.ID); err != nil {
|
|
t.Fatalf("DeleteDevice: %v", err)
|
|
}
|
|
_, err = s.GetDevice(d.ID)
|
|
if err == nil {
|
|
t.Error("expected error after delete")
|
|
}
|
|
}
|
|
|
|
// --- Occupant CRUD ---
|
|
|
|
func TestOccupantCRUD(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
|
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
|
|
|
|
o, err := s.CreateOccupant(r.ID, 1, "sedentary", false)
|
|
if err != nil {
|
|
t.Fatalf("CreateOccupant: %v", err)
|
|
}
|
|
if o.Count != 1 || o.ActivityLevel != "sedentary" || o.Vulnerable {
|
|
t.Errorf("unexpected occupant: %+v", o)
|
|
}
|
|
|
|
oVuln, err := s.CreateOccupant(r.ID, 1, "sleeping", true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !oVuln.Vulnerable {
|
|
t.Error("expected vulnerable=true")
|
|
}
|
|
|
|
occupants, err := s.ListOccupants(r.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListOccupants: %v", err)
|
|
}
|
|
if len(occupants) != 2 {
|
|
t.Errorf("ListOccupants len = %d, want 2", len(occupants))
|
|
}
|
|
|
|
all, err := s.ListAllOccupants(p.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListAllOccupants: %v", err)
|
|
}
|
|
if len(all) != 2 {
|
|
t.Errorf("ListAllOccupants len = %d, want 2", len(all))
|
|
}
|
|
|
|
if err := s.DeleteOccupant(o.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = s.GetOccupant(o.ID)
|
|
if err == nil {
|
|
t.Error("expected error after delete")
|
|
}
|
|
}
|
|
|
|
// --- AC Unit CRUD ---
|
|
|
|
func TestACUnitCRUD(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
|
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
|
|
|
|
ac, err := s.CreateACUnit(p.ID, "Portable", "portable", 8000, true, 10.5)
|
|
if err != nil {
|
|
t.Fatalf("CreateACUnit: %v", err)
|
|
}
|
|
if ac.Name != "Portable" || ac.CapacityBTU != 8000 || !ac.HasDehumidify {
|
|
t.Errorf("unexpected ac: %+v", ac)
|
|
}
|
|
|
|
if err := s.AssignACToRoom(ac.ID, r.ID); err != nil {
|
|
t.Fatalf("AssignACToRoom: %v", err)
|
|
}
|
|
rooms, err := s.GetACRoomAssignments(ac.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetACRoomAssignments: %v", err)
|
|
}
|
|
if len(rooms) != 1 || rooms[0] != r.ID {
|
|
t.Errorf("unexpected room assignments: %v", rooms)
|
|
}
|
|
|
|
if err := s.UnassignACFromRoom(ac.ID, r.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rooms, _ = s.GetACRoomAssignments(ac.ID)
|
|
if len(rooms) != 0 {
|
|
t.Error("expected empty after unassign")
|
|
}
|
|
|
|
units, err := s.ListACUnits(p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(units) != 1 {
|
|
t.Errorf("ListACUnits len = %d, want 1", len(units))
|
|
}
|
|
|
|
if err := s.DeleteACUnit(ac.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// --- Forecast CRUD ---
|
|
|
|
func TestForecastUpsertAndQuery(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
|
|
|
ts := time.Date(2025, 7, 15, 14, 0, 0, 0, time.UTC)
|
|
temp := 35.5
|
|
hum := 40.0
|
|
f := &Forecast{
|
|
ProfileID: p.ID,
|
|
Timestamp: ts,
|
|
TemperatureC: &temp,
|
|
HumidityPct: &hum,
|
|
Source: "openmeteo",
|
|
}
|
|
if err := s.UpsertForecast(f); err != nil {
|
|
t.Fatalf("UpsertForecast: %v", err)
|
|
}
|
|
|
|
// Upsert again (should update, not duplicate)
|
|
temp2 := 36.0
|
|
f.TemperatureC = &temp2
|
|
if err := s.UpsertForecast(f); err != nil {
|
|
t.Fatalf("UpsertForecast update: %v", err)
|
|
}
|
|
|
|
from := ts.Add(-time.Hour)
|
|
to := ts.Add(time.Hour)
|
|
forecasts, err := s.GetForecasts(p.ID, from, to, "openmeteo")
|
|
if err != nil {
|
|
t.Fatalf("GetForecasts: %v", err)
|
|
}
|
|
if len(forecasts) != 1 {
|
|
t.Fatalf("GetForecasts len = %d, want 1", len(forecasts))
|
|
}
|
|
if *forecasts[0].TemperatureC != 36.0 {
|
|
t.Errorf("temperature = %v, want 36.0", *forecasts[0].TemperatureC)
|
|
}
|
|
|
|
// Cleanup
|
|
deleted, err := s.CleanupOldForecasts(ts.Add(time.Hour * 24))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if deleted != 1 {
|
|
t.Errorf("deleted = %d, want 1", deleted)
|
|
}
|
|
}
|
|
|
|
// --- Warning CRUD ---
|
|
|
|
func TestWarningUpsertAndQuery(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
|
|
|
now := time.Now().UTC()
|
|
w := &Warning{
|
|
ProfileID: p.ID,
|
|
WarningID: "dwd-heat-001",
|
|
EventType: "STARKE HITZE",
|
|
Severity: "Severe",
|
|
Headline: "Heat warning Berlin",
|
|
Description: "Temperatures up to 37C expected",
|
|
Instruction: "Stay hydrated",
|
|
Onset: now,
|
|
Expires: now.Add(48 * time.Hour),
|
|
}
|
|
if err := s.UpsertWarning(w); err != nil {
|
|
t.Fatalf("UpsertWarning: %v", err)
|
|
}
|
|
|
|
// Upsert again (should update)
|
|
w.Headline = "Updated heat warning"
|
|
if err := s.UpsertWarning(w); err != nil {
|
|
t.Fatalf("UpsertWarning update: %v", err)
|
|
}
|
|
|
|
got, err := s.GetWarning("dwd-heat-001")
|
|
if err != nil {
|
|
t.Fatalf("GetWarning: %v", err)
|
|
}
|
|
if got.Headline != "Updated heat warning" {
|
|
t.Errorf("headline = %s, want 'Updated heat warning'", got.Headline)
|
|
}
|
|
|
|
active, err := s.GetActiveWarnings(p.ID, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(active) != 1 {
|
|
t.Errorf("active warnings = %d, want 1", len(active))
|
|
}
|
|
|
|
// Check expired don't show
|
|
expired, _ := s.GetActiveWarnings(p.ID, now.Add(72*time.Hour))
|
|
if len(expired) != 0 {
|
|
t.Errorf("expected 0 active warnings after expiry, got %d", len(expired))
|
|
}
|
|
|
|
// Cleanup
|
|
deleted, err := s.CleanupExpiredWarnings(now.Add(72 * time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if deleted != 1 {
|
|
t.Errorf("deleted = %d, want 1", deleted)
|
|
}
|
|
}
|
|
|
|
// --- Toggle ---
|
|
|
|
func TestToggle(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
|
|
|
if err := s.SetToggle(p.ID, "gaming", true); err != nil {
|
|
t.Fatalf("SetToggle: %v", err)
|
|
}
|
|
|
|
toggles, err := s.GetToggles(p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(toggles) != 1 || !toggles[0].Active || toggles[0].Name != "gaming" {
|
|
t.Errorf("unexpected toggles: %+v", toggles)
|
|
}
|
|
|
|
active, err := s.GetActiveToggleNames(p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !active["gaming"] {
|
|
t.Error("expected gaming toggle active")
|
|
}
|
|
|
|
if err := s.SetToggle(p.ID, "gaming", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
active, _ = s.GetActiveToggleNames(p.ID)
|
|
if active["gaming"] {
|
|
t.Error("expected gaming toggle inactive")
|
|
}
|
|
}
|
|
|
|
// --- Cascade Delete ---
|
|
|
|
func TestCascadeDeleteProfile(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
|
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
|
|
s.CreateDevice(r.ID, "PC", "pc", 65, 200, 450, 1.0)
|
|
s.CreateOccupant(r.ID, 1, "sedentary", false)
|
|
s.CreateACUnit(p.ID, "AC", "portable", 8000, false, 10)
|
|
|
|
if err := s.DeleteProfile(p.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rooms, _ := s.ListRooms(p.ID)
|
|
if len(rooms) != 0 {
|
|
t.Error("expected rooms deleted on cascade")
|
|
}
|
|
devices, _ := s.ListDevices(r.ID)
|
|
if len(devices) != 0 {
|
|
t.Error("expected devices deleted on cascade")
|
|
}
|
|
}
|