package web import ( "strings" "testing" "pete/internal/storage" ) // TestProseAcceptedWhenClean: gogobee's LLM prose replaces the template render // (on the site row AND the live Matrix post) when both fields are present and // name nobody the fact did not authorize. func TestProseAcceptedWhenClean(t *testing.T) { const token = "t" s, posted := newAdvServer(t, token) const hl = "Josie went into the Ossuary alone and came back with the crown." const lede = "No fanfare, no party — just Josie, a locked door, and a very bad afternoon for whatever was guarding it. She walked back out at level 14 with the thing everyone else left behind." f := AdvFact{ GUID: "boss_kill:j:1", EventType: "boss_kill", Tier: "priority", Actors: []string{"Josie"}, Subject: "Josie", Boss: "the Bone Warden", Zone: "the Ossuary", Level: 14, OccurredAt: 1, Headline: hl, Lede: lede, } if rw := postFact(t, s, token, f); rw.Code != 200 { t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String()) } got, err := storage.GetStoryByGUID(f.GUID) if err != nil || got == nil { t.Fatalf("story not stored: %v", err) } if got.Headline != hl { t.Errorf("stored headline = %q, want the LLM headline", got.Headline) } if got.Lede != lede { t.Errorf("stored lede = %q, want the LLM lede", got.Lede) } if len(*posted) != 1 || (*posted)[0].Headline != hl { t.Fatalf("live post did not carry LLM headline: %+v", *posted) } } // TestProseRejectedNamesBystander: prose that names a real adventurer on the // board who is NOT in the fact's Actors is the injection this guard exists for. // It must fall back to the template, not print the name. func TestProseRejectedNamesBystander(t *testing.T) { const token = "t" s, _ := newAdvServer(t, token) // Kif is a real, current adventurer — but this fact is about Josie. if err := storage.ReplaceRoster([]storage.RosterEntry{ {Token: "tk", Name: "Kif", Level: 9, Status: "idle"}, }, 1); err != nil { t.Fatal(err) } f := AdvFact{ GUID: "boss_kill:j:2", EventType: "boss_kill", Tier: "priority", Actors: []string{"Josie"}, Subject: "Josie", Boss: "the Bone Warden", Zone: "the Ossuary", Level: 14, OccurredAt: 1, Headline: "Josie and Kif split the Ossuary hoard.", Lede: "A tidy bit of teamwork down in the dark today.", } if rw := postFact(t, s, token, f); rw.Code != 200 { t.Fatalf("ingest status = %d", rw.Code) } got, err := storage.GetStoryByGUID(f.GUID) if err != nil || got == nil { t.Fatalf("story not stored: %v", err) } if strings.Contains(got.Headline+got.Lede, "Kif") { t.Errorf("bystander name leaked past the guard: %q / %q", got.Headline, got.Lede) } // The template render is what should have published instead. tHl, _, _ := renderAdventure(f) if got.Headline != tHl { t.Errorf("did not fall back to template: headline = %q, want %q", got.Headline, tHl) } } // TestProseRejectedTooLong: a runaway generation past the length caps is not a // dispatch. Falls back to the template. func TestProseRejectedTooLong(t *testing.T) { const token = "t" s, _ := newAdvServer(t, token) f := AdvFact{ GUID: "arrival:z:1", EventType: "arrival", Tier: "bulletin", Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "human fighter", OccurredAt: 1, Headline: "Welcome, Zapp.", Lede: strings.Repeat("very long ", maxDispatchLede), } if rw := postFact(t, s, token, f); rw.Code != 200 { t.Fatalf("ingest status = %d", rw.Code) } got, err := storage.GetStoryByGUID(f.GUID) if err != nil || got == nil { t.Fatalf("story not stored: %v", err) } tHl, _, _ := renderAdventure(f) if got.Headline != tHl { t.Errorf("over-length prose was not rejected: headline = %q", got.Headline) } } // TestProseNeedsBothFields: a headline with no lede is half a voice. The guard // path is only taken when both are present; otherwise the whole template renders. func TestProseNeedsBothFields(t *testing.T) { const token = "t" s, _ := newAdvServer(t, token) f := AdvFact{ GUID: "arrival:z:2", EventType: "arrival", Tier: "bulletin", Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "human fighter", OccurredAt: 1, Headline: "Welcome, Zapp — a headline with no body.", // Lede intentionally empty. } if rw := postFact(t, s, token, f); rw.Code != 200 { t.Fatalf("ingest status = %d", rw.Code) } got, _ := storage.GetStoryByGUID(f.GUID) tHl, _, _ := renderAdventure(f) if got == nil || got.Headline != tHl { t.Errorf("half-authored prose was used; want template headline %q", tHl) } } func TestContainsWholeWord(t *testing.T) { cases := []struct { hay, needle string want bool }{ {"josie and kif split it", "kif", true}, {"the kiffish blade", "kif", false}, // substring, not a word {"al arrived", "al", true}, // short name, still bounded {"alabama arrived", "al", false}, // bounded off {"ended with kif.", "kif", true}, // trailing punctuation is a boundary {"名月 cleared it", "名月", true}, // non-ASCII name, bounded by space {"the 名月光 shard", "名月", false}, // non-ASCII substring, not a word {"nobody here", "kif", false}, // absent } for _, c := range cases { if got := containsWholeWord(c.hay, c.needle); got != c.want { t.Errorf("containsWholeWord(%q,%q) = %v, want %v", c.hay, c.needle, got, c.want) } } }