Files
Pete/internal/web/run_report_test.go
T
prosolis b4a276da36 adventure: give a finished run a report worth sharing
The liveblog answers "what is happening" — it is capped, it scrolls, and six
hours after a run ends it is gone, because the adventurer page is about now.
Nothing answered the question asked afterwards, usually by somebody who wasn't
watching: what WAS that run. So a dispatch announcing a clear or a death was a
paragraph about an outcome with no way back to what produced it.

The report is that way back. The whole log uncapped, the numbers rolled up, and
the single worst hit the party took pulled out of the middle where it otherwise
reads as one line among forty. It is stable for a fortnight, which is what makes
it a thing worth linking from a dispatch and worth sending to somebody.

It is assembled from the same beats through the same renderer as the liveblog.
A report that told a different story from the log it was built out of would be
the more convincing of the two and the less true.

The summary is the exception and the only prose on the channel: gogobee's model
reads the finished run back and says what it was about, which is a judgement no
template makes. It rides a summary beat rather than its own endpoint, so it
inherits the whole channel — idempotent, retried, impossible to attach to a run
that doesn't exist — and it passes the same class of guard a dispatch lede does
before it reaches a public page.

Visibility is the adventurer page's rule exactly, and that matters more here
than anywhere: the report outlives the log by a fortnight and is linked from a
public dispatch, so it is the surface most likely to still be reachable after
somebody opts out. Coming off the board closes it, including through links
minted days earlier.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 17:11:04 -07:00

356 lines
14 KiB
Go

