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
- 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.
1239 lines
34 KiB
Go
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)
|
|
}
|
|
}
|