Files
HeatGuard/internal/compute/compute_test.go
vikingowl d5452409b6 feat: rewrite to stateless web app with IndexedDB frontend
Replace CLI + SQLite architecture with a Go web server + vanilla JS
frontend using IndexedDB for all client-side data storage.

- Remove: cli, store, report, static packages
- Add: compute engine (BuildDashboard), server package, web UI
- Add: setup page with CRUD for profiles, rooms, devices, occupants, AC
- Add: dashboard with SVG temperature timeline, risk analysis, care checklist
- Add: i18n support (English/German) with server-side Go templates
- Add: LLM provider selection UI with client-side API key storage
- Add: per-room indoor temperature, edit buttons, language-aware AI summary
2026-02-09 13:31:38 +01:00

264 lines
7.2 KiB
Go

package compute
import (
"testing"
"time"
)
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_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))
}
}