package compute import ( "testing" "time" "github.com/cnachtigall/heatwave-autopilot/internal/heat" ) func ptr(f float64) *float64 { return &f } func makeForecasts(baseTime time.Time, temps []float64) []Forecast { var forecasts []Forecast for i, t := range temps { ts := baseTime.Add(time.Duration(i) * time.Hour) temp := t humid := 50.0 cloud := 50.0 sun := 30.0 apparent := t forecasts = append(forecasts, Forecast{ Timestamp: ts, TemperatureC: &temp, HumidityPct: &humid, CloudCoverPct: &cloud, SunshineMin: &sun, ApparentTempC: &apparent, }) } return forecasts } func TestBuildDashboard_NoForecasts(t *testing.T) { req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "Europe/Berlin"}, Date: "2025-07-15", } _, err := BuildDashboard(req) if err == nil { t.Fatal("expected error for empty forecasts") } } func TestBuildDashboard_BasicComputation(t *testing.T) { loc, _ := time.LoadLocation("Europe/Berlin") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) // 24 hours: mild night, hot afternoon temps := make([]float64, 24) for i := range temps { switch { case i < 6: temps[i] = 18 case i < 10: temps[i] = 22 + float64(i-6)*2 case i < 16: temps[i] = 30 + float64(i-10)*1.5 case i < 20: temps[i] = 37 - float64(i-16)*2 default: temps[i] = 22 - float64(i-20) } } req := ComputeRequest{ Profile: Profile{Name: "Berlin", Timezone: "Europe/Berlin"}, Forecasts: makeForecasts(base, temps), Rooms: []Room{{ ID: 1, ProfileID: 1, Name: "Office", AreaSqm: 20, CeilingHeightM: 2.5, Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, }}, Devices: []Device{{ ID: 1, RoomID: 1, Name: "PC", WattsIdle: 50, WattsTypical: 200, WattsPeak: 400, DutyCycle: 1.0, }}, Occupants: []Occupant{{ ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary", }}, ACUnits: []ACUnit{{ ID: 1, ProfileID: 1, Name: "Portable AC", CapacityBTU: 8000, EfficiencyEER: 10, }}, ACAssignments: []ACAssignment{{ACID: 1, RoomID: 1}}, Toggles: map[string]bool{}, Date: "2025-07-15", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if data.ProfileName != "Berlin" { t.Errorf("got profile name %q, want %q", data.ProfileName, "Berlin") } if data.Date != "2025-07-15" { t.Errorf("got date %q, want %q", data.Date, "2025-07-15") } if len(data.Timeline) != 24 { t.Errorf("got %d timeline slots, want 24", len(data.Timeline)) } if data.PeakTempC == 0 { t.Error("peak temp should be > 0") } if data.RiskLevel == "" { t.Error("risk level should not be empty") } if len(data.RoomBudgets) == 0 { t.Error("room budgets should not be empty") } } func TestBuildDashboard_VulnerableOccupants(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) temps := make([]float64, 24) for i := range temps { temps[i] = 32 } req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "UTC"}, Forecasts: makeForecasts(base, temps), Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6}}, Occupants: []Occupant{ {ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary", Vulnerable: true}, }, Toggles: map[string]bool{}, Date: "2025-07-15", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(data.CareChecklist) == 0 { t.Error("expected care checklist for vulnerable occupant") } } func TestBuildDashboard_GamingToggle(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) temps := make([]float64, 24) for i := range temps { temps[i] = 35 } req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "UTC"}, Forecasts: makeForecasts(base, temps), Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6}}, Devices: []Device{{ ID: 1, RoomID: 1, Name: "PC", WattsIdle: 50, WattsTypical: 200, WattsPeak: 400, DutyCycle: 1.0, }}, Occupants: []Occupant{{ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary"}}, Toggles: map[string]bool{"gaming": true}, Date: "2025-07-15", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } // Gaming mode should increase heat load vs non-gaming if len(data.RoomBudgets) == 0 { t.Error("expected room budgets") } } func TestBuildDashboard_Warnings(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) temps := make([]float64, 24) for i := range temps { temps[i] = 30 } req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "UTC"}, Forecasts: makeForecasts(base, temps), Warnings: []Warning{{ Headline: "Heat Warning", Severity: "Severe", Description: "Extreme heat expected", Instruction: "Stay hydrated", Onset: "2025-07-15 06:00", Expires: "2025-07-15 22:00", }}, Toggles: map[string]bool{}, Date: "2025-07-15", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(data.Warnings) != 1 { t.Errorf("got %d warnings, want 1", len(data.Warnings)) } if data.Warnings[0].Headline != "Heat Warning" { t.Errorf("got headline %q, want %q", data.Warnings[0].Headline, "Heat Warning") } } func TestBuildDashboard_InvalidDate(t *testing.T) { req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "UTC"}, Forecasts: []Forecast{{Timestamp: time.Now()}}, Date: "not-a-date", } _, err := BuildDashboard(req) if err == nil { t.Fatal("expected error for invalid date") } } func TestBuildDashboard_CoolModeVentilate(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) // All hours at 20°C (below default indoor 25°C) → ventilate temps := make([]float64, 24) for i := range temps { temps[i] = 20 } req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "UTC"}, Forecasts: makeForecasts(base, temps), Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 25}}, Toggles: map[string]bool{}, Date: "2025-07-15", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } for _, slot := range data.Timeline { if slot.CoolMode != "ventilate" { t.Errorf("hour %d: got CoolMode %q, want %q", slot.Hour, slot.CoolMode, "ventilate") } } if data.IndoorTempC != 25 { t.Errorf("got IndoorTempC %v, want 25", data.IndoorTempC) } } func TestBuildDashboard_CoolModeAC(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) // All hours at 30°C (above indoor 25°C), with enough AC → "ac" temps := make([]float64, 24) for i := range temps { temps[i] = 30 } req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "UTC"}, Forecasts: makeForecasts(base, temps), Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 25}}, ACUnits: []ACUnit{{ID: 1, ProfileID: 1, Name: "AC", CapacityBTU: 20000}}, ACAssignments: []ACAssignment{{ACID: 1, RoomID: 1}}, Toggles: map[string]bool{}, Date: "2025-07-15", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } for _, slot := range data.Timeline { if slot.CoolMode != "ac" { t.Errorf("hour %d: got CoolMode %q, want %q", slot.Hour, slot.CoolMode, "ac") } } } func TestBuildDashboard_CoolModeOverloaded(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) // Hot temps, no AC → overloaded (heat gains exceed 0 AC capacity) temps := make([]float64, 24) for i := range temps { temps[i] = 38 } req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "UTC"}, Forecasts: makeForecasts(base, temps), Rooms: []Room{{ ID: 1, Name: "Room", AreaSqm: 20, CeilingHeightM: 2.5, Orientation: "S", ShadingFactor: 1, VentilationACH: 2.0, WindowFraction: 0.3, SHGC: 0.8, IndoorTempC: 25, }}, Devices: []Device{{ ID: 1, RoomID: 1, Name: "PC", WattsIdle: 100, WattsTypical: 400, WattsPeak: 600, DutyCycle: 1.0, }}, Occupants: []Occupant{{ID: 1, RoomID: 1, Count: 3, ActivityLevel: "moderate"}}, Toggles: map[string]bool{}, Date: "2025-07-15", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } hasOverloaded := false for _, slot := range data.Timeline { if slot.CoolMode == "overloaded" { hasOverloaded = true break } } if !hasOverloaded { t.Error("expected at least one hour with CoolMode 'overloaded' (no AC, high gains)") } } func TestDetermineCoolMode(t *testing.T) { tests := []struct { name string outdoorTempC float64 indoorTempC float64 outdoorHumidityPct float64 worstStatus heat.BudgetStatus want string }{ {"cool and dry → ventilate", 20, 25, 50, heat.Comfortable, "ventilate"}, {"cool and humid → sealed", 20, 25, 90, heat.Comfortable, "sealed"}, {"hot and overloaded → overloaded", 38, 25, 50, heat.Overloaded, "overloaded"}, {"hot and comfortable → ac", 30, 25, 50, heat.Comfortable, "ac"}, {"humidity boundary 79.9 → ventilate", 20, 25, 79.9, heat.Comfortable, "ventilate"}, {"humidity boundary 80.0 → sealed", 20, 25, 80.0, heat.Comfortable, "sealed"}, {"cold and dry → comfort", 5, 25, 50, heat.Comfortable, "comfort"}, {"cold and humid → comfort", 5, 25, 90, heat.Marginal, "comfort"}, {"warm but not cold enough → ventilate", 21, 25, 50, heat.Comfortable, "ventilate"}, {"warm and humid, marginal → sealed", 21, 25, 85, heat.Marginal, "sealed"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := determineCoolMode(tt.outdoorTempC, tt.indoorTempC, tt.outdoorHumidityPct, tt.worstStatus) if got != tt.want { t.Errorf("determineCoolMode(%v, %v, %v, %v) = %q, want %q", tt.outdoorTempC, tt.indoorTempC, tt.outdoorHumidityPct, tt.worstStatus, got, tt.want) } }) } } func TestBuildDashboard_IndoorHumidityDefault(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) temps := make([]float64, 24) for i := range temps { temps[i] = 25 } req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "UTC"}, Forecasts: makeForecasts(base, temps), Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6}}, Toggles: map[string]bool{}, Date: "2025-07-15", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if data.IndoorHumidityPct != 50.0 { t.Errorf("got IndoorHumidityPct %v, want 50.0", data.IndoorHumidityPct) } } func TestBuildDashboard_IndoorHumidityFromRooms(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) temps := make([]float64, 24) for i := range temps { temps[i] = 25 } h60 := 60.0 h40 := 40.0 req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "UTC"}, Forecasts: makeForecasts(base, temps), Rooms: []Room{ {ID: 1, Name: "Room1", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorHumidityPct: &h60}, {ID: 2, Name: "Room2", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "S", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorHumidityPct: &h40}, }, Toggles: map[string]bool{}, Date: "2025-07-15", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if data.IndoorHumidityPct != 50.0 { t.Errorf("got IndoorHumidityPct %v, want 50.0", data.IndoorHumidityPct) } } func TestBuildDashboard_CoolModeSealed(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) // 20°C outdoor, 90% RH, indoor 25°C → sealed forecasts := make([]Forecast, 24) for i := range forecasts { ts := base.Add(time.Duration(i) * time.Hour) temp := 20.0 humid := 90.0 cloud := 50.0 sun := 30.0 apparent := 20.0 forecasts[i] = Forecast{ Timestamp: ts, TemperatureC: &temp, HumidityPct: &humid, CloudCoverPct: &cloud, SunshineMin: &sun, ApparentTempC: &apparent, } } req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "UTC"}, Forecasts: forecasts, Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 25}}, ACUnits: []ACUnit{{ID: 1, ProfileID: 1, Name: "AC", CapacityBTU: 20000}}, ACAssignments: []ACAssignment{{ACID: 1, RoomID: 1}}, Toggles: map[string]bool{}, Date: "2025-07-15", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } for _, slot := range data.Timeline { if slot.CoolMode != "sealed" { t.Errorf("hour %d: got CoolMode %q, want %q", slot.Hour, slot.CoolMode, "sealed") } } } func TestBuildDashboard_CoolModeComfort(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 2, 10, 0, 0, 0, 0, loc) // Winter day: -4°C to 6°C, no AC, indoor 23°C forecasts := make([]Forecast, 24) for i := range forecasts { ts := base.Add(time.Duration(i) * time.Hour) temp := -4.0 + float64(i)*0.4 // -4 to ~5.6 humid := 50.0 cloud := 80.0 sun := 0.0 apparent := temp - 2 forecasts[i] = Forecast{ Timestamp: ts, TemperatureC: &temp, HumidityPct: &humid, CloudCoverPct: &cloud, SunshineMin: &sun, ApparentTempC: &apparent, } } req := ComputeRequest{ Profile: Profile{Name: "Winter", Timezone: "UTC"}, Forecasts: forecasts, Rooms: []Room{{ ID: 1, Name: "Office", AreaSqm: 20, CeilingHeightM: 2.5, Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 23, }}, Devices: []Device{{ ID: 1, RoomID: 1, Name: "PC", WattsIdle: 50, WattsTypical: 200, WattsPeak: 400, DutyCycle: 1.0, }}, Occupants: []Occupant{{ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary"}}, Toggles: map[string]bool{}, Date: "2025-02-10", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } for _, slot := range data.Timeline { if slot.CoolMode != "comfort" { t.Errorf("hour %d (%.1f°C): got CoolMode %q, want %q", slot.Hour, slot.TempC, slot.CoolMode, "comfort") } } if data.Timezone != "UTC" { t.Errorf("got Timezone %q, want %q", data.Timezone, "UTC") } // Room budget should be marginal (not overloaded) — no AC but ventilation can solve if len(data.RoomBudgets) == 0 { t.Fatal("expected room budgets") } if data.RoomBudgets[0].Status != "marginal" { t.Errorf("got budget status %q, want %q", data.RoomBudgets[0].Status, "marginal") } } func TestBuildDashboard_MultipleRooms(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) temps := make([]float64, 24) for i := range temps { temps[i] = 35 } req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "UTC"}, Forecasts: makeForecasts(base, temps), Rooms: []Room{ {ID: 1, Name: "Office", AreaSqm: 20, CeilingHeightM: 2.5, Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6}, {ID: 2, Name: "Bedroom", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1.0, VentilationACH: 0.3, WindowFraction: 0.1, SHGC: 0.4}, }, Devices: []Device{ {ID: 1, RoomID: 1, Name: "PC", WattsIdle: 50, WattsTypical: 200, WattsPeak: 400, DutyCycle: 1.0}, {ID: 2, RoomID: 2, Name: "Lamp", WattsIdle: 10, WattsTypical: 60, WattsPeak: 60, DutyCycle: 0.5}, }, Occupants: []Occupant{ {ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary"}, {ID: 2, RoomID: 2, Count: 2, ActivityLevel: "sleeping"}, }, ACUnits: []ACUnit{ {ID: 1, ProfileID: 1, Name: "AC1", CapacityBTU: 8000}, }, ACAssignments: []ACAssignment{ {ACID: 1, RoomID: 1}, {ACID: 1, RoomID: 2}, }, Toggles: map[string]bool{}, Date: "2025-07-15", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(data.RoomBudgets) != 2 { t.Errorf("got %d room budgets, want 2", len(data.RoomBudgets)) } }