All checks were successful
CI / changes (push) Successful in 19s
CI / test (push) Successful in 24s
CI / build-ai-gateway (push) Successful in 1m6s
CI / build-ha-gateway (push) Successful in 1m3s
CI / build-discord-bot (push) Successful in 1m4s
CI / build-alexa-bridge (push) Successful in 1m15s
CI / build-tts-gateway (push) Successful in 1m5s
CI / build-tts-sidecar (push) Has been skipped
CI / build-tts-model (push) Has been skipped
- Implemented SetTemperature in ClimateService for setting an absolute target temperature. - Updated ClimateServiceClient and ClimateServiceServer interfaces to include SetTemperature. - Added corresponding handler and tests for SetTemperature in ClimateGRPC. - Modified ClimateApp to handle SetTemperature requests without clamping. - Updated climate.proto to define SetTemperatureRequest message. - Adjusted Dockerfiles to include alexa-bridge dependencies.
265 lines
9.1 KiB
Go
265 lines
9.1 KiB
Go
package directive
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
|
|
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/alexa"
|
|
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/domain"
|
|
)
|
|
|
|
func discardLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
type fakeResolver struct {
|
|
entities map[string]domain.Entity
|
|
}
|
|
|
|
func (f *fakeResolver) Resolve(friendlyName string) (domain.Entity, bool) {
|
|
e, ok := f.entities[friendlyName]
|
|
return e, ok
|
|
}
|
|
|
|
type fakeController struct {
|
|
executeFunc func(ctx context.Context, entityID, action string, params map[string]any) error
|
|
gotEntityID string
|
|
gotAction string
|
|
gotParams map[string]any
|
|
}
|
|
|
|
func (f *fakeController) ExecuteAction(ctx context.Context, entityID, action string, params map[string]any) error {
|
|
f.gotEntityID = entityID
|
|
f.gotAction = action
|
|
f.gotParams = params
|
|
if f.executeFunc == nil {
|
|
return nil
|
|
}
|
|
return f.executeFunc(ctx, entityID, action, params)
|
|
}
|
|
|
|
func intentRequest(intentName string, slots map[string]alexa.Slot) alexa.Request {
|
|
return alexa.Request{
|
|
Version: "1.0",
|
|
Request: alexa.RequestBody{
|
|
Type: "IntentRequest",
|
|
Intent: &alexa.Intent{
|
|
Name: intentName,
|
|
Slots: slots,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestRouterDispatchLaunchAndSessionEnded(t *testing.T) {
|
|
r := NewRouter(&fakeResolver{}, &fakeController{}, discardLogger())
|
|
|
|
t.Run("LaunchRequest keeps the session open", func(t *testing.T) {
|
|
resp, err := r.Dispatch(context.Background(), alexa.Request{Request: alexa.RequestBody{Type: "LaunchRequest"}})
|
|
if err != nil {
|
|
t.Fatalf("Dispatch() error = %v", err)
|
|
}
|
|
if resp.Response.ShouldEndSession {
|
|
t.Fatal("LaunchRequest should not end the session")
|
|
}
|
|
})
|
|
|
|
t.Run("SessionEndedRequest returns an empty response", func(t *testing.T) {
|
|
resp, err := r.Dispatch(context.Background(), alexa.Request{Request: alexa.RequestBody{Type: "SessionEndedRequest"}})
|
|
if err != nil {
|
|
t.Fatalf("Dispatch() error = %v", err)
|
|
}
|
|
if resp.Version != "1.0" {
|
|
t.Fatalf("Version = %q, want %q", resp.Version, "1.0")
|
|
}
|
|
})
|
|
|
|
t.Run("unrecognized request type does not error", func(t *testing.T) {
|
|
resp, err := r.Dispatch(context.Background(), alexa.Request{Request: alexa.RequestBody{Type: "SomethingElse"}})
|
|
if err != nil {
|
|
t.Fatalf("Dispatch() error = %v", err)
|
|
}
|
|
if resp.Response.OutputSpeech == nil {
|
|
t.Fatal("expected a spoken fallback response")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRouterDispatchBuiltinIntents(t *testing.T) {
|
|
r := NewRouter(&fakeResolver{}, &fakeController{}, discardLogger())
|
|
|
|
tests := []string{"AMAZON.HelpIntent", "AMAZON.StopIntent", "AMAZON.CancelIntent"}
|
|
for _, name := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
resp, err := r.Dispatch(context.Background(), intentRequest(name, nil))
|
|
if err != nil {
|
|
t.Fatalf("Dispatch() error = %v", err)
|
|
}
|
|
if resp.Response.OutputSpeech == nil || resp.Response.OutputSpeech.Text == "" {
|
|
t.Fatal("expected spoken output")
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("unrecognized intent", func(t *testing.T) {
|
|
resp, err := r.Dispatch(context.Background(), intentRequest("NoSuchIntent", nil))
|
|
if err != nil {
|
|
t.Fatalf("Dispatch() error = %v", err)
|
|
}
|
|
if resp.Response.OutputSpeech == nil {
|
|
t.Fatal("expected a spoken fallback response")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRouterDispatchDeviceActions(t *testing.T) {
|
|
entity := domain.Entity{EntityID: "light.living_room", FriendlyName: "Living Room Lamp", Domain: "light"}
|
|
resolver := &fakeResolver{entities: map[string]domain.Entity{"living room lamp": entity}}
|
|
|
|
t.Run("TurnOnIntent resolves device and executes turn_on with no params", func(t *testing.T) {
|
|
ctrl := &fakeController{}
|
|
r := NewRouter(resolver, ctrl, discardLogger())
|
|
|
|
req := intentRequest("TurnOnIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "living room lamp"}})
|
|
resp, err := r.Dispatch(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Dispatch() error = %v", err)
|
|
}
|
|
if ctrl.gotEntityID != "light.living_room" || ctrl.gotAction != "turn_on" {
|
|
t.Fatalf("ExecuteAction called with (%q, %q), want (light.living_room, turn_on)", ctrl.gotEntityID, ctrl.gotAction)
|
|
}
|
|
if ctrl.gotParams != nil {
|
|
t.Fatalf("params = %#v, want nil", ctrl.gotParams)
|
|
}
|
|
if !resp.Response.ShouldEndSession {
|
|
t.Fatal("a completed action should end the session")
|
|
}
|
|
})
|
|
|
|
t.Run("SetBrightnessIntent forwards the Brightness slot as brightness_pct", func(t *testing.T) {
|
|
ctrl := &fakeController{}
|
|
r := NewRouter(resolver, ctrl, discardLogger())
|
|
|
|
req := intentRequest("SetBrightnessIntent", map[string]alexa.Slot{
|
|
"Device": {Name: "Device", Value: "living room lamp"},
|
|
"Brightness": {Name: "Brightness", Value: "50"},
|
|
})
|
|
if _, err := r.Dispatch(context.Background(), req); err != nil {
|
|
t.Fatalf("Dispatch() error = %v", err)
|
|
}
|
|
if ctrl.gotAction != "set_brightness" || ctrl.gotParams["brightness_pct"] != "50" {
|
|
t.Fatalf("action/params = %q/%#v, want set_brightness/{brightness_pct:50}", ctrl.gotAction, ctrl.gotParams)
|
|
}
|
|
})
|
|
|
|
t.Run("SetTemperatureIntent forwards the Temperature slot as target_temperature", func(t *testing.T) {
|
|
climateEntity := domain.Entity{EntityID: "climate.air_conditioner", FriendlyName: "Air Conditioner", Domain: "climate"}
|
|
climateResolver := &fakeResolver{entities: map[string]domain.Entity{"air conditioner": climateEntity}}
|
|
ctrl := &fakeController{}
|
|
r := NewRouter(climateResolver, ctrl, discardLogger())
|
|
|
|
req := intentRequest("SetTemperatureIntent", map[string]alexa.Slot{
|
|
"Device": {Name: "Device", Value: "air conditioner"},
|
|
"Temperature": {Name: "Temperature", Value: "23.5"},
|
|
})
|
|
if _, err := r.Dispatch(context.Background(), req); err != nil {
|
|
t.Fatalf("Dispatch() error = %v", err)
|
|
}
|
|
if ctrl.gotEntityID != "climate.air_conditioner" || ctrl.gotAction != "set_temperature" {
|
|
t.Fatalf("entity/action = %q/%q, want climate.air_conditioner/set_temperature", ctrl.gotEntityID, ctrl.gotAction)
|
|
}
|
|
if ctrl.gotParams["target_temperature"] != "23.5" {
|
|
t.Fatalf("params = %#v, want target_temperature=23.5", ctrl.gotParams)
|
|
}
|
|
})
|
|
|
|
t.Run("SetColorIntent forwards Red/Green/Blue slots as an rgb_color map", func(t *testing.T) {
|
|
ctrl := &fakeController{}
|
|
r := NewRouter(resolver, ctrl, discardLogger())
|
|
|
|
req := intentRequest("SetColorIntent", map[string]alexa.Slot{
|
|
"Device": {Name: "Device", Value: "living room lamp"},
|
|
"Red": {Name: "Red", Value: "255"},
|
|
"Green": {Name: "Green", Value: "0"},
|
|
"Blue": {Name: "Blue", Value: "0"},
|
|
})
|
|
if _, err := r.Dispatch(context.Background(), req); err != nil {
|
|
t.Fatalf("Dispatch() error = %v", err)
|
|
}
|
|
rgb, ok := ctrl.gotParams["rgb_color"].(map[string]any)
|
|
if !ok || rgb["r"] != "255" {
|
|
t.Fatalf("rgb_color = %#v, want map with r=255", ctrl.gotParams["rgb_color"])
|
|
}
|
|
})
|
|
|
|
t.Run("missing Device slot does not call the controller", func(t *testing.T) {
|
|
ctrl := &fakeController{}
|
|
r := NewRouter(resolver, ctrl, discardLogger())
|
|
|
|
req := intentRequest("TurnOnIntent", nil)
|
|
resp, err := r.Dispatch(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Dispatch() error = %v", err)
|
|
}
|
|
if ctrl.gotAction != "" {
|
|
t.Fatal("controller should not be called without a Device slot")
|
|
}
|
|
if resp.Response.OutputSpeech == nil {
|
|
t.Fatal("expected a spoken response explaining the missing device")
|
|
}
|
|
})
|
|
|
|
t.Run("unresolvable device does not call the controller", func(t *testing.T) {
|
|
ctrl := &fakeController{}
|
|
r := NewRouter(resolver, ctrl, discardLogger())
|
|
|
|
req := intentRequest("TurnOnIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "nonexistent"}})
|
|
resp, err := r.Dispatch(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Dispatch() error = %v", err)
|
|
}
|
|
if ctrl.gotAction != "" {
|
|
t.Fatal("controller should not be called for an unresolvable device")
|
|
}
|
|
if resp.Response.OutputSpeech == nil {
|
|
t.Fatal("expected a spoken response explaining the device wasn't found")
|
|
}
|
|
})
|
|
|
|
t.Run("missing required action slot does not call the controller", func(t *testing.T) {
|
|
ctrl := &fakeController{}
|
|
r := NewRouter(resolver, ctrl, discardLogger())
|
|
|
|
req := intentRequest("SetBrightnessIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "living room lamp"}})
|
|
resp, err := r.Dispatch(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Dispatch() error = %v", err)
|
|
}
|
|
if ctrl.gotAction != "" {
|
|
t.Fatal("controller should not be called when a required param slot is missing")
|
|
}
|
|
if resp.Response.OutputSpeech == nil {
|
|
t.Fatal("expected a spoken response explaining the request wasn't understood")
|
|
}
|
|
})
|
|
|
|
t.Run("controller error is turned into a spoken failure, not a Dispatch error", func(t *testing.T) {
|
|
ctrl := &fakeController{executeFunc: func(ctx context.Context, entityID, action string, params map[string]any) error {
|
|
return errors.New("ha-gateway unreachable")
|
|
}}
|
|
r := NewRouter(resolver, ctrl, discardLogger())
|
|
|
|
req := intentRequest("TurnOnIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "living room lamp"}})
|
|
resp, err := r.Dispatch(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Dispatch() error = %v, want nil (errors should be spoken, not propagated)", err)
|
|
}
|
|
if resp.Response.OutputSpeech == nil {
|
|
t.Fatal("expected a spoken failure response")
|
|
}
|
|
})
|
|
}
|