Nik Afiq 0d58e46740
All checks were successful
CI / changes (push) Successful in 1s
CI / test (push) Successful in 6s
CI / build-ai-gateway (push) Has been skipped
CI / build-ha-gateway (push) Has been skipped
CI / build-discord-bot (push) Successful in 1m33s
CI / build-tts-gateway (push) Successful in 37s
CI / build-tts-sidecar (push) Has been skipped
feat: add /speak command for TTS integration
- Implemented the /speak command in Discord bot to synthesize speech using the TTS gateway.
- Added voice handling logic to join voice channels and play synthesized audio.
- Created tests for the new command and voice functionalities.
- Introduced TTSGateway interface for TTS service communication.
- Updated configuration to include TTS gateway address.
- Documented the TTS gateway integration and model artifact distribution process.
2026-07-25 02:09:02 +09:00

1239 lines
34 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)
}
type mockTTSGateway struct {
synthesizeFunc func(ctx context.Context, speakerName, text string) ([]byte, string, error)
listSpeakersFunc func(ctx context.Context, search string) ([]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 (m *mockTTSGateway) Synthesize(ctx context.Context, speakerName, text string) ([]byte, string, error) {
if m.synthesizeFunc == nil {
return nil, "", nil
}
return m.synthesizeFunc(ctx, speakerName, text)
}
func (m *mockTTSGateway) ListSpeakers(ctx context.Context, search string) ([]string, error) {
if m.listSpeakersFunc == nil {
return nil, nil
}
return m.listSpeakersFunc(ctx, search)
}
func newTestCommandApp(ha *mockHAGateway, ai *mockAIGateway) *CommandApp {
return NewCommandApp(ha, ai, modelstore.New(), modelvalidator.New(ai, time.Minute), &mockTTSGateway{})
}
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), &mockTTSGateway{})
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 TestCommandAppHandleSpeak(t *testing.T) {
tests := []struct {
name string
speakerName string
text string
audio []byte
mimeType string
synthErr error
wantErr string
}{
{
name: "synthesize success",
speakerName: "Rice Shower",
text: "おはようございます",
audio: []byte{0x00, 0x01, 0x02},
mimeType: "audio/aac",
},
{
name: "synthesize error propagated",
speakerName: "Unknown Speaker",
text: "hello",
synthErr: errors.New("boom"),
wantErr: "handle speak: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotSpeaker, gotText string
app := NewCommandApp(&mockHAGateway{}, &mockAIGateway{}, modelstore.New(), modelvalidator.New(&mockAIGateway{}, time.Minute), &mockTTSGateway{
synthesizeFunc: func(ctx context.Context, speakerName, text string) ([]byte, string, error) {
gotSpeaker, gotText = speakerName, text
if tt.synthErr != nil {
return nil, "", tt.synthErr
}
return tt.audio, tt.mimeType, nil
},
})
audio, mimeType, err := app.HandleSpeak(context.Background(), tt.speakerName, tt.text)
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("HandleSpeak() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("HandleSpeak() error = %v", err)
}
if gotSpeaker != tt.speakerName || gotText != tt.text {
t.Fatalf("Synthesize() called with (%q, %q), want (%q, %q)", gotSpeaker, gotText, tt.speakerName, tt.text)
}
if !reflect.DeepEqual(audio, tt.audio) || mimeType != tt.mimeType {
t.Fatalf("HandleSpeak() = (%#v, %q), want (%#v, %q)", audio, mimeType, tt.audio, tt.mimeType)
}
})
}
}
func TestCommandAppAutocompleteSpeakers(t *testing.T) {
tests := []struct {
name string
speakers []string
listErr error
want []Choice
wantErr string
}{
{
name: "maps speaker names to choices",
speakers: []string{"Rice Shower", "Special Week"},
want: []Choice{
{Label: "Rice Shower", Value: "Rice Shower"},
{Label: "Special Week", Value: "Special Week"},
},
},
{
name: "ListSpeakers error",
listErr: errors.New("boom"),
wantErr: "autocomplete speakers: boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := NewCommandApp(&mockHAGateway{}, &mockAIGateway{}, modelstore.New(), modelvalidator.New(&mockAIGateway{}, time.Minute), &mockTTSGateway{
listSpeakersFunc: func(ctx context.Context, search string) ([]string, error) {
if tt.listErr != nil {
return nil, tt.listErr
}
return tt.speakers, nil
},
})
got, err := app.AutocompleteSpeakers(context.Background())
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("AutocompleteSpeakers() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("AutocompleteSpeakers() error = %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("AutocompleteSpeakers() = %#v, want %#v", got, tt.want)
}
})
}
}
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)
}
}