Nik Afiq 8f7024edfa
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
feat(climate): add SetTemperature method to ClimateService
- 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.
2026-07-25 12:08:24 +09:00

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")
}
}