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.
155 lines
4.7 KiB
Go
155 lines
4.7 KiB
Go
package entities
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
|
)
|
|
|
|
func discardLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
func TestRefresherStart(t *testing.T) {
|
|
t.Run("happy path populates resolver before returning", func(t *testing.T) {
|
|
fake := &fakeEntityServiceClient{
|
|
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
|
|
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
|
|
{EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}},
|
|
}}, nil
|
|
},
|
|
}
|
|
client := NewClient(fake, []string{"light"})
|
|
resolver := NewResolver()
|
|
r := NewRefresher(client, resolver, time.Hour, discardLogger())
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
if err := r.Start(ctx); err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
if _, ok := resolver.Resolve("Living Room Lamp"); !ok {
|
|
t.Fatal("resolver not populated after Start() returned")
|
|
}
|
|
})
|
|
|
|
t.Run("initial fetch failure returns error and leaves resolver empty", func(t *testing.T) {
|
|
wantErr := errors.New("ha-gateway unreachable")
|
|
fake := &fakeEntityServiceClient{
|
|
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
|
|
return nil, wantErr
|
|
},
|
|
}
|
|
client := NewClient(fake, []string{"light"})
|
|
resolver := NewResolver()
|
|
r := NewRefresher(client, resolver, time.Hour, discardLogger())
|
|
|
|
err := r.Start(context.Background())
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("Start() error = %v, want it to wrap %v", err, wantErr)
|
|
}
|
|
if _, ok := resolver.Resolve("anything"); ok {
|
|
t.Fatal("resolver should stay empty when the initial fetch fails")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRefresherPeriodicRefresh(t *testing.T) {
|
|
var calls int32
|
|
called := make(chan struct{}, 16)
|
|
fake := &fakeEntityServiceClient{
|
|
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
|
|
atomic.AddInt32(&calls, 1)
|
|
select {
|
|
case called <- struct{}{}:
|
|
default:
|
|
}
|
|
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
|
|
{EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}},
|
|
}}, nil
|
|
},
|
|
}
|
|
client := NewClient(fake, []string{"light"})
|
|
resolver := NewResolver()
|
|
r := NewRefresher(client, resolver, 5*time.Millisecond, discardLogger())
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
if err := r.Start(ctx); err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
// The initial blocking fetch inside Start already counts as one call;
|
|
// wait for at least two more from the background ticker loop.
|
|
timeout := time.After(2 * time.Second)
|
|
for i := 0; i < 2; i++ {
|
|
select {
|
|
case <-called:
|
|
case <-timeout:
|
|
t.Fatal("timed out waiting for periodic refresh")
|
|
}
|
|
}
|
|
if atomic.LoadInt32(&calls) < 2 {
|
|
t.Fatalf("calls = %d, want at least 2", calls)
|
|
}
|
|
}
|
|
|
|
func TestRefresherPeriodicRefreshFailureDoesNotStopLoop(t *testing.T) {
|
|
var calls int32
|
|
called := make(chan struct{}, 16)
|
|
fake := &fakeEntityServiceClient{
|
|
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
|
|
n := atomic.AddInt32(&calls, 1)
|
|
select {
|
|
case called <- struct{}{}:
|
|
default:
|
|
}
|
|
// Fail the initial fetch's implied call (n==1 is consumed by
|
|
// Start synchronously below, so only the periodic ones matter
|
|
// here); alternate failures thereafter to prove one bad
|
|
// refresh doesn't wedge the loop.
|
|
if n%2 == 0 {
|
|
return nil, errors.New("transient ha-gateway error")
|
|
}
|
|
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
|
|
{EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}},
|
|
}}, nil
|
|
},
|
|
}
|
|
client := NewClient(fake, []string{"light"})
|
|
resolver := NewResolver()
|
|
r := NewRefresher(client, resolver, 5*time.Millisecond, discardLogger())
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
if err := r.Start(ctx); err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
timeout := time.After(2 * time.Second)
|
|
for i := 0; i < 3; i++ {
|
|
select {
|
|
case <-called:
|
|
case <-timeout:
|
|
t.Fatal("timed out waiting for periodic refresh")
|
|
}
|
|
}
|
|
if atomic.LoadInt32(&calls) < 3 {
|
|
t.Fatalf("calls = %d, want at least 3 (loop should keep running through failures)", calls)
|
|
}
|
|
// The resolver should still hold whatever the last successful refresh
|
|
// produced, even though some refreshes in between failed.
|
|
if _, ok := resolver.Resolve("Living Room Lamp"); !ok {
|
|
t.Fatal("resolver lost its last-known-good data after a failed periodic refresh")
|
|
}
|
|
}
|