Files
Maven/internal/smarthome/smarthome_test.go
T
kami d62ba093f5 smarthome: keep the devices under the cap, and stop asserting what the house did not do
States sorted every entity by id and cut at MaxEntities. Entity ids sort
by domain prefix, so binary_sensor came first and forty slots went to
connectivity and update-available rows: propose found nothing
controllable, and homeSummary, reading the same list, said everything
was off with the lights on. The cap stays, because a tool name the 1.7B
half-remembers is a wrong act. What changes is which forty. Controllable
domains are taken first and round-robin, so every switch and light is in
before any sensor.

CallService reported done for a call that changed nothing. Home
Assistant answers a service call with the states it changed, and a
removed entity or an offline integration gets 200 and an empty array.
That is the one place Maven asserts something about the physical world,
so an empty array is now ErrUnknownEntity.

The confirm turn on a house row was a column, not an invariant. The
proposal is destructive, but /tools writes the checkbox through on
enable, so unticking it once made an unlock row that ran on first
hearing. Exec now demands the second turn for any smarthome row whatever
the column says, and lock is out of the default domain set so a bare
block does not propose an unlock for every door.

wireSmartHome enumerated the house synchronously, inside wireVoice,
before the socket was serving and inside the unlock handler. A box that
black-holes the connection held the daemon's start for the per-call
timeout. The first propose moved onto the ticker goroutine.

Smaller: an unreachable lamp is counted apart from an off one, a
truncated on-list says how many it left out, refresh has a floor of a
minute, and the http url is documented as a deliberate wg-only choice.
Found in review of #80.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:06:38 +04:00

292 lines
9.9 KiB
Go

