package main import ( "context" "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") } 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) } } }