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