package phraser import ( "context" "math/rand" "strings" "testing" "time" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/store" ) // downCand builds a service_down candidate the way a tick actually does it: // one fact per kuma monitor under the prefix, carrying the source and value // loop.DownServices checks. The old cand() shape wrote a single fact keyed // plain "service_down", which mavpoll stopped producing, and that is why the // tests passed through the whole of #534. func downCand(names ...string) loop.Candidate { now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC) st := loop.State{Now: now, Facts: map[string]store.Fact{}} for _, n := range names { key := loop.ServiceDownPrefix + n st.Facts[key] = store.Fact{ Key: key, Ts: now.Add(-3 * time.Minute), Source: loop.ServiceDownSource, Value: `"down"`, } } return loop.Candidate{ Rule: loop.Rule{Name: "service_down", Severity: loop.Sev4}, Severity: loop.Sev4, State: st, } } // The nudge he reads on telegram must name what broke. It is a sev4 that // reaches him away from the box, so "a service is down" costs him a trip to // kuma to learn anything at all. func TestNudgeNamesTheDownService(t *testing.T) { // Lowercased before matching: a name that opens the sentence is // capitalized by capitalizeFirst, which is wanted. nt := newTestTemplates(t, 5) for i := 0; i < 40; i++ { body, _ := nt.Nudge(downCand("paperless")) if !strings.Contains(strings.ToLower(body), "paperless") { t.Fatalf("body does not name the service: %q", body) } } // Two down: both named, in the key order the rule itself uses. for i := 0; i < 40; i++ { body, _ := nt.Nudge(downCand("nginx", "paperless")) low := strings.ToLower(body) if !strings.Contains(low, "nginx") || !strings.Contains(low, "paperless") { t.Fatalf("body drops a service: %q", body) } } } // Russian agrees the verb with the subject, so a list of services cannot go // into the singular sentence. One down takes the singular set, two or more // take service_down_many. func TestNudgeAgreesWithTheServiceCount(t *testing.T) { nt := newTestTemplates(t, 9) // "упал " keeps its trailing space: "упали" starts with "упал", and the // plural must not read as the singular by prefix. singular := []string{"не отвечает", "недоступен", "лежит", "упал "} plural := []string{"не отвечают", "недоступны", "лежат", "упали"} for i := 0; i < 60; i++ { body, _ := nt.Nudge(downCand("paperless")) if !containsAny(body, singular) { t.Fatalf("one down, no singular verb: %q", body) } if containsAny(body, plural) { t.Fatalf("one down, plural wording: %q", body) } } for i := 0; i < 60; i++ { body, _ := nt.Nudge(downCand("nginx", "paperless")) if !containsAny(body, plural) { t.Fatalf("two down, no plural verb: %q", body) } if containsAny(body, singular) { t.Fatalf("two down, singular wording: %q", body) } } } func containsAny(s string, subs []string) bool { for _, sub := range subs { if strings.Contains(s, sub) { return true } } return false } // Nothing down means no template fits, and the fallback answers rather than // the picker inventing a name. func TestNudgeServiceDownWithoutFacts(t *testing.T) { nt := newTestTemplates(t, 5) body, mood := nt.Nudge(downCand()) if body != "Сервис не отвечает." { t.Fatalf("fallback body %q", body) } if mood != "neutral" { t.Fatalf("mood %q", mood) } } // cand builds a candidate the way a tick would. func cand(rule string, sinceMin int, factKey string) loop.Candidate { now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC) st := loop.State{Now: now, Facts: map[string]store.Fact{}} if sinceMin > 0 || factKey != "" { key := rule if factKey != "" { key = factKey } st.Facts[rule] = store.Fact{Key: key, Ts: now.Add(-time.Duration(sinceMin) * time.Minute)} } return loop.Candidate{Rule: loop.Rule{Name: rule, Severity: loop.Sev1}, Severity: loop.Sev1, State: st} } func newTestTemplates(t *testing.T, seed int64) *NudgeTemplates { t.Helper() nt, err := NewNudgeTemplates(rand.NewSource(seed)) if err != nil { t.Fatalf("NewNudgeTemplates: %v", err) } return nt } func TestNudgeTemplatesLoad(t *testing.T) { nt := newTestTemplates(t, 1) for _, rule := range []string{"water", "meal", "break", "service_down", "service_down_many", "netdata_critical", "routine", "morning", "default"} { set, ok := nt.file.Rules[rule] if !ok { t.Errorf("no templates for %q", rule) continue } if len(set.Variants) < 5 { t.Errorf("%s: only %d variants", rule, len(set.Variants)) } // Every rule needs one variant that needs no value, or a candidate // without context has nothing to say. routine, morning and // service_down are exempt: they always carry a name and must always // say it. service_down's predicate cannot fire without a down fact, // so loop.DownServices always has something to fill {service} with, // and the nameless variant it used to carry was the bug (#534) — // {service} never filled, so that variant was the only fillable one. plain := 0 seen := map[string]bool{} for _, v := range set.Variants { if !placeholderRE.MatchString(v) { plain++ } if seen[v] { t.Errorf("%s: duplicate variant %q", rule, v) } seen[v] = true } if plain == 0 && rule != "routine" && rule != "morning" && !strings.HasPrefix(rule, "service_down") { t.Errorf("%s: every variant needs a placeholder value", rule) } } } // The whole point of the picker: never the same words twice in a row. func TestNudgeNoImmediateRepeat(t *testing.T) { nt := newTestTemplates(t, 7) prev := "" for i := 0; i < 200; i++ { body, _ := nt.Nudge(cand("water", 200, "")) if body == prev { t.Fatalf("repeat at %d: %q", i, body) } prev = body } } // Same seed, same sequence — otherwise the fixture score would drift run to run. func TestNudgeDeterministicWithSeed(t *testing.T) { var runs [2][]string for r := range runs { nt := newTestTemplates(t, 42) for i := 0; i < 20; i++ { body, _ := nt.Nudge(cand("break", 100, "")) runs[r] = append(runs[r], body) } } for i := range runs[0] { if runs[0][i] != runs[1][i] { t.Fatalf("run %d differs: %q vs %q", i, runs[0][i], runs[1][i]) } } } // A variant is only used when its value exists, and nothing half-filled ships. func TestNudgeNoLeftoverPlaceholders(t *testing.T) { nt := newTestTemplates(t, 3) cases := []loop.Candidate{ cand("water", 0, ""), // no duration cand("water", 30, ""), // under an hour cand("water", 200, ""), // hours downCand("vaultwarden"), downCand(), // nothing down: the fallback answers cand("routine:таблетки", 0, ""), cand("morning:утро", 0, ""), cand("unknown_rule", 0, ""), } for _, c := range cases { for i := 0; i < 40; i++ { body, mood := nt.Nudge(c) if body == "" { t.Fatalf("%s: empty body", c.Rule.Name) } if strings.ContainsAny(body, "{}%") { t.Fatalf("%s: unfilled template %q", c.Rule.Name, body) } if mood != "neutral" { t.Fatalf("%s: mood %q", c.Rule.Name, mood) } } } } // The routine name must actually land in the text. func TestNudgeSubstitutesWhat(t *testing.T) { nt := newTestTemplates(t, 11) for i := 0; i < 40; i++ { body, _ := nt.Nudge(cand("routine:таблетки", 0, "")) if !strings.Contains(strings.ToLower(body), "таблетки") { t.Fatalf("routine text lost the name: %q", body) } } } func TestRuSinceWords(t *testing.T) { cases := []struct { min int want string }{ {30, ""}, {60, "час"}, {95, "полтора часа"}, {150, "два с половиной часа"}, {190, "три часа"}, {240, "четыре часа"}, {430, "семь часов"}, {660, "одиннадцать часов"}, {60 * 30, "больше суток"}, } for _, c := range cases { got := ruSinceWords(time.Duration(c.min) * time.Minute) if got != c.want { t.Errorf("%d min: got %q want %q", c.min, got, c.want) } } } // Templates are the default: a nudge must not reach the model at all. func TestLLMPhraserUsesTemplatesByDefault(t *testing.T) { spy := newGrammarSpy(t) p := NewLLMPhraserAt(spy.srv.URL, Config{}) pn, err := p.PhraseNudge(context.Background(), cand("water", 200, "")) if err != nil { t.Fatalf("PhraseNudge: %v", err) } if len(spy.grammars) != 0 { t.Errorf("nudge hit the model %d times, want 0", len(spy.grammars)) } if !strings.Contains(strings.ToLower(pn.Body), "вод") { t.Errorf("nudge is not the water template: %q", pn.Body) } } // ...and the flag brings the model back. func TestLLMNudgesFlagRestoresTheModel(t *testing.T) { spy := newGrammarSpy(t) p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true}) pn, err := p.PhraseNudge(context.Background(), cand("water", 200, "")) if err != nil { t.Fatalf("PhraseNudge: %v", err) } if len(spy.grammars) != 1 { t.Fatalf("nudge hit the model %d times, want 1", len(spy.grammars)) } if pn.Body != "ага" { t.Errorf("body = %q, want the model's reply", pn.Body) } } func TestNudgeTemplatesPhraseNudge(t *testing.T) { nt := newTestTemplates(t, 5) pn, err := nt.PhraseNudge(context.Background(), cand("water", 200, "")) if err != nil { t.Fatalf("PhraseNudge: %v", err) } if pn.Body == "" || pn.Summary != pn.Body || pn.Mood != "neutral" { t.Fatalf("bad nudge: %+v", pn) } }