package phraser import ( "context" "strings" "testing" "time" "unicode/utf8" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/store" ) // ----------------------------- nudges -------------------------------------- func TestPhraseNudgeWaterMentionsDuration(t *testing.T) { now := time.Now().UTC() earlier := now.Add(-4 * time.Hour) st := loop.State{ Now: now, Facts: map[string]store.Fact{"water": {Key: "water", Ts: earlier, Source: "tap:water", Value: `"250ml"`}}, } c := loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1, State: st} pn, err := NewStub().PhraseNudge(context.Background(), c) if err != nil { t.Fatalf("PhraseNudge: %v", err) } if pn.Candidate.Rule.Name != "water" { t.Fatalf("candidate rule: want water, got %s", pn.Candidate.Rule.Name) } if !strings.Contains(pn.Body, "water") || !strings.Contains(pn.Body, "4 hours") { t.Fatalf("body should mention water + 4 hours, got %q", pn.Body) } if pn.Summary != "drink water" { t.Fatalf("summary: want 'drink water', got %q", pn.Summary) } // Summary must be shorter than Body — the away-channel minimal-body rule. if len(pn.Summary) >= len(pn.Body) { t.Fatalf("summary should be shorter than body: body=%q (%d), summary=%q (%d)", pn.Body, len(pn.Body), pn.Summary, len(pn.Summary)) } } func TestPhraseNudgeMealNoDataStillPhrases(t *testing.T) { // predicate wouldn't fire on no data (since==null), but the phraser is // still required to produce SOMETHING if called — never return empty body. st := loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}} c := loop.Candidate{Rule: loop.MealRule(), Severity: loop.Sev1, State: st} pn, err := NewStub().PhraseNudge(context.Background(), c) if err != nil { t.Fatalf("PhraseNudge: %v", err) } if pn.Body == "" { t.Fatal("body empty on no-data — should still phrase") } if pn.Summary == "" { t.Fatal("summary empty on no-data") } } func TestPhraseNudgeBreakDeskDuration(t *testing.T) { now := time.Now().UTC() st := loop.State{ Now: now, Facts: map[string]store.Fact{ "desk_active": {Key: "desk_active", Ts: now.Add(-30 * time.Second)}, "break": {Key: "break", Ts: now.Add(-95 * time.Minute)}, }, } c := loop.Candidate{Rule: loop.BreakRule(), Severity: loop.Sev2, State: st} pn, _ := NewStub().PhraseNudge(context.Background(), c) if !strings.Contains(pn.Body, "desk") || !strings.Contains(pn.Body, "an hour and a half") { t.Fatalf("break body should mention desk + duration, got %q", pn.Body) } if pn.Summary != "take a break" { t.Fatalf("summary: want 'take a break', got %q", pn.Summary) } } func TestPhraseNudgeServiceDownNamedService(t *testing.T) { // one fact per kuma monitor → the phrase names the monitor that is down. now := time.Now().UTC() st := loop.State{ Now: now, Facts: map[string]store.Fact{ "service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`}, "service_down:db": {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"up"`}, }, } c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st} pn, _ := NewStub().PhraseNudge(context.Background(), c) if !strings.Contains(pn.Body, "nginx") { t.Fatalf("body should name the service, got %q", pn.Body) } if !strings.Contains(pn.Summary, "nginx") { t.Fatalf("summary should name the service, got %q", pn.Summary) } } func TestPhraseNudgeServiceDownGenericKey(t *testing.T) { // the old aggregate key, still in the store from before the per-monitor // facts landed → the generic phrase, never a phantom "service_down down". now := time.Now().UTC() st := loop.State{ Now: now, Facts: map[string]store.Fact{"service_down": {Key: "service_down", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`}}, } c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st} pn, _ := NewStub().PhraseNudge(context.Background(), c) if strings.Contains(pn.Body, "service_down down") { t.Fatalf("body shouldn't repeat the key verbatim: %q", pn.Body) } } // Two monitors down at once must both be named — he needs to know the blast // radius, and "a service is down" was the whole defect being fixed here. func TestPhraseNudgeServiceDownNamesEveryDownMonitor(t *testing.T) { now := time.Now().UTC() st := loop.State{ Now: now, Facts: map[string]store.Fact{ "service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`}, "service_down:db": {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`}, }, } c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st} pn, _ := NewStub().PhraseNudge(context.Background(), c) for _, want := range []string{"nginx", "db"} { if !strings.Contains(pn.Body, want) { t.Fatalf("body should name %q, got %q", want, pn.Body) } } } func TestPhraseNudgeUnknownRuleFallsBack(t *testing.T) { // a rule without a dedicated template — generic fallback names the rule + // severity gist. never empty. unk := loop.Rule{Name: "custom_rule", Severity: loop.Sev3} st := loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}} c := loop.Candidate{Rule: unk, Severity: loop.Sev3, State: st} pn, _ := NewStub().PhraseNudge(context.Background(), c) if pn.Body == "" || pn.Summary == "" { t.Fatalf("fallback should produce both: body=%q summary=%q", pn.Body, pn.Summary) } if pn.Summary != "custom_rule" { t.Fatalf("fallback summary should be the rule name, got %q", pn.Summary) } } // ----------------------------- reminders ------------------------------------ func TestPhraseReminderExtractsText(t *testing.T) { // the router's reminder slot extraction produces {"text": "wake me"} — the // phraser unwraps it. the reminder's words are the user's; no editorializing. rd := loop.ReminderDecision{ Reminder: store.Reminder{Payload: `{"text":"wake me at 7"}`}, State: loop.State{Now: time.Now().UTC()}, } pr, err := NewStub().PhraseReminder(context.Background(), rd) if err != nil { t.Fatalf("PhraseReminder: %v", err) } if pr.Body != "wake me at 7" { t.Fatalf("body: want 'wake me at 7', got %q", pr.Body) } if pr.Summary != "wake me at 7" { t.Fatalf("summary: want 'wake me at 7', got %q", pr.Summary) } } func TestPhraseReminderTruncatesLongSummary(t *testing.T) { // away channels (ntfy/telegram) get Summary; a long reminder text → // truncated so the lock-screen preview isn't a paragraph. long := "remind me to do the thing where i need to walk all the way over there and back before the sun comes up" rd := loop.ReminderDecision{ Reminder: store.Reminder{Payload: `{"text":"` + long + `"}`}, State: loop.State{Now: time.Now().UTC()}, } pr, _ := NewStub().PhraseReminder(context.Background(), rd) if len(pr.Summary) > 63 { t.Fatalf("summary should be truncated to ~60, got %d: %q", len(pr.Summary), pr.Summary) } if pr.Body != long { t.Fatalf("body should be the full text, got %q", pr.Body) } } // The same truncation, in the language she actually speaks. The test above is // ASCII, which is what let the byte arithmetic stand: `len(s) > 60` and `s[:57]` // cut a Russian reminder at about 28 letters instead of 60, and landed inside a // letter about half the time. Summary is what voicesink hands to piper and what // the telegram sink posts, so half a rune was spoken and sent. func TestPhraseReminderSummaryCountsRunesNotBytes(t *testing.T) { long := "позвонить маме и забрать посылку из пункта выдачи на соседней улице до восьми вечера" rd := loop.ReminderDecision{ Reminder: store.Reminder{Payload: `{"text":"` + long + `"}`}, State: loop.State{Now: time.Now().UTC()}, } pr, _ := NewStub().PhraseReminder(context.Background(), rd) if !utf8.ValidString(pr.Summary) { t.Fatalf("summary is not valid UTF-8, it was cut mid-letter: %q", pr.Summary) } if n := utf8.RuneCountInString(pr.Summary); n > summaryLimit { t.Fatalf("summary = %d runes, want at most %d: %q", n, summaryLimit, pr.Summary) } // The cut must be near the limit, not near half of it. A byte count would // stop at 28 letters here. if n := utf8.RuneCountInString(pr.Summary); n < summaryLimit-5 { t.Fatalf("summary = %d runes, cut far too early — counted in bytes? %q", n, pr.Summary) } if pr.Mood == "" { t.Error("Mood is empty; the Stub is a production fallback and owes the contract a mood") } } func TestPhraseReminderNonJSONPayload(t *testing.T) { // a payload that isn't JSON → the phraser falls back to the raw string. rd := loop.ReminderDecision{ Reminder: store.Reminder{Payload: "just a plain string"}, State: loop.State{Now: time.Now().UTC()}, } pr, _ := NewStub().PhraseReminder(context.Background(), rd) if pr.Body != "just a plain string" { t.Fatalf("non-json body: want the raw string, got %q", pr.Body) } if pr.Summary != "just a plain string" { t.Fatalf("non-json summary: want the raw string, got %q", pr.Summary) } } func TestPhraseReminderEmptyPayload(t *testing.T) { // don't ship an empty message — the reminder path must produce something. rd := loop.ReminderDecision{ Reminder: store.Reminder{Payload: ""}, State: loop.State{Now: time.Now().UTC()}, } pr, _ := NewStub().PhraseReminder(context.Background(), rd) if pr.Body == "" || pr.Summary == "" { t.Fatalf("empty payload should fall back to 'reminder': body=%q summary=%q", pr.Body, pr.Summary) } if pr.Body != "reminder" { t.Fatalf("empty payload body: want 'reminder', got %q", pr.Body) } } // ----------------------------- chat ----------------------------------------- func TestPhraseChatReturnsNonEmpty(t *testing.T) { p := NewStub() reply, err := p.PhraseChat(context.Background(), "как дела", nil) if err != nil { t.Fatalf("PhraseChat: %v", err) } if reply == "" { t.Fatal("PhraseChat returned empty reply") } } func TestPhraseChatWithHistory(t *testing.T) { p := NewStub() history := []dialogue.Turn{ {Text: "привет", Intent: dialogue.IntentChat}, {Text: "как тебя зовут", Intent: dialogue.IntentChat}, } reply, err := p.PhraseChat(context.Background(), "расскажи о себе", history) if err != nil { t.Fatalf("PhraseChat with history: %v", err) } if reply == "" { t.Fatal("PhraseChat with history returned empty reply") } } // ----------------------------- interface guard ------------------------------ func TestStubSatisfiesPhraser(t *testing.T) { // compile-time guard: the Stub must satisfy the Phraser interface so the // daemon can wire it. the production LLM impl satisfies the same interface. var _ Phraser = (*Stub)(nil) // also exercise the methods once so the guard isn't the only assertion. p := NewStub() if _, err := p.PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1, State: loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}}}); err != nil { t.Fatalf("PhraseNudge: %v", err) } if _, err := p.PhraseReminder(context.Background(), loop.ReminderDecision{Reminder: store.Reminder{Payload: "{}"}, State: loop.State{Now: time.Now().UTC()}}); err != nil { t.Fatalf("PhraseReminder: %v", err) } if _, err := p.PhraseChat(context.Background(), "как дела", nil); err != nil { t.Fatalf("PhraseChat: %v", err) } } func TestStubProducesDeliveryTypes(t *testing.T) { // the output must be the delivery.Phrased* structs the dispatcher // consumes — the phraser owns the full output contract. pn, _ := NewStub().PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1, State: loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}}}) if _, ok := any(pn).(delivery.PhrasedNudge); !ok { t.Fatalf("PhraseNudge must return delivery.PhrasedNudge, got %T", pn) } } // ----------------------------- helpers -------------------------------------- // (the per-rule templates change output shape; assert via strings.Contains // against salient fragments, not exact strings — keeps the tests robust to // tone tweaks in the Stub.)