package web import ( "encoding/json" "strings" "testing" "time" "pete/internal/storage" ) // The two W7 additions to the adventurer page: the party roster (public, rides // the roster detail) and pet levelling (owner-only, rides the self detail). Both // are driven through the real templates, so a field slip 500s here. // seedPartyWho puts Josie on the board mid-run with a three-seat party: her, a // named companion player, an opted-out player (anonymised upstream: kind but no // name), and the hireling. func seedPartyWho(t *testing.T) *Server { s, _ := newAdvServer(t, "tok") s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} raw, err := json.Marshal(map[string]any{ "hp_current": 30, "hp_max": 42, "armor_class": 17, "abilities": [6]int{16, 14, 15, 10, 12, 8}, "modifiers": [6]int{3, 2, 2, 0, 1, -1}, "supplies": 8, "room": "3 / 7", "party": []map[string]any{ {"kind": "leader", "name": "Josie", "token": "tok-josie", "level": 14}, {"kind": "member", "name": "Camcast", "token": "tok-cam", "level": 12}, {"kind": "member"}, // opted out: anonymised upstream {"kind": "companion", "name": "Pete"}, }, }) if err != nil { t.Fatal(err) } e := entry("tok-josie", "Josie", "expedition", "holymachina") e.Level = 14 e.ClassRace = "human cleric" e.Detail = raw if w := postRoster(t, s, "tok", rosterPush{ SnapshotAt: time.Now().Unix(), Adventurers: []storage.RosterEntry{e}, }); w.Code != 200 { t.Fatalf("seed roster = %d", w.Code) } return s } // TestWhoDrawsThePartyWithoutNamingAnOptOut. The roster has to add up — a party // of four rendered as three contradicts the supply burn printed beside it — while // the seat belonging to a player who keeps out of the news carries no name and no // link out. func TestWhoDrawsThePartyWithoutNamingAnOptOut(t *testing.T) { s := seedPartyWho(t) w := getWho(t, s, "tok-josie", "") if w.Code != 200 { t.Fatalf("GET who = %d, want 200", w.Code) } body := w.Body.String() for _, want := range []string{"Down there together", "Camcast", "Pete", "leader", "companion"} { if !strings.Contains(body, want) { t.Errorf("party block missing %q", want) } } // Four seats drawn, not three. if got := strings.Count(body, `class="party-seat"`); got != 4 { t.Errorf("drew %d seats, want 4 — an anonymised seat must still occupy a chair", got) } if !strings.Contains(body, "keeps out of the news") { t.Error("the anonymised seat rendered as nothing at all") } // The named member links to their own page; the hireling and the anonymous // seat have nothing to link to. if !strings.Contains(body, `/adventure/who/tok-cam`) { t.Error("a named party member is not linked to their page") } // Exactly two seat links: the two seats carrying tokens. The hireling and the // anonymised seat must be text, not doors. (Counted on the href so the page's // own /api/adventure/who/ poll URL does not register as a link.) if got := strings.Count(body, `href="/adventure/who/`); got != 2 { t.Errorf("seat links = %d, want 2 — only a named seat may link out", got) } } // TestSoloRunDrawsNoPartyBlock: the block is the answer to "is anybody else down // there", so on a solo run it must not appear at all rather than list one chair. func TestSoloRunDrawsNoPartyBlock(t *testing.T) { s := seedWho(t, "josie") // no party in its detail blob w := getWho(t, s, "tok-josie", "") if w.Code != 200 { t.Fatalf("GET who = %d", w.Code) } if strings.Contains(w.Body.String(), "Down there together") { t.Error("a solo run drew a party block") } } // TestPetLevellingIsVisibleToTheOwner. Pets have been earning XP from every won // fight since that wiring was fixed and no web surface has ever shown it. The // unit matters more than the bar: XP arrives as centi-XP, so a page that printed // it raw would tell somebody their cat has 750 of 20 points. func TestPetLevellingIsVisibleToTheOwner(t *testing.T) { s, _ := newAdvServer(t, "tok") s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} now := time.Now().Unix() e := entry("tok-josie", "Josie", "idle", "") e.Detail = publicDetail(t) if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 { t.Fatalf("seed roster = %d", w.Code) } if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{ Localpart: "josie", Token: "tok-josie", Pets: []storage.PetView{ {Type: "cat", Name: "Mittens", Level: 4, XP: 750, XPNeeded: 2000}, {Type: "dog", Name: "Rex", Level: 10}, // capped: nothing left to earn }, }}}); w.Code != 200 { t.Fatalf("seed detail = %d", w.Code) } body := getWho(t, s, "tok-josie", "josie").Body.String() if !strings.Contains(body, "7.5 / 20 to level 5") { t.Error("Mittens' progress is not rendered in whole XP — check the centi-XP divide") } if strings.Contains(body, "750") { t.Error("raw centi-XP leaked onto the page") } if !strings.Contains(body, "fully grown") { t.Error("a capped pet has no label saying why its bar is full") } if !strings.Contains(body, "pet-xp-capped") { t.Error("a capped pet's bar is styled like an in-progress one") } // A capped pet must not render an empty track, which reads as the opposite of // what it means. if strings.Contains(body, `class="pet-xp-fill" style="width: 0%"`) { t.Error("a capped pet drew an empty bar") } // And none of it leaks to a visitor: pets are owner-only. if strings.Contains(getWho(t, s, "tok-josie", "").Body.String(), "Mittens") { t.Error("a visitor can see the owner's pets") } }