adventure: give every dispatch a card that looks like what it is
Every dispatch rendered the same image: one violet gradient, a swapped
emoji, a label. A death, a first-ever clear and a legendary hoard were
visually identical — and this image is not decoration. It is the
og:image on every link Pete puts in Matrix and the thumbnail on every
feed card. The most interesting thing that has ever happened in the
realm looked exactly like the most routine.
The card is now keyed on the dispatch GUID instead of the event type,
so the renderer can read the fact behind it and put the actual nouns on
it — the boss's name, the zone, the item, the level. Each family gets
its own palette, and treasure is tinted by the rarity gogobee already
computes and currently spends on an adjective in a sentence.
A realm-first gets visible ceremony. The priority/bulletin split is
already computed upstream and until now it only decided whether Matrix
got pinged; a thing nobody has ever done should also look different
from the ninth time somebody did it. It earns a ribbon on the card and
a badge and a heavier ring in the feed.
Siege cards carry the HP bar, which is the W1 deferral landing. The
siege fact has the boss and the defender count but never the HP, so the
bar comes from the war-room snapshot: the live row while that boss is
still camped, the history once it has closed. When neither has it yet —
the real two-minute window between a win being filed and the push that
explains it — the card renders barless rather than wrong, and caches
for five minutes instead of a day so the unfurl isn't pinned that way.
Nothing needs backfilling. A story with a type-keyed image_url still
serves, and a dispatch with no stored fact degrades to the old emblem.
Two things learned by looking at the rendered output rather than at the
tests, both of which unit tests would never have caught: the feed
thumbnail is a 16/10 crop of a 1200x630 card and was eating the chip
("EGENDARY"), hence advSafeX; and an empty bar under a victory headline
reads at a glance as a wipe, hence the event-aware caption.
Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// TestArtPaletteSplitsFamilies is the regression for the whole point of W3: two
|
||||
// different kinds of dispatch must not render the same image. Before this, every
|
||||
// card in the section was the same violet gradient with a swapped emoji.
|
||||
func TestArtPaletteSplitsFamilies(t *testing.T) {
|
||||
death := advPaletteFor("death", "")
|
||||
siege := advPaletteFor("siege_win", "")
|
||||
zone := advPaletteFor("zone_clear", "")
|
||||
if death == siege || siege == zone || death == zone {
|
||||
t.Errorf("families share a palette: death=%v siege=%v zone=%v", death, siege, zone)
|
||||
}
|
||||
|
||||
// Treasure is tinted by the rarity gogobee already computes and currently
|
||||
// spends on an adjective in a sentence.
|
||||
leg := advPaletteFor("treasure_found", "legendary")
|
||||
rare := advPaletteFor("treasure_found", "rare")
|
||||
if leg == rare {
|
||||
t.Errorf("legendary and rare hoards render identically: %v", leg)
|
||||
}
|
||||
// Case shouldn't matter — the rarity word arrives however gogobee wrote it.
|
||||
if advPaletteFor("treasure_found", "Legendary") != leg {
|
||||
t.Error("rarity match is case-sensitive")
|
||||
}
|
||||
// An unrecognised rarity still gets the story-grade treatment rather than
|
||||
// falling through to the neutral card.
|
||||
if advPaletteFor("treasure_found", "mythic") != leg {
|
||||
t.Error("unknown rarity dropped to the neutral palette")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealmFirstEarnsCeremony pins that the priority/bulletin split gogobee
|
||||
// already computes now changes how a card LOOKS, not just whether Matrix gets
|
||||
// pinged. A first-ever clear and the ninth repeat of it must be distinguishable.
|
||||
func TestRealmFirstEarnsCeremony(t *testing.T) {
|
||||
cases := []struct {
|
||||
eventType, tier string
|
||||
want bool
|
||||
}{
|
||||
{"zone_first", "priority", true},
|
||||
{"zone_clear", "bulletin", false},
|
||||
{"boss_first", "priority", true},
|
||||
{"boss_kill", "bulletin", false},
|
||||
{"treasure_found", "priority", true}, // realm-first hoard
|
||||
{"treasure_found", "bulletin", false}, // someone else already pulled it
|
||||
{"death", "priority", false}, // priority, but not a "first"
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := advIsRealmFirst(c.eventType, c.tier); got != c.want {
|
||||
t.Errorf("%s/%s ceremony = %v, want %v", c.eventType, c.tier, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// And it reaches the card as a ribbon.
|
||||
ev := &storage.AdvEvent{EventType: "zone_first", Tier: "priority", Subject: "Josie", Zone: "The Sump", Level: 9}
|
||||
card := advArtCardFor("zone_first", ev)
|
||||
if card.Ceremony == "" {
|
||||
t.Error("realm-first card has no ribbon")
|
||||
}
|
||||
if !strings.Contains(advRenderArt(card), card.Ceremony) {
|
||||
t.Error("ribbon text never reached the SVG")
|
||||
}
|
||||
plain := advArtCardFor("zone_clear", &storage.AdvEvent{EventType: "zone_clear", Tier: "bulletin", Subject: "Josie", Zone: "The Sump"})
|
||||
if plain.Ceremony != "" {
|
||||
t.Error("a repeat clear got the realm-first ribbon")
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtCardCarriesNouns: the card names the thing the dispatch is about, and
|
||||
// which field that is differs per family. A siege is about the boss; a treasure
|
||||
// is about the item.
|
||||
func TestArtCardCarriesNouns(t *testing.T) {
|
||||
treasure := advArtCardFor("treasure_found", &storage.AdvEvent{
|
||||
EventType: "treasure_found", Tier: "bulletin", Subject: "Josie",
|
||||
Zone: "The Sump", Stakes: "Ring of Nine Sorrows", Outcome: "epic", Level: 12,
|
||||
})
|
||||
if treasure.Noun != "Ring of Nine Sorrows" {
|
||||
t.Errorf("treasure noun = %q, want the item", treasure.Noun)
|
||||
}
|
||||
if treasure.Label != "EPIC" {
|
||||
t.Errorf("treasure label = %q, want the rarity", treasure.Label)
|
||||
}
|
||||
svg := advRenderArt(treasure)
|
||||
for _, want := range []string{"Ring of Nine Sorrows", "Josie", "The Sump", "level 12", "EPIC"} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Errorf("treasure card missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
boss := advArtCardFor("boss_kill", &storage.AdvEvent{
|
||||
EventType: "boss_kill", Subject: "Josie", Boss: "Aldric the Pale", Zone: "dragons_lair", Level: 14,
|
||||
})
|
||||
if boss.Noun != "Aldric the Pale" {
|
||||
t.Errorf("boss noun = %q, want the boss", boss.Noun)
|
||||
}
|
||||
|
||||
// A dispatch with no stored fact still renders — that is every story from
|
||||
// before the fact table, plus the window where the best-effort fact insert
|
||||
// failed. It degrades to the old emblem, not to a broken image.
|
||||
bare := advArtCardFor("death", nil)
|
||||
if bare.Noun != "" || bare.Label == "" {
|
||||
t.Errorf("factless card = %+v, want label-only", bare)
|
||||
}
|
||||
if b := advRenderArt(bare); !strings.Contains(b, "<svg") || !strings.Contains(b, "🪦") {
|
||||
t.Errorf("factless card didn't render an emblem: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSiegeCardDrawsTheBar is the W1 deferral landing. A Matrix unfurl of "the
|
||||
// town holds" that shows the bar is worth ten paragraphs — and the bar has to
|
||||
// come from the war-room snapshot, because the siege fact carries the boss and
|
||||
// the defender count but never the HP.
|
||||
func TestSiegeCardDrawsTheBar(t *testing.T) {
|
||||
newAdvServer(t, "t") // fresh temp DB
|
||||
|
||||
if err := storage.ReplaceSiege(storage.Siege{
|
||||
Active: false,
|
||||
History: []storage.SiegePast{{
|
||||
BossID: 7, BossName: "The Rust Sovereign", Tier: 5, Outcome: "defeated",
|
||||
HPRemaining: 0, HPMax: 18000, Defenders: 6, EndedAt: 5000,
|
||||
}},
|
||||
}, 5000); err != nil {
|
||||
t.Fatalf("seed siege: %v", err)
|
||||
}
|
||||
|
||||
ev := &storage.AdvEvent{EventType: "siege_win", Tier: "priority",
|
||||
Boss: "The Rust Sovereign", Tally: 6, OccurredAt: 5000}
|
||||
card := advArtCardFor("siege_win", ev)
|
||||
if card.Bar == nil {
|
||||
t.Fatal("siege card has no bar")
|
||||
}
|
||||
if card.Bar.Max != 18000 {
|
||||
t.Errorf("bar max = %d, want the pool", card.Bar.Max)
|
||||
}
|
||||
svg := advRenderArt(card)
|
||||
for _, want := range []string{"The Rust Sovereign", "6 defenders", "brought to zero"} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Errorf("siege card missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// A live Siege draws off the live row, so a siege_start card keeps sliding
|
||||
// as the town chips away rather than freezing at the spawn value.
|
||||
if err := storage.ReplaceSiege(storage.Siege{
|
||||
Active: true, BossID: 8, BossName: "The Rust Sovereign", Tier: 5,
|
||||
HPCurrent: 6200, HPMax: 18000, EndsAt: 9000,
|
||||
}, 6000); err != nil {
|
||||
t.Fatalf("seed live siege: %v", err)
|
||||
}
|
||||
live := advArtCardFor("siege_start", &storage.AdvEvent{
|
||||
EventType: "siege_start", Boss: "The Rust Sovereign", OccurredAt: 6000})
|
||||
if live.Bar == nil || live.Bar.Current != 6200 {
|
||||
t.Fatalf("live siege bar = %+v, want the current pool", live.Bar)
|
||||
}
|
||||
|
||||
// An unknown boss gets a siege card with no bar rather than a wrong one.
|
||||
// This is the real window between a win being filed and the war-room push
|
||||
// that explains it landing two minutes later.
|
||||
orphan := advArtCardFor("siege_loss", &storage.AdvEvent{
|
||||
EventType: "siege_loss", Boss: "Nobody In Particular", OccurredAt: 1})
|
||||
if orphan.Bar != nil {
|
||||
t.Errorf("drew a bar for a boss we have no snapshot of: %+v", orphan.Bar)
|
||||
}
|
||||
if !strings.Contains(advRenderArt(orphan), "Nobody In Particular") {
|
||||
t.Error("barless siege card lost its boss name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtRenderIsDeterministicAndEscaped: the card is a public URL served as an
|
||||
// image, and it must be byte-stable so it can be cached hard.
|
||||
func TestArtRenderIsDeterministicAndEscaped(t *testing.T) {
|
||||
card := advArtCardFor("death", &storage.AdvEvent{
|
||||
EventType: "death", Subject: `Bob<script>alert(1)</script>`, Zone: "the Underforge", Level: 3})
|
||||
a := advRenderArt(card)
|
||||
if a != advRenderArt(card) {
|
||||
t.Error("render is not deterministic")
|
||||
}
|
||||
if strings.Contains(a, "<script>") {
|
||||
t.Errorf("unescaped markup reached the SVG: %s", a)
|
||||
}
|
||||
|
||||
// Long names shrink to fit instead of running off the 1200px card.
|
||||
long := advArtCardFor("boss_kill", &storage.AdvEvent{
|
||||
EventType: "boss_kill", Boss: strings.Repeat("Nebuchadnezzar ", 6)})
|
||||
if got := advFitSize(long.Noun, 1060, 82, 40); got >= 82 {
|
||||
t.Errorf("long name kept the full font size (%d)", got)
|
||||
}
|
||||
if n := len([]rune(advClamp(long.Noun, 38))); n > 38 {
|
||||
t.Errorf("clamp let %d runes through", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCardAccentTintsTheFeed pins the border tint the feed cards read. It is the
|
||||
// same palette the art uses, so a card and its thumbnail agree.
|
||||
func TestCardAccentTintsTheFeed(t *testing.T) {
|
||||
legendary, first := advCardAccent("treasure_found", "priority", "legendary")
|
||||
if legendary == "" || !first {
|
||||
t.Errorf("legendary realm-first accent = %q ceremony=%v", legendary, first)
|
||||
}
|
||||
common, _ := advCardAccent("treasure_found", "bulletin", "common")
|
||||
if common == legendary {
|
||||
t.Error("a common find is tinted like a legendary one")
|
||||
}
|
||||
if a, _ := advCardAccent("", "", ""); a != "" {
|
||||
t.Error("a non-adventure story got an accent")
|
||||
}
|
||||
if legendary != advPaletteFor("treasure_found", "legendary").Accent {
|
||||
t.Error("feed accent and card art disagree on the colour")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedCardRendersTheTint renders the real adventure page through the real
|
||||
// template, because the interesting failure here is not in Go.
|
||||
//
|
||||
// html/template escapes a style attribute in CSS context and will replace a
|
||||
// value it doesn't trust with ZgotmplZ, silently — the card would render with no
|
||||
// border colour and nothing would say why. This is also the reason the accent is
|
||||
// an inline style at all: a generated Tailwind class would be purged out of the
|
||||
// stylesheet, which fails the same way and just as quietly.
|
||||
func TestFeedCardRendersTheTint(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "t")
|
||||
|
||||
first := AdvFact{GUID: "zone_first:a:1000", EventType: "zone_first", Tier: "priority",
|
||||
Actors: []string{"Josie"}, Subject: "Josie", Zone: "The Sump", Region: "Marches", Level: 9, OccurredAt: 1000}
|
||||
repeat := AdvFact{GUID: "zone_clear:b:1001", EventType: "zone_clear", Tier: "bulletin",
|
||||
Actors: []string{"Josie"}, Subject: "Josie", Zone: "The Sump", Region: "Marches", Level: 9, OccurredAt: 1001}
|
||||
for _, f := range []AdvFact{first, repeat} {
|
||||
if rw := postFact(t, s, "t", f); rw.Code != 200 {
|
||||
t.Fatalf("ingest %s: status %d", f.GUID, rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/adventure", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleChannel(w, req, Channel{Slug: "adventure", Title: "Adventure", Theme: "adventure"})
|
||||
body := w.Body.String()
|
||||
|
||||
accent := advPaletteFor("zone_first", "").Accent
|
||||
if !strings.Contains(body, "border-color:"+accent) {
|
||||
t.Errorf("zone accent %q never reached the card border", accent)
|
||||
}
|
||||
if strings.Contains(body, "ZgotmplZ") {
|
||||
t.Error("html/template rejected the accent as an unsafe CSS value")
|
||||
}
|
||||
if !strings.Contains(body, "Realm first") {
|
||||
t.Error("the realm-first card has no ribbon in the feed")
|
||||
}
|
||||
// Exactly one of the two cards is a first. If both got the badge the split
|
||||
// isn't doing anything.
|
||||
if n := strings.Count(body, "Realm first"); n != 1 {
|
||||
t.Errorf("realm-first badges = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommaFormatsPools(t *testing.T) {
|
||||
for in, want := range map[int]string{0: "0", 999: "999", 1000: "1,000", 18000: "18,000", 1234567: "1,234,567"} {
|
||||
if got := advComma(in); got != want {
|
||||
t.Errorf("advComma(%d) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user