Model heating mode when rooms have net heat loss in cold weather (<10°C). AC units with heat pump capability (canHeat) provide heating capacity, with the same 20% headroom threshold used for cooling. Adds cold risk detection, cold-weather actions, and full frontend support including heating mode timeline colors, room budget heating display, and i18n.
161 lines
4.5 KiB
Go
161 lines
4.5 KiB
Go
package action
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
|
|
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
|
|
)
|
|
|
|
func TestMatches_TempThreshold(t *testing.T) {
|
|
a := Action{When: TimeCondition{MinTempC: 30}}
|
|
if Matches(a, HourContext{TempC: 35}) != true {
|
|
t.Error("should match at 35C")
|
|
}
|
|
if Matches(a, HourContext{TempC: 25}) != false {
|
|
t.Error("should not match at 25C")
|
|
}
|
|
}
|
|
|
|
func TestMatches_HourRange(t *testing.T) {
|
|
a := Action{When: TimeCondition{HourFrom: 6, HourTo: 9}}
|
|
if Matches(a, HourContext{Hour: 7}) != true {
|
|
t.Error("should match at hour 7")
|
|
}
|
|
if Matches(a, HourContext{Hour: 12}) != false {
|
|
t.Error("should not match at hour 12")
|
|
}
|
|
}
|
|
|
|
func TestMatches_NightOnly(t *testing.T) {
|
|
a := Action{When: TimeCondition{NightOnly: true}}
|
|
if Matches(a, HourContext{IsDay: false}) != true {
|
|
t.Error("should match at night")
|
|
}
|
|
if Matches(a, HourContext{IsDay: true}) != false {
|
|
t.Error("should not match during day")
|
|
}
|
|
}
|
|
|
|
func TestMatches_MinRisk(t *testing.T) {
|
|
a := Action{When: TimeCondition{MinRisk: "high"}}
|
|
if Matches(a, HourContext{RiskLevel: risk.High}) != true {
|
|
t.Error("should match High")
|
|
}
|
|
if Matches(a, HourContext{RiskLevel: risk.Extreme}) != true {
|
|
t.Error("should match Extreme")
|
|
}
|
|
if Matches(a, HourContext{RiskLevel: risk.Moderate}) != false {
|
|
t.Error("should not match Moderate")
|
|
}
|
|
if Matches(a, HourContext{RiskLevel: risk.Low}) != false {
|
|
t.Error("should not match Low")
|
|
}
|
|
}
|
|
|
|
func TestMatches_BudgetStatus(t *testing.T) {
|
|
a := Action{When: TimeCondition{BudgetStatus: "marginal"}}
|
|
if Matches(a, HourContext{BudgetStatus: heat.Marginal}) != true {
|
|
t.Error("should match Marginal")
|
|
}
|
|
if Matches(a, HourContext{BudgetStatus: heat.Overloaded}) != true {
|
|
t.Error("should match Overloaded")
|
|
}
|
|
if Matches(a, HourContext{BudgetStatus: heat.Comfortable}) != false {
|
|
t.Error("should not match Comfortable")
|
|
}
|
|
}
|
|
|
|
func TestMatches_HighHumidity(t *testing.T) {
|
|
a := Action{When: TimeCondition{HighHumidity: true, MinTempC: 26}}
|
|
if Matches(a, HourContext{HumidityPct: 80, TempC: 30}) != true {
|
|
t.Error("should match at 80% humidity")
|
|
}
|
|
if Matches(a, HourContext{HumidityPct: 50, TempC: 30}) != false {
|
|
t.Error("should not match at 50% humidity")
|
|
}
|
|
}
|
|
|
|
func TestMatches_MaxTempThreshold(t *testing.T) {
|
|
a := Action{When: TimeCondition{MaxTempC: 5}}
|
|
if Matches(a, HourContext{TempC: 3}) != true {
|
|
t.Error("should match at 3C (below max 5)")
|
|
}
|
|
if Matches(a, HourContext{TempC: 10}) != false {
|
|
t.Error("should not match at 10C (above max 5)")
|
|
}
|
|
}
|
|
|
|
func TestMatches_NegativeMaxTemp(t *testing.T) {
|
|
a := Action{When: TimeCondition{MaxTempC: -5}}
|
|
if Matches(a, HourContext{TempC: -8}) != true {
|
|
t.Error("should match at -8C (below max -5)")
|
|
}
|
|
if Matches(a, HourContext{TempC: 0}) != false {
|
|
t.Error("should not match at 0C (above max -5)")
|
|
}
|
|
}
|
|
|
|
func TestMatches_NegativeMinTemp(t *testing.T) {
|
|
a := Action{When: TimeCondition{MinTempC: -10}}
|
|
if Matches(a, HourContext{TempC: -5}) != true {
|
|
t.Error("should match at -5C (above min -10)")
|
|
}
|
|
if Matches(a, HourContext{TempC: -15}) != false {
|
|
t.Error("should not match at -15C (below min -10)")
|
|
}
|
|
}
|
|
|
|
func TestSelectActions_ColdActions(t *testing.T) {
|
|
actions, err := LoadDefaultActions()
|
|
if err != nil {
|
|
t.Fatalf("LoadDefaultActions: %v", err)
|
|
}
|
|
ctx := HourContext{TempC: -8, Hour: 12, IsDay: true}
|
|
result := SelectActions(actions, ctx)
|
|
var ids []string
|
|
for _, a := range result {
|
|
ids = append(ids, a.ID)
|
|
}
|
|
wantIDs := map[string]bool{
|
|
"close_windows_cold": true,
|
|
"use_heating": true,
|
|
"check_insulation": true,
|
|
}
|
|
for id := range wantIDs {
|
|
found := false
|
|
for _, got := range ids {
|
|
if got == id {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected action %q in results, got %v", id, ids)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSelectActions_SortedByPriority(t *testing.T) {
|
|
actions := []Action{
|
|
{ID: "low_impact_high_effort", Impact: ImpactLow, Effort: EffortHigh},
|
|
{ID: "high_impact_no_effort", Impact: ImpactHigh, Effort: EffortNone},
|
|
{ID: "med_impact_low_effort", Impact: ImpactMedium, Effort: EffortLow},
|
|
}
|
|
ctx := HourContext{TempC: 35, Hour: 12, IsDay: true}
|
|
|
|
result := SelectActions(actions, ctx)
|
|
if len(result) != 3 {
|
|
t.Fatalf("len = %d, want 3", len(result))
|
|
}
|
|
if result[0].ID != "high_impact_no_effort" {
|
|
t.Errorf("first = %s, want high_impact_no_effort", result[0].ID)
|
|
}
|
|
if result[1].ID != "med_impact_low_effort" {
|
|
t.Errorf("second = %s, want med_impact_low_effort", result[1].ID)
|
|
}
|
|
if result[2].ID != "low_impact_high_effort" {
|
|
t.Errorf("third = %s, want low_impact_high_effort", result[2].ID)
|
|
}
|
|
}
|