Nik Afiq b327150d45
All checks were successful
CI / test (push) Successful in 5s
CI / build-ai-gateway (push) Successful in 39s
CI / build-ha-gateway (push) Successful in 39s
CI / build-discord-bot (push) Successful in 40s
feat: implement ClimateApp for managing HVAC entities
- Added ClimateApp to handle climate entity operations including turning on/off, adjusting temperature, and setting HVAC modes.
- Implemented caching mechanism for climate entities to optimize state retrieval.
- Created domain model for Climate with attributes such as current temperature, target temperature, and HVAC modes.
- Developed unit tests for ClimateApp to ensure functionality and correctness.

feat: add RemoteApp for SwitchBot commands

- Introduced RemoteApp to facilitate sending commands to SwitchBot devices.
- Implemented SendCommand method for executing device commands without state management.

chore: update configuration for SwitchBot integration

- Added SwitchBotToken and SwitchBotSecret to configuration for enabling SwitchBot Cloud commands.

feat: define gRPC services for Climate and Remote operations

- Created climate.proto and remote.proto files to define gRPC services for climate management and remote command execution.
- Implemented corresponding request and response message structures for gRPC interactions.
2026-07-23 13:49:54 +09:00

1110 lines
30 KiB
Go

package app
import (
"context"
"errors"
"reflect"
"testing"
"time"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/modelstore"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/modelvalidator"
)
type mockHAGateway struct {
listLightsFunc func(ctx context.Context) ([]driven.Light, error)
listSwitchesFunc func(ctx context.Context) ([]driven.Switch, error)
turnOnLightFunc func(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error
turnOffLightFunc func(ctx context.Context, entityID string, transition *uint32) error
toggleLightFunc func(ctx context.Context, entityID string) error
turnOnSwitchFunc func(ctx context.Context, entityID string) error
turnOffSwitchFunc func(ctx context.Context, entityID string) error
toggleSwitchFunc func(ctx context.Context, entityID string) error
listClimatesFunc func(ctx context.Context) ([]driven.Climate, error)
turnOnClimateFunc func(ctx context.Context, entityID string) error
turnOffClimateFunc func(ctx context.Context, entityID string) error
setClimateHVACModeFunc func(ctx context.Context, entityID, hvacMode string) error
increaseClimateTemperatureFunc func(ctx context.Context, entityID string) error
decreaseClimateTemperatureFunc func(ctx context.Context, entityID string) error
}
type mockAIGateway struct {
queryFunc func(ctx context.Context, text, model string) (string, string, error)
listModelsFunc func(ctx context.Context) ([]string, error)
}
func (m *mockHAGateway) ListLights(ctx context.Context) ([]driven.Light, error) {
if m.listLightsFunc == nil {
return nil, nil
}
return m.listLightsFunc(ctx)
}
func (m *mockHAGateway) ListSwitches(ctx context.Context) ([]driven.Switch, error) {
if m.listSwitchesFunc == nil {
return nil, nil
}
return m.listSwitchesFunc(ctx)
}
func (m *mockHAGateway) TurnOnLight(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error {
if m.turnOnLightFunc == nil {
return nil
}
return m.turnOnLightFunc(ctx, entityID, brightnessPct, colorTempKelvin)
}
func (m *mockHAGateway) TurnOffLight(ctx context.Context, entityID string, transition *uint32) error {
if m.turnOffLightFunc == nil {
return nil
}
return m.turnOffLightFunc(ctx, entityID, transition)
}
func (m *mockHAGateway) ToggleLight(ctx context.Context, entityID string) error {
if m.toggleLightFunc == nil {
return nil
}
return m.toggleLightFunc(ctx, entityID)
}
func (m *mockHAGateway) TurnOnSwitch(ctx context.Context, entityID string) error {
if m.turnOnSwitchFunc == nil {
return nil
}
return m.turnOnSwitchFunc(ctx, entityID)
}
func (m *mockHAGateway) TurnOffSwitch(ctx context.Context, entityID string) error {
if m.turnOffSwitchFunc == nil {
return nil
}
return m.turnOffSwitchFunc(ctx, entityID)
}
func (m *mockHAGateway) ToggleSwitch(ctx context.Context, entityID string) error {
if m.toggleSwitchFunc == nil {
return nil
}
return m.toggleSwitchFunc(ctx, entityID)
}
func (m *mockHAGateway) ListClimates(ctx context.Context) ([]driven.Climate, error) {
if m.listClimatesFunc == nil {
return nil, nil
}
return m.listClimatesFunc(ctx)
}
func (m *mockHAGateway) TurnOnClimate(ctx context.Context, entityID string) error {
if m.turnOnClimateFunc == nil {
return nil
}
return m.turnOnClimateFunc(ctx, entityID)
}
func (m *mockHAGateway) TurnOffClimate(ctx context.Context, entityID string) error {
if m.turnOffClimateFunc == nil {
return nil
}
return m.turnOffClimateFunc(ctx, entityID)
}
func (m *mockHAGateway) SetClimateHVACMode(ctx context.Context, entityID, hvacMode string) error {
if m.setClimateHVACModeFunc == nil {
return nil
}
return m.setClimateHVACModeFunc(ctx, entityID, hvacMode)
}
func (m *mockHAGateway) IncreaseClimateTemperature(ctx context.Context, entityID string) error {
if m.increaseClimateTemperatureFunc == nil {
return nil
}
return m.increaseClimateTemperatureFunc(ctx, entityID)
}
func (m *mockHAGateway) DecreaseClimateTemperature(ctx context.Context, entityID string) error {
if m.decreaseClimateTemperatureFunc == nil {
return nil
}
return m.decreaseClimateTemperatureFunc(ctx, entityID)
}
func (m *mockAIGateway) Query(ctx context.Context, text, model string) (string, string, error) {
if m.queryFunc == nil {
return "", "", nil
}
return m.queryFunc(ctx, text, model)
}
func (m *mockAIGateway) ListModels(ctx context.Context) ([]string, error) {
if m.listModelsFunc == nil {
return nil, nil
}
return m.listModelsFunc(ctx)
}
func newTestCommandApp(ha *mockHAGateway, ai *mockAIGateway) *CommandApp {
return NewCommandApp(ha, ai, modelstore.New(), modelvalidator.New(ai, time.Minute))
}
func TestCommandAppHandleLightList(t *testing.T) {
tests := []struct {
name string
lights []driven.Light
want string
}{
{
name: "empty list",
want: "No lights found.",
},
{
name: "single light with full data",
lights: []driven.Light{
{
EntityID: "light.kitchen",
FriendlyName: "Kitchen",
State: "on",
SupportedColorModes: []string{"brightness", "color_temp"},
MinColorTempKelvin: 2700,
MaxColorTempKelvin: 6500,
IsHueGroup: true,
},
},
want: "```text\n🟢 Kitchen on brightness,color_temp 2700-6500K hue-group\n```",
},
{
name: "empty friendly name uses entity id and off emoji",
lights: []driven.Light{
{
EntityID: "light.desk",
State: "off",
},
},
want: "```text\n🔴 light.desk off -\n```",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := newTestCommandApp(&mockHAGateway{
listLightsFunc: func(ctx context.Context) ([]driven.Light, error) {
return tt.lights, nil
},
}, &mockAIGateway{})
got, err := app.HandleLightList(context.Background())
if err != nil {
t.Fatalf("HandleLightList() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleLightList() = %q, want %q", got, tt.want)
}
})
}
}
func TestCommandAppHandleLightOn(t *testing.T) {
brightness := uint32(55)
colorTemp := uint32(3200)
tests := []struct {
name string
lights []driven.Light
entityID string
brightnessPct *uint32
colorTempKelvin *uint32
turnOnErr error
listErr error
want string
wantErr string
}{
{
name: "no optional params",
lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}},
entityID: "light.kitchen",
want: "Turned on `Kitchen`.",
},
{
name: "brightness only",
lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}},
entityID: "light.kitchen",
brightnessPct: &brightness,
want: "Turned on `Kitchen` (brightness 55%).",
},
{
name: "color temp only",
lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}},
entityID: "light.kitchen",
colorTempKelvin: &colorTemp,
want: "Turned on `Kitchen` (3200K).",
},
{
name: "both params",
lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}},
entityID: "light.kitchen",
brightnessPct: &brightness,
colorTempKelvin: &colorTemp,
want: "Turned on `Kitchen` (brightness 55%, 3200K).",
},
{
name: "light not found falls back to entity id",
lights: []driven.Light{{EntityID: "light.other", FriendlyName: "Other"}},
entityID: "light.kitchen",
want: "Turned on `light.kitchen`.",
},
{
name: "TurnOnLight error",
lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}},
entityID: "light.kitchen",
turnOnErr: errors.New("boom"),
wantErr: "handle light on: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotEntityID string
var gotBrightness *uint32
var gotColorTemp *uint32
app := newTestCommandApp(&mockHAGateway{
listLightsFunc: func(ctx context.Context) ([]driven.Light, error) {
if tt.listErr != nil {
return nil, tt.listErr
}
return tt.lights, nil
},
turnOnLightFunc: func(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error {
gotEntityID = entityID
gotBrightness = brightnessPct
gotColorTemp = colorTempKelvin
return tt.turnOnErr
},
}, &mockAIGateway{})
got, err := app.HandleLightOn(context.Background(), tt.entityID, tt.brightnessPct, tt.colorTempKelvin)
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("HandleLightOn() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("HandleLightOn() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleLightOn() = %q, want %q", got, tt.want)
}
if gotEntityID != tt.entityID {
t.Fatalf("TurnOnLight entityID = %q, want %q", gotEntityID, tt.entityID)
}
if !reflect.DeepEqual(gotBrightness, tt.brightnessPct) || !reflect.DeepEqual(gotColorTemp, tt.colorTempKelvin) {
t.Fatalf("TurnOnLight params = (%v, %v), want (%v, %v)", gotBrightness, gotColorTemp, tt.brightnessPct, tt.colorTempKelvin)
}
})
}
}
func TestCommandAppHandleLightOff(t *testing.T) {
transition := uint32(4)
tests := []struct {
name string
entityID string
lights []driven.Light
transition *uint32
turnOffErr error
want string
wantErr string
}{
{
name: "no transition",
entityID: "light.kitchen",
lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}},
want: "Turned off `Kitchen`.",
},
{
name: "with transition",
entityID: "light.kitchen",
lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}},
transition: &transition,
want: "Turned off `Kitchen` with 4s transition.",
},
{
name: "error",
entityID: "light.kitchen",
lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}},
turnOffErr: errors.New("boom"),
wantErr: "handle light off: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotTransition *uint32
app := newTestCommandApp(&mockHAGateway{
listLightsFunc: func(ctx context.Context) ([]driven.Light, error) {
return tt.lights, nil
},
turnOffLightFunc: func(ctx context.Context, entityID string, transition *uint32) error {
gotTransition = transition
return tt.turnOffErr
},
}, &mockAIGateway{})
got, err := app.HandleLightOff(context.Background(), tt.entityID, tt.transition)
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("HandleLightOff() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("HandleLightOff() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleLightOff() = %q, want %q", got, tt.want)
}
if !reflect.DeepEqual(gotTransition, tt.transition) {
t.Fatalf("TurnOffLight transition = %v, want %v", gotTransition, tt.transition)
}
})
}
}
func TestCommandAppHandleLightToggle(t *testing.T) {
tests := []struct {
name string
lights []driven.Light
listErr error
toggleErr error
want string
wantErr string
}{
{
name: "happy path",
lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}},
want: "Toggled `Kitchen`.",
},
{
name: "lookup error",
listErr: errors.New("lookup failed"),
wantErr: "lookup light name: list lights: lookup failed",
},
{
name: "toggle error",
lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen"}},
toggleErr: errors.New("boom"),
wantErr: "handle light toggle: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := newTestCommandApp(&mockHAGateway{
listLightsFunc: func(ctx context.Context) ([]driven.Light, error) {
if tt.listErr != nil {
return nil, tt.listErr
}
return tt.lights, nil
},
toggleLightFunc: func(ctx context.Context, entityID string) error {
return tt.toggleErr
},
}, &mockAIGateway{})
got, err := app.HandleLightToggle(context.Background(), "light.kitchen")
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("HandleLightToggle() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("HandleLightToggle() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleLightToggle() = %q, want %q", got, tt.want)
}
})
}
}
func TestCommandAppHandleSwitchList(t *testing.T) {
tests := []struct {
name string
switches []driven.Switch
want string
}{
{
name: "empty",
want: "No switches found.",
},
{
name: "with switches",
switches: []driven.Switch{
{EntityID: "switch.fan", FriendlyName: "Fan", State: "on", DeviceClass: "outlet"},
{EntityID: "switch.pump", State: "off"},
},
want: "```text\n🟢 Fan on outlet\n🔴 switch.pump off switch\n```",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := newTestCommandApp(&mockHAGateway{
listSwitchesFunc: func(ctx context.Context) ([]driven.Switch, error) {
return tt.switches, nil
},
}, &mockAIGateway{})
got, err := app.HandleSwitchList(context.Background())
if err != nil {
t.Fatalf("HandleSwitchList() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleSwitchList() = %q, want %q", got, tt.want)
}
})
}
}
func TestCommandAppHandleSwitchOn(t *testing.T) {
tests := []struct {
name string
switches []driven.Switch
entityID string
turnOnErr error
listErr error
want string
wantErr string
}{
{
name: "happy path",
switches: []driven.Switch{{EntityID: "switch.fan", FriendlyName: "Fan"}},
entityID: "switch.fan",
want: "Turned on `Fan`.",
},
{
name: "switch not found falls back to entity id",
switches: []driven.Switch{{EntityID: "switch.other", FriendlyName: "Other"}},
entityID: "switch.fan",
want: "Turned on `switch.fan`.",
},
{
name: "TurnOnSwitch error",
switches: []driven.Switch{{EntityID: "switch.fan", FriendlyName: "Fan"}},
entityID: "switch.fan",
turnOnErr: errors.New("boom"),
wantErr: "handle switch on: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotEntityID string
app := newTestCommandApp(&mockHAGateway{
listSwitchesFunc: func(ctx context.Context) ([]driven.Switch, error) {
if tt.listErr != nil {
return nil, tt.listErr
}
return tt.switches, nil
},
turnOnSwitchFunc: func(ctx context.Context, entityID string) error {
gotEntityID = entityID
return tt.turnOnErr
},
}, &mockAIGateway{})
got, err := app.HandleSwitchOn(context.Background(), tt.entityID)
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("HandleSwitchOn() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("HandleSwitchOn() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleSwitchOn() = %q, want %q", got, tt.want)
}
if gotEntityID != tt.entityID {
t.Fatalf("TurnOnSwitch entityID = %q, want %q", gotEntityID, tt.entityID)
}
})
}
}
func TestCommandAppHandleSwitchOff(t *testing.T) {
tests := []struct {
name string
switches []driven.Switch
entityID string
turnOffErr error
want string
wantErr string
}{
{
name: "happy path",
switches: []driven.Switch{{EntityID: "switch.fan", FriendlyName: "Fan"}},
entityID: "switch.fan",
want: "Turned off `Fan`.",
},
{
name: "TurnOffSwitch error",
switches: []driven.Switch{{EntityID: "switch.fan", FriendlyName: "Fan"}},
entityID: "switch.fan",
turnOffErr: errors.New("boom"),
wantErr: "handle switch off: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := newTestCommandApp(&mockHAGateway{
listSwitchesFunc: func(ctx context.Context) ([]driven.Switch, error) {
return tt.switches, nil
},
turnOffSwitchFunc: func(ctx context.Context, entityID string) error {
return tt.turnOffErr
},
}, &mockAIGateway{})
got, err := app.HandleSwitchOff(context.Background(), tt.entityID)
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("HandleSwitchOff() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("HandleSwitchOff() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleSwitchOff() = %q, want %q", got, tt.want)
}
})
}
}
func TestCommandAppHandleSwitchToggle(t *testing.T) {
tests := []struct {
name string
switches []driven.Switch
listErr error
toggleErr error
want string
wantErr string
}{
{
name: "happy path",
switches: []driven.Switch{{EntityID: "switch.fan", FriendlyName: "Fan"}},
want: "Toggled `Fan`.",
},
{
name: "lookup error",
listErr: errors.New("lookup failed"),
wantErr: "lookup switch name: list switches: lookup failed",
},
{
name: "toggle error",
switches: []driven.Switch{{EntityID: "switch.fan", FriendlyName: "Fan"}},
toggleErr: errors.New("boom"),
wantErr: "handle switch toggle: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := newTestCommandApp(&mockHAGateway{
listSwitchesFunc: func(ctx context.Context) ([]driven.Switch, error) {
if tt.listErr != nil {
return nil, tt.listErr
}
return tt.switches, nil
},
toggleSwitchFunc: func(ctx context.Context, entityID string) error {
return tt.toggleErr
},
}, &mockAIGateway{})
got, err := app.HandleSwitchToggle(context.Background(), "switch.fan")
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("HandleSwitchToggle() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("HandleSwitchToggle() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleSwitchToggle() = %q, want %q", got, tt.want)
}
})
}
}
func TestCommandAppHandleACOn(t *testing.T) {
tests := []struct {
name string
climates []driven.Climate
entityID string
turnOnErr error
want string
wantErr string
}{
{
name: "happy path",
climates: []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}},
entityID: "climate.air_conditioner",
want: "Turned on `AC`.",
},
{
name: "climate not found falls back to entity id",
climates: []driven.Climate{{EntityID: "climate.other", FriendlyName: "Other"}},
entityID: "climate.air_conditioner",
want: "Turned on `climate.air_conditioner`.",
},
{
name: "TurnOnClimate error",
climates: []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}},
entityID: "climate.air_conditioner",
turnOnErr: errors.New("boom"),
wantErr: "handle ac on: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotEntityID string
app := newTestCommandApp(&mockHAGateway{
listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) {
return tt.climates, nil
},
turnOnClimateFunc: func(ctx context.Context, entityID string) error {
gotEntityID = entityID
return tt.turnOnErr
},
}, &mockAIGateway{})
got, err := app.HandleACOn(context.Background(), tt.entityID)
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("HandleACOn() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("HandleACOn() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleACOn() = %q, want %q", got, tt.want)
}
if gotEntityID != tt.entityID {
t.Fatalf("TurnOnClimate entityID = %q, want %q", gotEntityID, tt.entityID)
}
})
}
}
func TestCommandAppHandleACOff(t *testing.T) {
tests := []struct {
name string
climates []driven.Climate
turnOffErr error
want string
wantErr string
}{
{
name: "happy path",
climates: []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}},
want: "Turned off `AC`.",
},
{
name: "TurnOffClimate error",
climates: []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}},
turnOffErr: errors.New("boom"),
wantErr: "handle ac off: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := newTestCommandApp(&mockHAGateway{
listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) {
return tt.climates, nil
},
turnOffClimateFunc: func(ctx context.Context, entityID string) error {
return tt.turnOffErr
},
}, &mockAIGateway{})
got, err := app.HandleACOff(context.Background(), "climate.air_conditioner")
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("HandleACOff() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("HandleACOff() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleACOff() = %q, want %q", got, tt.want)
}
})
}
}
func TestCommandAppHandleACMode(t *testing.T) {
tests := []struct {
name string
modeErr error
want string
wantErr string
}{
{
name: "happy path passes raw mode through",
want: "Set `AC` mode to `cool`.",
},
{
name: "SetClimateHVACMode error",
modeErr: errors.New("boom"),
wantErr: "handle ac mode: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotEntityID, gotMode string
app := newTestCommandApp(&mockHAGateway{
listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) {
return []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}}, nil
},
setClimateHVACModeFunc: func(ctx context.Context, entityID, hvacMode string) error {
gotEntityID = entityID
gotMode = hvacMode
return tt.modeErr
},
}, &mockAIGateway{})
got, err := app.HandleACMode(context.Background(), "climate.air_conditioner", "cool")
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("HandleACMode() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("HandleACMode() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleACMode() = %q, want %q", got, tt.want)
}
if gotEntityID != "climate.air_conditioner" || gotMode != "cool" {
t.Fatalf("SetClimateHVACMode entityID/mode = %q/%q, want %q/%q", gotEntityID, gotMode, "climate.air_conditioner", "cool")
}
})
}
}
func TestCommandAppHandleACTempUp(t *testing.T) {
tests := []struct {
name string
stepErr error
want string
wantErr string
}{
{
name: "happy path",
want: "Increased `AC` target temperature.",
},
{
name: "IncreaseClimateTemperature error",
stepErr: errors.New("boom"),
wantErr: "handle ac temp up: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := newTestCommandApp(&mockHAGateway{
listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) {
return []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}}, nil
},
increaseClimateTemperatureFunc: func(ctx context.Context, entityID string) error {
return tt.stepErr
},
}, &mockAIGateway{})
got, err := app.HandleACTempUp(context.Background(), "climate.air_conditioner")
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("HandleACTempUp() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("HandleACTempUp() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleACTempUp() = %q, want %q", got, tt.want)
}
})
}
}
func TestCommandAppHandleACTempDown(t *testing.T) {
tests := []struct {
name string
stepErr error
want string
wantErr string
}{
{
name: "happy path",
want: "Decreased `AC` target temperature.",
},
{
name: "DecreaseClimateTemperature error",
stepErr: errors.New("boom"),
wantErr: "handle ac temp down: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := newTestCommandApp(&mockHAGateway{
listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) {
return []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}}, nil
},
decreaseClimateTemperatureFunc: func(ctx context.Context, entityID string) error {
return tt.stepErr
},
}, &mockAIGateway{})
got, err := app.HandleACTempDown(context.Background(), "climate.air_conditioner")
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("HandleACTempDown() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("HandleACTempDown() error = %v", err)
}
if got != tt.want {
t.Fatalf("HandleACTempDown() = %q, want %q", got, tt.want)
}
})
}
}
func TestCommandAppAutocompleteLights(t *testing.T) {
tests := []struct {
name string
lights []driven.Light
listErr error
want []Choice
wantErr string
}{
{
name: "friendly name and fallback",
lights: []driven.Light{
{EntityID: "light.kitchen", FriendlyName: "Kitchen"},
{EntityID: "light.desk"},
},
want: []Choice{
{Label: "Kitchen", Value: "light.kitchen"},
{Label: "light.desk", Value: "light.desk"},
},
},
{
name: "ListLights error",
listErr: errors.New("boom"),
wantErr: "autocomplete lights: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := newTestCommandApp(&mockHAGateway{
listLightsFunc: func(ctx context.Context) ([]driven.Light, error) {
if tt.listErr != nil {
return nil, tt.listErr
}
return tt.lights, nil
},
}, &mockAIGateway{})
got, err := app.AutocompleteLights(context.Background())
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("AutocompleteLights() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("AutocompleteLights() error = %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("AutocompleteLights() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestCommandAppAutocompleteSwitches(t *testing.T) {
tests := []struct {
name string
switches []driven.Switch
listErr error
want []Choice
wantErr string
}{
{
name: "friendly name and fallback",
switches: []driven.Switch{
{EntityID: "switch.fan", FriendlyName: "Fan"},
{EntityID: "switch.pump"},
},
want: []Choice{
{Label: "Fan", Value: "switch.fan"},
{Label: "switch.pump", Value: "switch.pump"},
},
},
{
name: "ListSwitches error",
listErr: errors.New("boom"),
wantErr: "autocomplete switches: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := newTestCommandApp(&mockHAGateway{
listSwitchesFunc: func(ctx context.Context) ([]driven.Switch, error) {
if tt.listErr != nil {
return nil, tt.listErr
}
return tt.switches, nil
},
}, &mockAIGateway{})
got, err := app.AutocompleteSwitches(context.Background())
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("AutocompleteSwitches() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("AutocompleteSwitches() error = %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("AutocompleteSwitches() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestCommandAppAutocompleteClimates(t *testing.T) {
tests := []struct {
name string
climates []driven.Climate
listErr error
want []Choice
wantErr string
}{
{
name: "friendly name and fallback",
climates: []driven.Climate{
{EntityID: "climate.air_conditioner", FriendlyName: "AC"},
{EntityID: "climate.bedroom"},
},
want: []Choice{
{Label: "AC", Value: "climate.air_conditioner"},
{Label: "climate.bedroom", Value: "climate.bedroom"},
},
},
{
name: "ListClimates error",
listErr: errors.New("boom"),
wantErr: "autocomplete climates: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := newTestCommandApp(&mockHAGateway{
listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) {
if tt.listErr != nil {
return nil, tt.listErr
}
return tt.climates, nil
},
}, &mockAIGateway{})
got, err := app.AutocompleteClimates(context.Background())
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("AutocompleteClimates() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("AutocompleteClimates() error = %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("AutocompleteClimates() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestCommandAppHandleAIQuery(t *testing.T) {
store := modelstore.New()
store.Set("llama3:latest")
app := NewCommandApp(&mockHAGateway{}, &mockAIGateway{
queryFunc: func(ctx context.Context, text, model string) (string, string, error) {
if text != "turn on kitchen" {
t.Fatalf("Query() text = %q", text)
}
if model != "llama3:latest" {
t.Fatalf("Query() model = %q", model)
}
return "Turning on Kitchen.", "llama3:latest", nil
},
}, store, modelvalidator.New(&mockAIGateway{}, time.Minute))
got, err := app.HandleAIQuery(context.Background(), "turn on kitchen")
if err != nil {
t.Fatalf("HandleAIQuery() error = %v", err)
}
if got != "Turning on Kitchen.\n\n_(via llama3:latest)_" {
t.Fatalf("HandleAIQuery() = %q", got)
}
}
func TestCommandAppHandleAIModelSet(t *testing.T) {
app := newTestCommandApp(&mockHAGateway{}, &mockAIGateway{
listModelsFunc: func(ctx context.Context) ([]string, error) {
return []string{"llama3:latest"}, nil
},
})
got, err := app.HandleAIModelSet(context.Background(), "llama3")
if err != nil {
t.Fatalf("HandleAIModelSet() error = %v", err)
}
if got != "Active model set to `llama3:latest`." {
t.Fatalf("HandleAIModelSet() = %q", got)
}
}