package web
import (
"net/http/httptest"
"strings"
"testing"
"time"
"pete/internal/storage"
)
// onBoard puts the run's owner on the roster. Every report test needs it: the
// report's visibility gate is the adventurer page's, and with no board at all
// every token reads as opted-out.
func onBoard(t *testing.T, s *Server, ingest, token, name string) {
t.Helper()
if w := postRoster(t, s, ingest, rosterPush{
SnapshotAt: time.Now().Unix(),
Adventurers: []storage.RosterEntry{entry(token, name, "idle", "")},
}); w.Code != 200 {
t.Fatalf("roster push failed: %d %s", w.Code, w.Body.String())
}
}
// aFinishedRun is a small but realistic expedition: two fights, a trap that hurt
// more than either of them, a find, and a clean ending.
func aFinishedRun(now int64) []storage.RunBeat {
return []storage.RunBeat{
startBeat(now),
{RunID: "run-1", Seq: 2, Kind: "combat", OccurredAt: now + 60, Room: 2, TotalRooms: 9,
Target: "Bone Chanter", Outcome: "won", Amount: 7, HP: 61, HPMax: 68, Crits: 1},
{RunID: "run-1", Seq: 3, Kind: "trap", OccurredAt: now + 120, Room: 3, TotalRooms: 9,
RoomKind: "trap", Outcome: "sprung", Amount: 22, HP: 39, HPMax: 68},
{RunID: "run-1", Seq: 4, Kind: "treasure", OccurredAt: now + 200, Room: 4, TotalRooms: 9,
Target: "Ashlight Pendant", Outcome: "cache"},
{RunID: "run-1", Seq: 5, Kind: "combat", OccurredAt: now + 300, Room: 5, TotalRooms: 9,
RoomKind: "boss", Target: "Valdris", Outcome: "won", Amount: 12, HP: 27, HPMax: 68},
{RunID: "run-1", Seq: 6, Kind: "end", OccurredAt: now + 360, Room: 5, TotalRooms: 9,
Outcome: "cleared"},
}
}
func getReport(t *testing.T, s *Server, runID string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("GET", "/adventure/run/"+runID, nil)
req.SetPathValue("run_id", runID)
w := httptest.NewRecorder()
s.handleRunReport(w, req)
return w
}
// TestRunReportRendersTheWholeRun. The liveblog is capped and expires; the
// report is the artefact, so what it has to get right is that everything is
// there — every beat, the rollup, and the moment it turned.
func TestRunReportRendersTheWholeRun(t *testing.T) {
const token = "tok"
s, _ := newAdvServer(t, token)
now := time.Now().Unix()
onBoard(t, s, token, "tok-abc", "Josie")
postBeats(t, s, token, aFinishedRun(now)...)
w := getReport(t, s, "run-1")
if w.Code != 200 {
t.Fatalf("report: %d %s", w.Code, w.Body.String())
}
body := w.Body.String()
for _, want := range []string{
"Josie in Crypt of Valdris",
"Cleared it",
"Bone Chanter down",
"Trap sprung — 22 damage",
"Found Ashlight Pendant",
"Valdris down",
"Run complete",
"2/2", // fights won, as one tile rather than two
"41", // damage taken: 7 + 22 + 12
"Where it turned", // the trap, being the biggest single hit
} {
if !strings.Contains(body, want) {
t.Errorf("report is missing %q", want)
}
}
}
// TestTurningPointIsTheBiggestHit. The plan's word for it is "turning point" and
// the temptation is to pick the boss, because a boss is the most *important*
// thing in a run. It isn't the thing that turned it: a boss killed without a
// scratch turned nothing, and the trap two rooms earlier that took a third of
// the party's health is the beat the reader is looking for.
func TestTurningPointIsTheBiggestHit(t *testing.T) {
now := time.Now().Unix()
run := storage.Run{RunID: "r", Token: "t", Name: "Josie", Zone: "Crypt", TotalRooms: 9,
StartedAt: now, EndedAt: now + 360, Outcome: "cleared"}
v := buildRunReport(run, aFinishedRun(now))
if v.Turning == nil {
t.Fatal("no turning point on a run with a 22-damage trap in it")
}
if !strings.Contains(v.Turning.Text, "Trap sprung") {
t.Errorf("turning point = %q, want the trap (22) over the boss (12)", v.Turning.Text)
}
// A run where nothing landed has no turning point rather than a made-up one.
quiet := []storage.RunBeat{
{RunID: "r", Seq: 1, Kind: "start", OccurredAt: now, Zone: "Crypt", TotalRooms: 3},
{RunID: "r", Seq: 2, Kind: "combat", OccurredAt: now + 10, Target: "Rat", Outcome: "won"},
{RunID: "r", Seq: 3, Kind: "end", OccurredAt: now + 20, Outcome: "cleared"},
}
if q := buildRunReport(run, quiet); q.Turning != nil {
t.Errorf("invented a turning point on an untouched run: %q", q.Turning.Text)
}
}
// TestHowFarTheyGotOnlyMattersWhenTheyFellShort. A dungeon graph forks, so a run
// that cleared it never walks every room — "room 7 / 9" printed under the words
// "Cleared it" says they came up two short of something they in fact finished.
// On a run that ended badly the same number is the whole story.
func TestHowFarTheyGotOnlyMattersWhenTheyFellShort(t *testing.T) {
now := time.Now().Unix()
beats := aFinishedRun(now)
base := storage.Run{RunID: "r", Token: "t", Name: "Josie", Zone: "Crypt", TotalRooms: 9,
StartedAt: now, EndedAt: now + 360}
cleared := base
cleared.Outcome = "cleared"
if v := buildRunReport(cleared, beats); v.Rooms != "" {
t.Errorf("a cleared run advertised how far it got: %q", v.Rooms)
}
died := base
died.Outcome = "died"
if v := buildRunReport(died, beats); v.Rooms != "got as far as room 5 of 9" {
t.Errorf("Rooms = %q, want the depth on a run that ended badly", v.Rooms)
}
}
// TestRunStatsSkipTheZeroes. The tile row is meant to say what THIS run was. A
// run that sprung no traps and found no treasure rendering two confident zeroes
// makes every run look identical, which is the exact failure the report exists
// to fix.
func TestRunStatsSkipTheZeroes(t *testing.T) {
now := time.Now().Unix()
stats := runStats([]storage.RunBeat{
{Kind: "combat", Outcome: "won", Target: "Rat", Amount: 3, OccurredAt: now},
{Kind: "trap", Outcome: "avoided", Amount: 0, OccurredAt: now + 1}, // stepped over it
})
for _, s := range stats {
if strings.Contains(s.Label, "trap") {
t.Errorf("a trap that was avoided produced a tile: %+v", s)
}
if strings.Contains(s.Label, "treasure") {
t.Errorf("a run with no finds produced a treasure tile: %+v", s)
}
if strings.Contains(s.Label, "critical") {
t.Errorf("a run with no crits produced a crit tile: %+v", s)
}
}
if len(stats) != 2 { // fights won + damage taken
t.Fatalf("want 2 tiles, got %d: %+v", len(stats), stats)
}
}
// TestRunReportIsGatedOnTheBoard is the report's half of TestOffTheBoardShipsNoLog.
//
// The report outlives the liveblog by a fortnight and is linked from a public
// dispatch, so it is the surface most likely to still be reachable after somebody
// opts out. Coming off the board is what an opt-out looks like from Pete's side,
// and from that moment a room-by-room account of where they went has to stop
// resolving — including through the link a dispatch minted days earlier.
func TestRunReportIsGatedOnTheBoard(t *testing.T) {
const token = "tok"
s, _ := newAdvServer(t, token)
now := time.Now().Unix()
onBoard(t, s, token, "tok-abc", "Josie")
postBeats(t, s, token, aFinishedRun(now)...)
if w := getReport(t, s, "run-1"); w.Code != 200 {
t.Fatalf("report should serve while its owner is on the board: %d", w.Code)
}
ev := &storage.AdvEvent{GUID: "zone_clear:x:1", EventType: "zone_clear", Subject: "Josie",
RunID: "run-1", OccurredAt: now}
if err := storage.InsertAdventureEvent(ev); err != nil {
t.Fatal(err)
}
if link := runReportLinkFor(ev); link != "/adventure/run/run-1" {
t.Fatalf("dispatch link = %q, want the report path", link)
}
// They opt out: gogobee stops sending them, so the next board has no such
// token. The beats Pete already holds are append-only and can't be recalled —
// what has to happen is that they become unreachable.
if w := postRoster(t, s, token, rosterPush{
SnapshotAt: now + 1,
Adventurers: []storage.RosterEntry{entry("someone-else", "Quack", "idle", "")},
}); w.Code != 200 {
t.Fatalf("roster push failed: %d", w.Code)
}
if w := getReport(t, s, "run-1"); w.Code != 404 {
t.Errorf("report still served after its owner left the board: %d", w.Code)
}
if link := runReportLinkFor(ev); link != "" {
t.Errorf("dispatch still offers a link to an opted-out player's run: %q", link)
}
}
// TestUnattributedRunHasNoReport. A run whose `start` beat never arrived still
// gets a readable log — that is deliberate, and W2a pinned it. But it has no
// token, so Pete cannot establish whose run it is, and "don't know" is not a
// basis on which to publish where somebody went.
func TestUnattributedRunHasNoReport(t *testing.T) {
const token = "tok"
s, _ := newAdvServer(t, token)
now := time.Now().Unix()
onBoard(t, s, token, "tok-abc", "Josie")
postBeats(t, s, token,
storage.RunBeat{RunID: "orphan", Seq: 2, Kind: "combat", OccurredAt: now,
Room: 2, TotalRooms: 9, Target: "Bone Chanter", Outcome: "won"},
storage.RunBeat{RunID: "orphan", Seq: 3, Kind: "end", OccurredAt: now + 5, Outcome: "cleared"},
)
if w := getReport(t, s, "orphan"); w.Code != 404 {
t.Errorf("served a report for a run with no owner: %d", w.Code)
}
}
// TestRunSummaryIsGuardedLikeADispatch. The summary is the only prose on the
// beat channel and it is LLM output over player-chosen names, so the field
// checks that make a *fact* safe are worth nothing here — the words are the
// thing being rendered. A summary that names a different adventurer on the board
// is either a hallucination or an injection, and both get the same answer: keep
// the report, drop the prose.
func TestRunSummaryIsGuardedLikeADispatch(t *testing.T) {
const token = "tok"
s, _ := newAdvServer(t, token)
now := time.Now().Unix()
if w := postRoster(t, s, token, rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
entry("tok-abc", "Josie", "idle", ""),
entry("tok-def", "Quack", "idle", ""),
}}); w.Code != 200 {
t.Fatalf("roster push failed: %d", w.Code)
}
postBeats(t, s, token, aFinishedRun(now)...)
// Names a bystander who was never on this expedition.
if w := postBeats(t, s, token, storage.RunBeat{
RunID: "run-1", Seq: 7, Kind: "summary", OccurredAt: now + 400, Name: "Josie",
Prose: "Josie and Quack went down into the crypt together and only one came back.",
}); w.Code != 200 {
t.Fatalf("a rejected summary should still be a 200: %d %s", w.Code, w.Body.String())
}
run, _, err := storage.RunByID("run-1")
if err != nil {
t.Fatal(err)
}
if run.Summary != "" {
t.Errorf("guard let through a summary naming a bystander: %q", run.Summary)
}
// The same beat, about the right person only. The seq differs because the
// rejected row is still stored — that is what stops gogobee re-authoring it
// forever — so a retry has to be a new beat.
good := "Josie took a bad trap on the way in and finished the boss on a quarter of her health."
if w := postBeats(t, s, token, storage.RunBeat{
RunID: "run-1", Seq: 8, Kind: "summary", OccurredAt: now + 401, Name: "Josie", Prose: good,
}); w.Code != 200 {
t.Fatalf("summary rejected: %d %s", w.Code, w.Body.String())
}
run, _, err = storage.RunByID("run-1")
if err != nil {
t.Fatal(err)
}
if run.Summary != good {
t.Errorf("summary = %q, want it stored", run.Summary)
}
// And it renders on the report, above the log rather than inside it.
w := getReport(t, s, "run-1")
if !strings.Contains(w.Body.String(), good) {
t.Error("the summary didn't reach the report page")
}
v := runLogFor("tok-abc")
for _, ln := range v.Lines {
if strings.Contains(ln.Text, "bad trap on the way in") {
t.Errorf("the summary rendered as a log line: %q", ln.Text)
}
}
}
// TestOnlyTheSummaryBeatCarriesProse. The guard at ingest only inspects the kind
// it knows about, so any other kind arriving with prose would put unguarded text
// onto the header. Both halves have to hold — the beat must be scrubbed, and the
// header fold must ignore it even if it weren't.
func TestOnlyTheSummaryBeatCarriesProse(t *testing.T) {
const token = "tok"
s, _ := newAdvServer(t, token)
now := time.Now().Unix()
onBoard(t, s, token, "tok-abc", "Josie")
postBeats(t, s, token, startBeat(now), storage.RunBeat{
RunID: "run-1", Seq: 2, Kind: "combat", OccurredAt: now + 10,
Target: "Bone Chanter", Outcome: "won",
Prose: "and then Quack showed up out of nowhere",
})
run, _, err := storage.RunByID("run-1")
if err != nil {
t.Fatal(err)
}
if run.Summary != "" {
t.Errorf("a combat beat wrote the run summary: %q", run.Summary)
}
beats, err := storage.RunBeats("run-1", 0)
if err != nil {
t.Fatal(err)
}
for _, b := range beats {
if b.Prose != "" {
t.Errorf("beat %d (%s) kept prose it isn't allowed to carry: %q", b.Seq, b.Kind, b.Prose)
}
}
}
// TestFinishedRunOffersItsReport / a live one doesn't. While a run is still
// walking, the column on the adventurer page IS the report; a link to a second
// copy of what somebody is already reading is only a way to lose them.
func TestFinishedRunOffersItsReport(t *testing.T) {
const token = "tok"
s, _ := newAdvServer(t, token)
now := time.Now().Unix()
onBoard(t, s, token, "tok-abc", "Josie")
postBeats(t, s, token, startBeat(now))
if v := runLogFor("tok-abc"); v.ReportURL != "" {
t.Errorf("a live run offered a report link: %q", v.ReportURL)
}
postBeats(t, s, token, storage.RunBeat{
RunID: "run-1", Seq: 9, Kind: "end", OccurredAt: now + 60, Outcome: "cleared"})
if v := runLogFor("tok-abc"); v.ReportURL != "/adventure/run/run-1" {
t.Errorf("ReportURL = %q, want the report path once the run is over", v.ReportURL)
}
}
// TestRunElapsedFallsBackToTheBeats. started_at comes off the `start` beat, so a
// run that lost it has a zero clock on the header and would otherwise report no
// duration at all — on precisely the run where the log is the only record there is.
func TestRunElapsedFallsBackToTheBeats(t *testing.T) {
now := time.Now().Unix()
beats := []storage.RunBeat{
{RunID: "r", Seq: 2, Kind: "combat", OccurredAt: now, Target: "Rat", Outcome: "won"},
{RunID: "r", Seq: 3, Kind: "end", OccurredAt: now + 5400, Outcome: "cleared"},
}
// No StartedAt: the beat that would have set it never arrived.
got := runElapsed(storage.Run{RunID: "r", EndedAt: now + 5400}, beats)
if got != "1h 30m" {
t.Errorf("elapsed = %q, want 1h 30m off the beats", got)
}
}