d62ba093f5
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
277 lines
9.0 KiB
Go
277 lines
9.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/config"
|
|
)
|
|
|
|
const haStatesFixture = `[
|
|
{"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":{"friendly_name":"Спальня","unit_of_measurement":"°C"}}
|
|
]`
|
|
|
|
func TestWireSmartHomeOffUnlessEnabled(t *testing.T) {
|
|
st := newTestStore(t)
|
|
for name, cfg := range map[string]*config.Config{
|
|
"no block": {},
|
|
"written but dark": {SmartHome: &config.SmartHomeConfig{
|
|
URL: "http://ha.lan:8123", Token: "t",
|
|
}},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if w := wireSmartHome(cfg, st); w != nil {
|
|
t.Fatal("the house must be off unless the block is enabled")
|
|
}
|
|
})
|
|
}
|
|
// nil wiring must be safe everywhere it is reachable.
|
|
var w *homeWiring
|
|
w.propose(context.Background())
|
|
w.run(context.Background())
|
|
if w.caller() != nil {
|
|
t.Fatal("a nil wiring must have no caller")
|
|
}
|
|
if _, ok := w.homeSummary(context.Background()); ok {
|
|
t.Fatal("a nil wiring must not claim a query")
|
|
}
|
|
}
|
|
|
|
// An unreachable instance must not stop the daemon and must propose nothing.
|
|
func TestWireSmartHomeUnreachableIsNotFatal(t *testing.T) {
|
|
st := newTestStore(t)
|
|
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
|
// Port 1 on loopback: nothing listens, and it fails fast.
|
|
URL: "http://127.0.0.1:1", Token: "t", Enabled: true,
|
|
}}, st)
|
|
if w == nil {
|
|
t.Fatal("a configured house should still wire")
|
|
}
|
|
tools, err := st.ListTools(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(tools) != 0 {
|
|
t.Fatalf("an instance that never answered must propose nothing, got %+v", tools)
|
|
}
|
|
}
|
|
|
|
// Discovery proposes one row per controllable service, always destructive,
|
|
// always 'proposed'. A sensor gets no row: there is nothing to call on it.
|
|
func TestProposeOnlyProposesControllableDevices(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(haStatesFixture))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
st := newTestStore(t)
|
|
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
|
URL: srv.URL, Token: "t", Enabled: true,
|
|
}}, st)
|
|
if w == nil {
|
|
t.Fatal("wireSmartHome returned nil for an enabled, reachable house")
|
|
}
|
|
// Wiring alone must not have touched the house: enumeration happens off
|
|
// the ticker, not on the daemon's start path.
|
|
if pre, err := st.ListTools(context.Background(), ""); err != nil || len(pre) != 0 {
|
|
t.Fatalf("wireSmartHome enumerated the house synchronously: %+v (%v)", pre, err)
|
|
}
|
|
w.propose(context.Background())
|
|
|
|
tools, err := st.ListTools(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := map[string]bool{}
|
|
for _, tl := range tools {
|
|
got[tl.Name] = true
|
|
if tl.Status != "proposed" {
|
|
t.Errorf("%s status = %q: discovery must never enable", tl.Name, tl.Status)
|
|
}
|
|
if !tl.Destructive {
|
|
t.Errorf("%s is not destructive: every house control needs the confirm turn", tl.Name)
|
|
}
|
|
if len(tl.Cmd) == 0 || tl.Cmd[0] != "smarthome" {
|
|
t.Errorf("%s cmd = %v", tl.Name, tl.Cmd)
|
|
}
|
|
}
|
|
for _, want := range []string{
|
|
"home_light_living_room_on", "home_light_living_room_off",
|
|
"home_switch_kettle_on", "home_switch_kettle_off",
|
|
} {
|
|
if !got[want] {
|
|
t.Errorf("missing proposal %q (have %v)", want, got)
|
|
}
|
|
}
|
|
if len(tools) != 4 {
|
|
t.Fatalf("got %d rows, want 4 — the sensor must not be proposed: %+v", len(tools), tools)
|
|
}
|
|
|
|
// A second pass must be idempotent: re-discovery duplicates nothing and
|
|
// never rewrites a row Kami already enabled.
|
|
if err := st.EnableTool(context.Background(), "home_switch_kettle_on",
|
|
[]string{"smarthome", "switch.kettle", "turn_on"}, true, "smarthome:switch", time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w.propose(context.Background())
|
|
again, err := st.ListTools(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(again) != 4 {
|
|
t.Fatalf("re-discovery duplicated rows: %d", len(again))
|
|
}
|
|
for _, tl := range again {
|
|
if tl.Name == "home_switch_kettle_on" && tl.Status != "enabled" {
|
|
t.Errorf("re-discovery un-enabled a device he had enabled: %q", tl.Status)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHomeSummaryReadsState(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(haStatesFixture))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
|
URL: srv.URL, Token: "t", Enabled: true,
|
|
}}, newTestStore(t))
|
|
out, ok := w.homeSummary(context.Background())
|
|
if !ok {
|
|
t.Fatal("summary did not claim the turn")
|
|
}
|
|
if !strings.Contains(out, "Гостиная") {
|
|
t.Errorf("the lamp that is on should be named: %q", out)
|
|
}
|
|
if strings.Contains(out, "Чайник") {
|
|
t.Errorf("a device that is off should not be listed as on: %q", out)
|
|
}
|
|
if !strings.Contains(out, "22.5") {
|
|
t.Errorf("the sensor reading should be there: %q", out)
|
|
}
|
|
// Persona: no masculine self-reference, no "вы", no pet names.
|
|
for _, bad := range []string{"рад ", "готов ", "вы ", "ваш", "милый", "дорогой"} {
|
|
if strings.Contains(strings.ToLower(out), bad) {
|
|
t.Errorf("persona violation %q in %q", bad, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsHomeQuery(t *testing.T) {
|
|
yes := []string{
|
|
"что включено дома?",
|
|
"что выключено",
|
|
"какой свет горит дома",
|
|
"свет в доме включен?",
|
|
"какая температура в квартире?",
|
|
"покажи умный дом",
|
|
}
|
|
no := []string{
|
|
"",
|
|
"я дома",
|
|
"буду дома в семь",
|
|
"какая погода дома", // weather wording wins
|
|
"какая температура на улице?",
|
|
"домашние дела", // "дома" must not fire on "домашние"
|
|
"что мне нужно сделать?",
|
|
"напомни выключить чайник в семь", // a reminder, not a house read
|
|
}
|
|
for _, u := range yes {
|
|
if !isHomeQuery(u) {
|
|
t.Errorf("isHomeQuery(%q) = false, want true", u)
|
|
}
|
|
}
|
|
for _, u := range no {
|
|
if isHomeQuery(u) {
|
|
t.Errorf("isHomeQuery(%q) = true, want false", u)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A house that black-holes the connection must not hold the daemon's start.
|
|
// wireSmartHome used to enumerate synchronously with a 30s context, inside
|
|
// wireVoice, inside run, before the IPC socket was serving — and on the locked
|
|
// path, inside the passkey unlock handler.
|
|
func TestWireSmartHomeDoesNotBlockOnTheHouse(t *testing.T) {
|
|
// A handler that never answers: the client's own timeout is the only way
|
|
// out, and it is ten seconds.
|
|
block := make(chan struct{})
|
|
defer close(block)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
<-block
|
|
}))
|
|
defer srv.Close()
|
|
|
|
done := make(chan *homeWiring, 1)
|
|
go func() {
|
|
done <- wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
|
URL: srv.URL, Token: "t", Enabled: true,
|
|
}}, newTestStore(t))
|
|
}()
|
|
select {
|
|
case w := <-done:
|
|
if w == nil {
|
|
t.Fatal("a configured house should still wire")
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("wireSmartHome waited on the house")
|
|
}
|
|
}
|
|
|
|
// A lamp that is unreachable is not a lamp that is off, and a list she cut
|
|
// short has to say so. Both used to read as plain statements about the house.
|
|
func TestHomeSummaryDoesNotCallUnreachableDevicesOff(t *testing.T) {
|
|
const fixture = `[
|
|
{"entity_id":"light.a","state":"unavailable","attributes":{"friendly_name":"Прихожая"}},
|
|
{"entity_id":"light.b","state":"unavailable","attributes":{"friendly_name":"Кухня"}}
|
|
]`
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(fixture))
|
|
}))
|
|
defer srv.Close()
|
|
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
|
URL: srv.URL, Token: "t", Enabled: true,
|
|
}}, newTestStore(t))
|
|
out, ok := w.homeSummary(context.Background())
|
|
if !ok {
|
|
t.Fatal("summary did not claim the turn")
|
|
}
|
|
if strings.Contains(out, "всё выключено") {
|
|
t.Errorf("two unreachable lamps were reported as off: %q", out)
|
|
}
|
|
if !strings.Contains(out, "не отвечают") {
|
|
t.Errorf("the unreachable devices are not mentioned: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestHomeSummarySaysWhenTheListIsCutShort(t *testing.T) {
|
|
var b strings.Builder
|
|
b.WriteString("[")
|
|
for i := 0; i < 8; i++ {
|
|
if i > 0 {
|
|
b.WriteString(",")
|
|
}
|
|
fmt.Fprintf(&b, `{"entity_id":"light.l%d","state":"on","attributes":{"friendly_name":"лампа%d"}}`, i, i)
|
|
}
|
|
b.WriteString("]")
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(b.String()))
|
|
}))
|
|
defer srv.Close()
|
|
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
|
URL: srv.URL, Token: "t", Enabled: true,
|
|
}}, newTestStore(t))
|
|
out, _ := w.homeSummary(context.Background())
|
|
if !strings.Contains(out, "и ещё 3") {
|
|
t.Errorf("eight lamps on, five named, and nothing said about the rest: %q", out)
|
|
}
|
|
}
|