package app import ( "context" "errors" "reflect" "testing" "time" "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain" "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/ports/driven" ) func TestClimateAppRefresh(t *testing.T) { t.Run("filters non-climate entities and populates cache", func(t *testing.T) { current := 21.5 target := 22.0 ha := &mockHAClient{ listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { return []*driven.HAState{ { EntityID: "climate.air_conditioner", State: "cool", Attributes: map[string]any{ "friendly_name": "Air Conditioner", "hvac_modes": []any{"heat_cool", "cool", "dry", "fan_only", "heat", "off"}, "fan_mode": "auto", "fan_modes": []any{"auto", "low", "high"}, "current_temperature": current, "temperature": target, "target_temp_step": float64(1), "min_temp": float64(7), "max_temp": float64(35), }, }, { EntityID: "switch.fan", State: "off", }, }, nil }, } app := NewClimateApp(ha) if err := app.Refresh(context.Background()); err != nil { t.Fatalf("Refresh() error = %v", err) } got := app.cache want := []domain.Climate{ { EntityID: "climate.air_conditioner", FriendlyName: "Air Conditioner", State: "cool", HVACModes: []string{"heat_cool", "cool", "dry", "fan_only", "heat", "off"}, FanMode: "auto", FanModes: []string{"auto", "low", "high"}, CurrentTemperature: ¤t, TargetTemperature: &target, TargetTempStep: 1, MinTemp: 7, MaxTemp: 35, }, } if !reflect.DeepEqual(got, want) { t.Fatalf("cache = %#v, want %#v", got, want) } }) t.Run("nil current_temperature means no sensor, not zero", func(t *testing.T) { ha := &mockHAClient{ listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { return []*driven.HAState{ { EntityID: "climate.air_conditioner", State: "off", Attributes: map[string]any{"current_temperature": nil}, }, }, nil }, } app := NewClimateApp(ha) if err := app.Refresh(context.Background()); err != nil { t.Fatalf("Refresh() error = %v", err) } if app.cache[0].CurrentTemperature != nil { t.Fatalf("CurrentTemperature = %v, want nil", *app.cache[0].CurrentTemperature) } }) } func TestClimateAppListClimates(t *testing.T) { t.Run("uses cache when populated", func(t *testing.T) { calls := 0 app := NewClimateApp(&mockHAClient{ listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { calls++ return nil, nil }, }) app.cache = []domain.Climate{{EntityID: "climate.air_conditioner", State: "cool"}} got, err := app.ListClimates(context.Background()) if err != nil { t.Fatalf("ListClimates() error = %v", err) } if calls != 0 { t.Fatalf("ListStates() calls = %d, want 0", calls) } if !reflect.DeepEqual(got, app.cache) { t.Fatalf("ListClimates() = %#v, want %#v", got, app.cache) } }) t.Run("calls ListStates when cache is nil", func(t *testing.T) { calls := 0 app := NewClimateApp(&mockHAClient{ listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { calls++ return []*driven.HAState{ {EntityID: "climate.air_conditioner", State: "cool"}, {EntityID: "sensor.temp", State: "21"}, }, nil }, }) got, err := app.ListClimates(context.Background()) if err != nil { t.Fatalf("ListClimates() error = %v", err) } if calls != 1 { t.Fatalf("ListStates() calls = %d, want 1", calls) } want := []domain.Climate{{EntityID: "climate.air_conditioner", State: "cool"}} if !reflect.DeepEqual(got, want) { t.Fatalf("ListClimates() = %#v, want %#v", got, want) } }) t.Run("propagates refresh error", func(t *testing.T) { wantErr := errors.New("list failed") app := NewClimateApp(&mockHAClient{ listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { return nil, wantErr }, }) _, err := app.ListClimates(context.Background()) if !errors.Is(err, wantErr) { t.Fatalf("ListClimates() error = %v, want %v", err, wantErr) } }) } func TestClimateAppTurnOn(t *testing.T) { now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) state := &driven.HAState{ EntityID: "climate.air_conditioner", State: "cool", Attributes: map[string]any{"friendly_name": "Air Conditioner"}, LastChanged: now, LastUpdated: now, } t.Run("happy path", func(t *testing.T) { app := NewClimateApp(&mockHAClient{ callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { if svcDomain != "climate" || service != "turn_on" { t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) } wantPayload := map[string]any{"entity_id": "climate.air_conditioner"} if !reflect.DeepEqual(payload, wantPayload) { t.Fatalf("payload = %#v, want %#v", payload, wantPayload) } return []*driven.HAState{state}, nil }, }) got, err := app.TurnOn(context.Background(), "climate.air_conditioner") if err != nil { t.Fatalf("TurnOn() error = %v", err) } if !reflect.DeepEqual(got, haStateToDomain(state)) { t.Fatalf("TurnOn() = %#v, want %#v", got, haStateToDomain(state)) } }) t.Run("falls back to GetState when service returns empty list", func(t *testing.T) { getStateCalls := 0 app := NewClimateApp(&mockHAClient{ callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { return []*driven.HAState{}, nil }, getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { getStateCalls++ return state, nil }, }) got, err := app.TurnOn(context.Background(), "climate.air_conditioner") if err != nil { t.Fatalf("TurnOn() error = %v", err) } if getStateCalls != 1 { t.Fatalf("GetState() calls = %d, want 1", getStateCalls) } if !reflect.DeepEqual(got, haStateToDomain(state)) { t.Fatalf("TurnOn() = %#v, want %#v", got, haStateToDomain(state)) } }) t.Run("returns CallService error", func(t *testing.T) { wantErr := errors.New("call failed") app := NewClimateApp(&mockHAClient{ callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { return nil, wantErr }, }) _, err := app.TurnOn(context.Background(), "climate.air_conditioner") if !errors.Is(err, wantErr) { t.Fatalf("TurnOn() error = %v, want %v", err, wantErr) } }) } func TestClimateAppTurnOff(t *testing.T) { now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) state := &driven.HAState{ EntityID: "climate.air_conditioner", State: "off", Attributes: map[string]any{"friendly_name": "Air Conditioner"}, LastChanged: now, LastUpdated: now, } t.Run("happy path", func(t *testing.T) { app := NewClimateApp(&mockHAClient{ callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { if svcDomain != "climate" || service != "turn_off" { t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) } return []*driven.HAState{state}, nil }, }) got, err := app.TurnOff(context.Background(), "climate.air_conditioner") if err != nil { t.Fatalf("TurnOff() error = %v", err) } if !reflect.DeepEqual(got, haStateToDomain(state)) { t.Fatalf("TurnOff() = %#v, want %#v", got, haStateToDomain(state)) } }) t.Run("error path", func(t *testing.T) { wantErr := errors.New("turn off failed") app := NewClimateApp(&mockHAClient{ callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { return nil, wantErr }, }) _, err := app.TurnOff(context.Background(), "climate.air_conditioner") if !errors.Is(err, wantErr) { t.Fatalf("TurnOff() error = %v, want %v", err, wantErr) } }) } func TestClimateAppSetHVACMode(t *testing.T) { now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) state := &driven.HAState{ EntityID: "climate.air_conditioner", State: "cool", Attributes: map[string]any{"friendly_name": "Air Conditioner"}, LastChanged: now, LastUpdated: now, } t.Run("happy path passes raw mode through with no validation", func(t *testing.T) { app := NewClimateApp(&mockHAClient{ callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { if svcDomain != "climate" || service != "set_hvac_mode" { t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) } wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "hvac_mode": "cool"} if !reflect.DeepEqual(payload, wantPayload) { t.Fatalf("payload = %#v, want %#v", payload, wantPayload) } return []*driven.HAState{state}, nil }, }) got, err := app.SetHVACMode(context.Background(), "climate.air_conditioner", "cool") if err != nil { t.Fatalf("SetHVACMode() error = %v", err) } if !reflect.DeepEqual(got, haStateToDomain(state)) { t.Fatalf("SetHVACMode() = %#v, want %#v", got, haStateToDomain(state)) } }) t.Run("error path", func(t *testing.T) { wantErr := errors.New("set hvac mode failed") app := NewClimateApp(&mockHAClient{ callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { return nil, wantErr }, }) _, err := app.SetHVACMode(context.Background(), "climate.air_conditioner", "cool") if !errors.Is(err, wantErr) { t.Fatalf("SetHVACMode() error = %v, want %v", err, wantErr) } }) } func TestClimateAppIncreaseTemperature(t *testing.T) { now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) resultState := &driven.HAState{ EntityID: "climate.air_conditioner", State: "cool", LastChanged: now, LastUpdated: now, } t.Run("steps up by target_temp_step", func(t *testing.T) { liveState := &driven.HAState{ EntityID: "climate.air_conditioner", State: "cool", Attributes: map[string]any{ "temperature": float64(21), "target_temp_step": float64(1), "min_temp": float64(7), "max_temp": float64(35), }, } app := NewClimateApp(&mockHAClient{ getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { return liveState, nil }, callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { if svcDomain != "climate" || service != "set_temperature" { t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) } wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(22)} if !reflect.DeepEqual(payload, wantPayload) { t.Fatalf("payload = %#v, want %#v", payload, wantPayload) } return []*driven.HAState{resultState}, nil }, }) if _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil { t.Fatalf("IncreaseTemperature() error = %v", err) } }) t.Run("step defaults to 1 when target_temp_step is 0", func(t *testing.T) { liveState := &driven.HAState{ EntityID: "climate.air_conditioner", State: "cool", Attributes: map[string]any{ "temperature": float64(21), "min_temp": float64(7), "max_temp": float64(35), }, } app := NewClimateApp(&mockHAClient{ getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { return liveState, nil }, callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(22)} if !reflect.DeepEqual(payload, wantPayload) { t.Fatalf("payload = %#v, want %#v", payload, wantPayload) } return []*driven.HAState{resultState}, nil }, }) if _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil { t.Fatalf("IncreaseTemperature() error = %v", err) } }) t.Run("clamps at max_temp", func(t *testing.T) { liveState := &driven.HAState{ EntityID: "climate.air_conditioner", State: "cool", Attributes: map[string]any{ "temperature": float64(35), "target_temp_step": float64(1), "min_temp": float64(7), "max_temp": float64(35), }, } app := NewClimateApp(&mockHAClient{ getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { return liveState, nil }, callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(35)} if !reflect.DeepEqual(payload, wantPayload) { t.Fatalf("payload = %#v, want %#v", payload, wantPayload) } return []*driven.HAState{resultState}, nil }, }) if _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil { t.Fatalf("IncreaseTemperature() error = %v", err) } }) t.Run("error when no target temperature is set", func(t *testing.T) { liveState := &driven.HAState{ EntityID: "climate.air_conditioner", State: "off", Attributes: map[string]any{}, } app := NewClimateApp(&mockHAClient{ getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { return liveState, nil }, }) _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner") if err == nil { t.Fatal("IncreaseTemperature() error = nil, want error") } }) t.Run("propagates GetState error", func(t *testing.T) { wantErr := errors.New("get state failed") app := NewClimateApp(&mockHAClient{ getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { return nil, wantErr }, }) _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner") if !errors.Is(err, wantErr) { t.Fatalf("IncreaseTemperature() error = %v, want %v", err, wantErr) } }) } func TestClimateAppDecreaseTemperature(t *testing.T) { now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) resultState := &driven.HAState{ EntityID: "climate.air_conditioner", State: "cool", LastChanged: now, LastUpdated: now, } t.Run("steps down by target_temp_step", func(t *testing.T) { liveState := &driven.HAState{ EntityID: "climate.air_conditioner", State: "cool", Attributes: map[string]any{ "temperature": float64(21), "target_temp_step": float64(1), "min_temp": float64(7), "max_temp": float64(35), }, } app := NewClimateApp(&mockHAClient{ getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { return liveState, nil }, callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { if svcDomain != "climate" || service != "set_temperature" { t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) } wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(20)} if !reflect.DeepEqual(payload, wantPayload) { t.Fatalf("payload = %#v, want %#v", payload, wantPayload) } return []*driven.HAState{resultState}, nil }, }) if _, err := app.DecreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil { t.Fatalf("DecreaseTemperature() error = %v", err) } }) t.Run("clamps at min_temp", func(t *testing.T) { liveState := &driven.HAState{ EntityID: "climate.air_conditioner", State: "cool", Attributes: map[string]any{ "temperature": float64(7), "target_temp_step": float64(1), "min_temp": float64(7), "max_temp": float64(35), }, } app := NewClimateApp(&mockHAClient{ getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { return liveState, nil }, callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(7)} if !reflect.DeepEqual(payload, wantPayload) { t.Fatalf("payload = %#v, want %#v", payload, wantPayload) } return []*driven.HAState{resultState}, nil }, }) if _, err := app.DecreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil { t.Fatalf("DecreaseTemperature() error = %v", err) } }) t.Run("error when no target temperature is set", func(t *testing.T) { liveState := &driven.HAState{ EntityID: "climate.air_conditioner", State: "off", Attributes: map[string]any{}, } app := NewClimateApp(&mockHAClient{ getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { return liveState, nil }, }) _, err := app.DecreaseTemperature(context.Background(), "climate.air_conditioner") if err == nil { t.Fatal("DecreaseTemperature() error = nil, want error") } }) }