package haclient import ( "context" "errors" "testing" hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" "google.golang.org/grpc" ) type fakeLightServiceClient struct { turnOnFunc func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) turnOffFunc func(ctx context.Context, in *hav1.TurnOffRequest) (*hav1.LightResponse, error) toggleFunc func(ctx context.Context, in *hav1.ToggleRequest) (*hav1.LightResponse, error) } func (f *fakeLightServiceClient) TurnOn(ctx context.Context, in *hav1.TurnOnRequest, _ ...grpc.CallOption) (*hav1.LightResponse, error) { if f.turnOnFunc == nil { return &hav1.LightResponse{}, nil } return f.turnOnFunc(ctx, in) } func (f *fakeLightServiceClient) TurnOff(ctx context.Context, in *hav1.TurnOffRequest, _ ...grpc.CallOption) (*hav1.LightResponse, error) { if f.turnOffFunc == nil { return &hav1.LightResponse{}, nil } return f.turnOffFunc(ctx, in) } func (f *fakeLightServiceClient) Toggle(ctx context.Context, in *hav1.ToggleRequest, _ ...grpc.CallOption) (*hav1.LightResponse, error) { if f.toggleFunc == nil { return &hav1.LightResponse{}, nil } return f.toggleFunc(ctx, in) } func (f *fakeLightServiceClient) ListLights(ctx context.Context, in *hav1.ListLightsRequest, _ ...grpc.CallOption) (*hav1.ListLightsResponse, error) { return &hav1.ListLightsResponse{}, nil } type fakeSwitchServiceClient struct { turnOnFunc func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) turnOffFunc func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) toggleFunc func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) } func (f *fakeSwitchServiceClient) TurnOn(ctx context.Context, in *hav1.SwitchRequest, _ ...grpc.CallOption) (*hav1.SwitchResponse, error) { if f.turnOnFunc == nil { return &hav1.SwitchResponse{}, nil } return f.turnOnFunc(ctx, in) } func (f *fakeSwitchServiceClient) TurnOff(ctx context.Context, in *hav1.SwitchRequest, _ ...grpc.CallOption) (*hav1.SwitchResponse, error) { if f.turnOffFunc == nil { return &hav1.SwitchResponse{}, nil } return f.turnOffFunc(ctx, in) } func (f *fakeSwitchServiceClient) Toggle(ctx context.Context, in *hav1.SwitchRequest, _ ...grpc.CallOption) (*hav1.SwitchResponse, error) { if f.toggleFunc == nil { return &hav1.SwitchResponse{}, nil } return f.toggleFunc(ctx, in) } func (f *fakeSwitchServiceClient) ListSwitches(ctx context.Context, in *hav1.ListSwitchesRequest, _ ...grpc.CallOption) (*hav1.ListSwitchesResponse, error) { return &hav1.ListSwitchesResponse{}, nil } type fakeClimateServiceClient struct { turnOnFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error) turnOffFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error) increaseTemperatureFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error) decreaseTemperatureFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error) setTemperatureFunc func(ctx context.Context, in *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error) setHVACModeFunc func(ctx context.Context, in *hav1.SetHVACModeRequest) (*hav1.ClimateResponse, error) } func (f *fakeClimateServiceClient) TurnOn(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) { if f.turnOnFunc == nil { return &hav1.ClimateResponse{}, nil } return f.turnOnFunc(ctx, in) } func (f *fakeClimateServiceClient) TurnOff(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) { if f.turnOffFunc == nil { return &hav1.ClimateResponse{}, nil } return f.turnOffFunc(ctx, in) } func (f *fakeClimateServiceClient) IncreaseTemperature(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) { if f.increaseTemperatureFunc == nil { return &hav1.ClimateResponse{}, nil } return f.increaseTemperatureFunc(ctx, in) } func (f *fakeClimateServiceClient) DecreaseTemperature(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) { if f.decreaseTemperatureFunc == nil { return &hav1.ClimateResponse{}, nil } return f.decreaseTemperatureFunc(ctx, in) } func (f *fakeClimateServiceClient) SetTemperature(ctx context.Context, in *hav1.SetTemperatureRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) { if f.setTemperatureFunc == nil { return &hav1.ClimateResponse{}, nil } return f.setTemperatureFunc(ctx, in) } func (f *fakeClimateServiceClient) SetHVACMode(ctx context.Context, in *hav1.SetHVACModeRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) { if f.setHVACModeFunc == nil { return &hav1.ClimateResponse{}, nil } return f.setHVACModeFunc(ctx, in) } func (f *fakeClimateServiceClient) ListClimates(ctx context.Context, in *hav1.ListClimatesRequest, _ ...grpc.CallOption) (*hav1.ListClimatesResponse, error) { return &hav1.ListClimatesResponse{}, nil } func TestExecuteActionLight(t *testing.T) { t.Run("turn_on forwards optional params", func(t *testing.T) { var got *hav1.TurnOnRequest c := &Client{lightClient: &fakeLightServiceClient{ turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) { got = in return &hav1.LightResponse{}, nil }, }} err := c.ExecuteAction(context.Background(), "light.living_room", "turn_on", map[string]any{ "brightness_pct": "80", "color_temp_kelvin": "2700", "rgb_color": map[string]any{"r": "255", "g": "0", "b": "0"}, }) if err != nil { t.Fatalf("ExecuteAction() error = %v", err) } if got.GetEntityId() != "light.living_room" { t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "light.living_room") } if got.GetBrightnessPct() != 80 { t.Fatalf("BrightnessPct = %v, want 80", got.GetBrightnessPct()) } if got.GetColorTempKelvin() != 2700 { t.Fatalf("ColorTempKelvin = %v, want 2700", got.GetColorTempKelvin()) } if got.GetRgbColor().GetR() != 255 { t.Fatalf("RgbColor.R = %v, want 255", got.GetRgbColor().GetR()) } }) t.Run("turn_on with no params sets no optional fields", func(t *testing.T) { var got *hav1.TurnOnRequest c := &Client{lightClient: &fakeLightServiceClient{ turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) { got = in return &hav1.LightResponse{}, nil }, }} if err := c.ExecuteAction(context.Background(), "light.living_room", "turn_on", nil); err != nil { t.Fatalf("ExecuteAction() error = %v", err) } if got.BrightnessPct != nil || got.ColorTempKelvin != nil || got.RgbColor != nil { t.Fatalf("expected no optional fields set, got %#v", got) } }) t.Run("turn_off forwards transition", func(t *testing.T) { var got *hav1.TurnOffRequest c := &Client{lightClient: &fakeLightServiceClient{ turnOffFunc: func(ctx context.Context, in *hav1.TurnOffRequest) (*hav1.LightResponse, error) { got = in return &hav1.LightResponse{}, nil }, }} if err := c.ExecuteAction(context.Background(), "light.living_room", "turn_off", map[string]any{"transition": "3"}); err != nil { t.Fatalf("ExecuteAction() error = %v", err) } if got.GetTransition() != 3 { t.Fatalf("Transition = %v, want 3", got.GetTransition()) } }) t.Run("toggle", func(t *testing.T) { var got *hav1.ToggleRequest c := &Client{lightClient: &fakeLightServiceClient{ toggleFunc: func(ctx context.Context, in *hav1.ToggleRequest) (*hav1.LightResponse, error) { got = in return &hav1.LightResponse{}, nil }, }} if err := c.ExecuteAction(context.Background(), "light.living_room", "toggle", nil); err != nil { t.Fatalf("ExecuteAction() error = %v", err) } if got.GetEntityId() != "light.living_room" { t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "light.living_room") } }) t.Run("set_brightness reuses TurnOn with only brightness set", func(t *testing.T) { var got *hav1.TurnOnRequest c := &Client{lightClient: &fakeLightServiceClient{ turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) { got = in return &hav1.LightResponse{}, nil }, }} if err := c.ExecuteAction(context.Background(), "light.living_room", "set_brightness", map[string]any{"brightness_pct": "50"}); err != nil { t.Fatalf("ExecuteAction() error = %v", err) } if got.GetBrightnessPct() != 50 { t.Fatalf("BrightnessPct = %v, want 50", got.GetBrightnessPct()) } if got.ColorTempKelvin != nil || got.RgbColor != nil { t.Fatalf("expected only brightness set, got %#v", got) } }) t.Run("set_brightness missing param errors without calling RPC", func(t *testing.T) { called := false c := &Client{lightClient: &fakeLightServiceClient{ turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) { called = true return &hav1.LightResponse{}, nil }, }} err := c.ExecuteAction(context.Background(), "light.living_room", "set_brightness", nil) if err == nil { t.Fatal("ExecuteAction() error = nil, want error") } if called { t.Fatal("TurnOn should not be called when the required param is missing") } }) t.Run("set_color_temp", func(t *testing.T) { var got *hav1.TurnOnRequest c := &Client{lightClient: &fakeLightServiceClient{ turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) { got = in return &hav1.LightResponse{}, nil }, }} if err := c.ExecuteAction(context.Background(), "light.living_room", "set_color_temp", map[string]any{"color_temp_kelvin": "4000"}); err != nil { t.Fatalf("ExecuteAction() error = %v", err) } if got.GetColorTempKelvin() != 4000 { t.Fatalf("ColorTempKelvin = %v, want 4000", got.GetColorTempKelvin()) } }) t.Run("set_color", func(t *testing.T) { var got *hav1.TurnOnRequest c := &Client{lightClient: &fakeLightServiceClient{ turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) { got = in return &hav1.LightResponse{}, nil }, }} err := c.ExecuteAction(context.Background(), "light.living_room", "set_color", map[string]any{ "rgb_color": map[string]any{"r": "10", "g": "20", "b": "30"}, }) if err != nil { t.Fatalf("ExecuteAction() error = %v", err) } if got.GetRgbColor().GetR() != 10 || got.GetRgbColor().GetG() != 20 || got.GetRgbColor().GetB() != 30 { t.Fatalf("RgbColor = %#v, want (10,20,30)", got.GetRgbColor()) } }) t.Run("unsupported action", func(t *testing.T) { c := &Client{lightClient: &fakeLightServiceClient{}} if err := c.ExecuteAction(context.Background(), "light.living_room", "explode", nil); err == nil { t.Fatal("ExecuteAction() error = nil, want error") } }) t.Run("propagates RPC error", func(t *testing.T) { wantErr := errors.New("boom") c := &Client{lightClient: &fakeLightServiceClient{ turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) { return nil, wantErr }, }} err := c.ExecuteAction(context.Background(), "light.living_room", "turn_on", nil) if !errors.Is(err, wantErr) { t.Fatalf("ExecuteAction() error = %v, want %v", err, wantErr) } }) } func TestExecuteActionSwitch(t *testing.T) { tests := []struct { action string }{{"turn_on"}, {"turn_off"}, {"toggle"}} for _, tt := range tests { t.Run(tt.action, func(t *testing.T) { var gotOn, gotOff, gotToggle *hav1.SwitchRequest c := &Client{switchClient: &fakeSwitchServiceClient{ turnOnFunc: func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) { gotOn = in return &hav1.SwitchResponse{}, nil }, turnOffFunc: func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) { gotOff = in return &hav1.SwitchResponse{}, nil }, toggleFunc: func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) { gotToggle = in return &hav1.SwitchResponse{}, nil }, }} if err := c.ExecuteAction(context.Background(), "switch.fan", tt.action, nil); err != nil { t.Fatalf("ExecuteAction() error = %v", err) } for _, got := range []*hav1.SwitchRequest{gotOn, gotOff, gotToggle} { if got != nil && got.GetEntityId() != "switch.fan" { t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "switch.fan") } } }) } t.Run("unsupported action", func(t *testing.T) { c := &Client{switchClient: &fakeSwitchServiceClient{}} if err := c.ExecuteAction(context.Background(), "switch.fan", "explode", nil); err == nil { t.Fatal("ExecuteAction() error = nil, want error") } }) } func TestExecuteActionClimate(t *testing.T) { t.Run("set_hvac_mode", func(t *testing.T) { var got *hav1.SetHVACModeRequest c := &Client{climateClient: &fakeClimateServiceClient{ setHVACModeFunc: func(ctx context.Context, in *hav1.SetHVACModeRequest) (*hav1.ClimateResponse, error) { got = in return &hav1.ClimateResponse{}, nil }, }} if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_hvac_mode", map[string]any{"hvac_mode": "cool"}); err != nil { t.Fatalf("ExecuteAction() error = %v", err) } if got.GetHvacMode() != "cool" { t.Fatalf("HvacMode = %q, want %q", got.GetHvacMode(), "cool") } }) t.Run("set_hvac_mode missing param errors", func(t *testing.T) { c := &Client{climateClient: &fakeClimateServiceClient{}} if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_hvac_mode", nil); err == nil { t.Fatal("ExecuteAction() error = nil, want error") } }) t.Run("increase_temperature", func(t *testing.T) { var got *hav1.ClimateRequest c := &Client{climateClient: &fakeClimateServiceClient{ increaseTemperatureFunc: func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error) { got = in return &hav1.ClimateResponse{}, nil }, }} if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "increase_temperature", nil); err != nil { t.Fatalf("ExecuteAction() error = %v", err) } if got.GetEntityId() != "climate.air_conditioner" { t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "climate.air_conditioner") } }) t.Run("decrease_temperature", func(t *testing.T) { called := false c := &Client{climateClient: &fakeClimateServiceClient{ decreaseTemperatureFunc: func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error) { called = true return &hav1.ClimateResponse{}, nil }, }} if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "decrease_temperature", nil); err != nil { t.Fatalf("ExecuteAction() error = %v", err) } if !called { t.Fatal("DecreaseTemperature was not called") } }) t.Run("set_temperature forwards absolute target", func(t *testing.T) { var got *hav1.SetTemperatureRequest c := &Client{climateClient: &fakeClimateServiceClient{ setTemperatureFunc: func(ctx context.Context, in *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error) { got = in return &hav1.ClimateResponse{}, nil }, }} if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_temperature", map[string]any{"target_temperature": "23.5"}); err != nil { t.Fatalf("ExecuteAction() error = %v", err) } if got.GetEntityId() != "climate.air_conditioner" || got.GetTargetTemperature() != 23.5 { t.Fatalf("SetTemperatureRequest = %#v, want entity=climate.air_conditioner target=23.5", got) } }) t.Run("set_temperature missing param errors without calling RPC", func(t *testing.T) { called := false c := &Client{climateClient: &fakeClimateServiceClient{ setTemperatureFunc: func(ctx context.Context, in *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error) { called = true return &hav1.ClimateResponse{}, nil }, }} err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_temperature", nil) if err == nil { t.Fatal("ExecuteAction() error = nil, want error") } if called { t.Fatal("SetTemperature should not be called when the required param is missing") } }) t.Run("unsupported action", func(t *testing.T) { c := &Client{climateClient: &fakeClimateServiceClient{}} if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "explode", nil); err == nil { t.Fatal("ExecuteAction() error = nil, want error") } }) } func TestExecuteActionDomainDispatch(t *testing.T) { t.Run("unsupported domain", func(t *testing.T) { c := &Client{} if err := c.ExecuteAction(context.Background(), "sensor.temp", "turn_on", nil); err == nil { t.Fatal("ExecuteAction() error = nil, want error") } }) t.Run("entity id with no domain prefix", func(t *testing.T) { c := &Client{} if err := c.ExecuteAction(context.Background(), "no-domain-here", "turn_on", nil); err == nil { t.Fatal("ExecuteAction() error = nil, want error") } }) }