package smarthome
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// statesFixture is a trimmed /api/states response from a Home Assistant with
// one light, one switch, one sensor and two entities Maven must ignore.
const statesFixture = `[
{"entity_id":"light.living_room","state":"on","attributes":{"friendly_name":"Гостиная"}},
{"entity_id":"switch.kettle","state":"off","attributes":{"friendly_name":"Чайник"}},
{"entity_id":"sensor.bedroom_temp","state":"22.5","attributes":{"unit_of_measurement":"°C"}},
{"entity_id":"person.kami","state":"home","attributes":{}},
{"entity_id":"automation.wake","state":"on","attributes":[]}
]`
func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c := NewClient(Config{URL: srv.URL, Token: "tok"})
return c, srv
}
func TestStatesFiltersAndNames(t *testing.T) {
var auth string
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
if r.URL.Path != "/api/states" {
t.Errorf("path = %q", r.URL.Path)
}
_, _ = w.Write([]byte(statesFixture))
})
got, err := c.States(context.Background())
if err != nil {
t.Fatalf("States: %v", err)
}
if auth != "Bearer tok" {
t.Errorf("Authorization = %q", auth)
}
// person and automation are neither controllable nor sensors.
want := []string{"light.living_room", "sensor.bedroom_temp", "switch.kettle"}
if len(got) != len(want) {
t.Fatalf("got %d entities, want %d: %+v", len(got), len(want), got)
}
for i, id := range want {
if got[i].ID != id {
t.Errorf("entity %d = %q, want %q (sorted by id)", i, got[i].ID, id)
}
}
if got[0].Name != "Гостиная" || got[0].Domain != "light" || got[0].State != "on" {
t.Errorf("light = %+v", got[0])
}
if got[1].Unit != "°C" {
t.Errorf("sensor unit = %q", got[1].Unit)
}
// An attributes value of the wrong shape must not lose the entity.
if got[2].Name != "Чайник" {
t.Errorf("switch name = %q", got[2].Name)
}
}
func TestStatesRespectsConfiguredDomainsAndCap(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(statesFixture))
}))
defer srv.Close()
c := NewClient(Config{URL: srv.URL, Token: "t", Domains: []string{"switch"}})
got, err := c.States(context.Background())
if err != nil {
t.Fatalf("States: %v", err)
}
if len(got) != 1 || got[0].ID != "switch.kettle" {
t.Fatalf("domain filter: %+v", got)
}
c = NewClient(Config{URL: srv.URL, Token: "t", MaxEntities: 2})
got, err = c.States(context.Background())
if err != nil {
t.Fatalf("States: %v", err)
}
if len(got) != 2 {
t.Fatalf("cap: got %d entities, want 2", len(got))
}
}
func TestCallServicePostsEntityID(t *testing.T) {
var path, body string
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
b := make([]byte, 256)
n, _ := r.Body.Read(b)
body = string(b[:n])
_, _ = w.Write([]byte(`[{"entity_id":"light.living_room","state":"off"}]`))
})
out, err := c.CallService(context.Background(), "light.living_room", "turn_off")
if err != nil {
t.Fatalf("CallService: %v", err)
}
if out != "готово" {
t.Errorf("out = %q", out)
}
if path != "/api/services/light/turn_off" {
t.Errorf("path = %q", path)
}
if !strings.Contains(body, `"entity_id":"light.living_room"`) {
t.Errorf("body = %q", body)
}
}
// A service that is not in the domain's table never leaves the box. The
// allowlist is the gate, and it is enforced on the way out too, so a corrupted
// or hand-edited cmd column cannot reach an arbitrary Home Assistant service.
func TestCallServiceRefusesUnknownServiceAndDomain(t *testing.T) {
called := false
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = w.Write([]byte(`[]`))
})
for _, tc := range []struct {
entity, service string
want error
}{
{"light.living_room", "delete_everything", ErrNotControllable},
{"sensor.bedroom_temp", "turn_on", ErrNotControllable},
{"nodot", "turn_on", ErrUnknownEntity},
} {
if _, err := c.CallService(context.Background(), tc.entity, tc.service); !errors.Is(err, tc.want) {
t.Errorf("CallService(%q,%q) err = %v, want %v", tc.entity, tc.service, err, tc.want)
}
}
if called {
t.Error("a refused call still reached the network")
}
}
func TestHTTPErrorIsAnError(t *testing.T) {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
})
if _, err := c.States(context.Background()); err == nil {
t.Fatal("want error on 401")
}
}
func TestUnconfiguredClientRefuses(t *testing.T) {
c := NewClient(Config{})
if _, err := c.States(context.Background()); !errors.Is(err, ErrNotConfigured) {
t.Fatalf("err = %v, want ErrNotConfigured", err)
}
}
func TestValidate(t *testing.T) {
ok := Config{URL: "http://ha.lan:8123", Token: "t"}
if err := Validate(ok); err != nil {
t.Fatalf("Validate(ok): %v", err)
}
for name, c := range map[string]Config{
"no url": {Token: "t"},
"no token": {URL: "http://ha.lan:8123"},
"bad scheme": {URL: "ftp://ha.lan", Token: "t"},
"no host": {URL: "http://", Token: "t"},
"not a url": {URL: "://x", Token: "t"},
"bare string": {URL: "ha.lan:8123", Token: "t"},
} {
if err := Validate(c); err == nil {
t.Errorf("Validate(%s) = nil, want error", name)
}
}
}
func TestAllowlistEncoding(t *testing.T) {
cmd := Cmd("light.living_room", "turn_off")
id, svc, ok := ParseCmd(cmd)
if !ok || id != "light.living_room" || svc != "turn_off" {
t.Fatalf("ParseCmd(%v) = %q,%q,%v", cmd, id, svc, ok)
}
// Anything that is not exactly a three-element smarthome row stays a
// process row, or the executor would swallow a real shell tool.
for _, bad := range [][]string{
nil,
{"smarthome"},
{"smarthome", "light.x"},
{"smarthome", "light.x", "turn_on", "extra"},
{"smarthome", "", "turn_on"},
{"smarthome", "light.x", ""},
{"systemctl", "restart", "nginx"},
} {
if _, _, ok := ParseCmd(bad); ok {
t.Errorf("ParseCmd(%v) = ok, want not a smarthome row", bad)
}
}
if got := LocalName("light.living_room", "off"); got != "home_light_living_room_off" {
t.Errorf("LocalName = %q", got)
}
if got := Scope("light"); got != "smarthome:light" {
t.Errorf("Scope = %q", got)
}
if Services("light") == nil || Services("sensor") != nil {
t.Error("Services: light must be controllable and sensor must not")
}
if DomainOf("light.x") != "light" || DomainOf("nodot") != "" || DomainOf(".x") != "" {
t.Error("DomainOf")
}
}
// Home Assistant answers a service call with the states it changed, and an
// entity that has been removed or whose integration is offline gets 200 and an
// empty array. "готово" for that is Maven asserting something false about the
// physical world: he says выключи свет, she says done, the light stays on.
func TestCallServiceOnAnEntityThatChangedNothing(t *testing.T) {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[]`))
})
if _, err := c.CallService(context.Background(), "light.living_room", "turn_off"); !errors.Is(err, ErrUnknownEntity) {
t.Errorf("err = %v, want ErrUnknownEntity for a call that changed nothing", err)
}
// A response shape we cannot parse is not evidence of failure: HA answered
// 2xx, so the call is reported as made.
c2, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"result":"ok"}`))
})
if out, err := c2.CallService(context.Background(), "light.living_room", "turn_off"); err != nil || out != "готово" {
t.Errorf("out,err = %q,%v", out, err)
}
}
// The cap must not be spent on sensors. Entity ids sort by domain prefix and
// binary_sensor sorts first, so a globally sorted truncation handed all forty
// slots to connectivity sensors: propose found nothing controllable and
// homeSummary said "всё выключено" with the lights on.
func TestCapKeepsControllableEntitiesFirst(t *testing.T) {
var all []Entity
for i := 0; i < 40; i++ {
all = append(all, Entity{ID: fmt.Sprintf("binary_sensor.b%02d", i), Domain: "binary_sensor", State: "off"})
}
for i := 0; i < 30; i++ {
all = append(all, Entity{ID: fmt.Sprintf("sensor.s%02d", i), Domain: "sensor", State: "1"})
}
for i := 0; i < 4; i++ {
all = append(all, Entity{ID: fmt.Sprintf("switch.w%d", i), Domain: "switch", State: "on"})
}
for i := 0; i < 3; i++ {
all = append(all, Entity{ID: fmt.Sprintf("light.l%d", i), Domain: "light", State: "on"})
}
got := capEntities(all, 40)
if len(got) != 40 {
t.Fatalf("kept %d entities, want 40", len(got))
}
kept := map[string]int{}
for _, e := range got {
kept[e.Domain]++
}
if kept["switch"] != 4 || kept["light"] != 3 {
t.Errorf("kept %d switches and %d lights, want all 4 and all 3: %v", kept["switch"], kept["light"], kept)
}
// The remainder still carries readable sensors, spread across both sensor
// domains rather than exhausting the one that sorts first.
if kept["sensor"] == 0 || kept["binary_sensor"] == 0 {
t.Errorf("the read domains were starved: %v", kept)
}
// Deterministic across restarts: /tools has to read the same each time.
for i := 1; i < len(got); i++ {
if got[i-1].ID >= got[i].ID {
t.Fatalf("output is not sorted by id at %d", i)
}
}
}
// A deadbolt is a different class of object from a lamp. A bare url+token block
// must not auto-propose an unlock row for every door in the flat.
func TestLockIsNotInTheDefaultDomains(t *testing.T) {
c := NewClient(Config{URL: "http://x", Token: "t"})
if c.wanted("lock") {
t.Error("lock is enumerated without being named in domains")
}
if !c.wanted("light") || !c.wanted("sensor") {
t.Error("the ordinary default domains were lost")
}
named := NewClient(Config{URL: "http://x", Token: "t", Domains: []string{"lock"}})
if !named.wanted("lock") {
t.Error("lock named in domains is still not enumerated")
}
}