N4/E3: town registries — !town, !graveyard, !rivals board

Surface social data the game already stores as three read-only boards:

- !town — civic pride (top tax_ledger contributors), housing street
  (name + tier), pet showcase (name/type/level/barding).
- !graveyard — recent deaths across the guild (death_source/location,
  still-resting vs recovered), St. Guildmore's caretaker voice.
- !rivals board — room-wide duel standings aggregated from the directed
  adventure_rival_records ledger; bare !rivals keeps the per-user view.

All read-only: no schema, no combat, golden byte-identical. Enumerate off
player_meta / tax_ledger / adventure_rival_records with COALESCE'd display
names. Render layer is client-free and unit-tested; a DB-backed loader test
exercises the real schema (caught a MAX(datetime) affinity issue — parsed by
hand via parseSQLiteTime).

Leak-check (engagement plan §E3): the civic board ranks tax_ledger.total_paid,
which is gambling/shop/arena rake only — Misty/Arina donations go through
euro.Debit with their own reason strings and their counters live in separate
player_meta columns, so no board can correlate donations with the hidden buffs.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 14:31:59 -07:00
parent 476384206e
commit 27d6bfd361
3 changed files with 706 additions and 0 deletions

View File

@@ -180,6 +180,9 @@ func (p *AdventurePlugin) Commands() []CommandDef {
{Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|<recipe>]", Category: "Games"},
{Name: "lore", Description: "Dig through Thom Krooke's lore stacks for a new recipe (sharp minds turn up more)", Usage: "!lore", Category: "Games"},
{Name: "give", Description: "Gift a consumable or non-magic item to another adventurer (5% handling fee to the community pot; 3/day)", Usage: "!give <item> @user", Category: "Games"},
{Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"},
{Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"},
{Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"},
}
}
@@ -421,6 +424,17 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
return p.handleGiveCmd(ctx)
}
// 0b. Town registries (E3) — read-only social boards (rooms and DMs)
if p.IsCommand(ctx.Body, "town") {
return p.handleTownCmd(ctx)
}
if p.IsCommand(ctx.Body, "graveyard") {
return p.handleGraveyardCmd(ctx)
}
if p.IsCommand(ctx.Body, "rivals") {
return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals"))
}
// 1. Arena commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "bail") {
return p.handleArenaBail(ctx)

View File

@@ -0,0 +1,458 @@
package plugin
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"gogobee/internal/db"
)
// E3 — Surfacing the buried social data. Three read-only registries that
// render data the game already stores: !town (civic pride + housing + pets),
// !graveyard (recent deaths), and !rivals board (room-wide duel standings).
//
// Leak-check (gogobee_engagement_plan.md §E3): the civic-pride board ranks
// tax_ledger.total_paid, which is dominated by gambling/shop/arena rake and
// carries ZERO Misty/Arina donation signal — those debits never touch
// tax_ledger (adventure_npcs.go routes them through euro.Debit with their own
// reason strings, counters live in player_meta.misty_*/arina_* columns). So no
// board here can let a player correlate donations with the hidden NPC buffs.
const (
// townCivicBoardLimit caps the civic-pride board.
townCivicBoardLimit = 10
// townListCap caps the housing/pet showcase lists.
townListCap = 15
// graveyardLimit caps the graveyard.
graveyardLimit = 12
// rivalsBoardLimit caps the room-wide rivalry standings.
rivalsBoardLimit = 10
)
// ── Civic pride ──────────────────────────────────────────────────────────────
type civicEntry struct {
Name string
TotalPaid int64
}
// loadCivicPrideBoard returns the top contributors to the community pot by
// lifetime tax paid. Safe to rank — see the leak-check note above.
func loadCivicPrideBoard(limit int) ([]civicEntry, error) {
rows, err := db.Get().Query(
`SELECT COALESCE(NULLIF(c.display_name, ''), t.user_id), t.total_paid
FROM tax_ledger t
LEFT JOIN player_meta c ON c.user_id = t.user_id
WHERE t.total_paid > 0
ORDER BY t.total_paid DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []civicEntry
for rows.Next() {
var e civicEntry
if err := rows.Scan(&e.Name, &e.TotalPaid); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// ── Housing street ───────────────────────────────────────────────────────────
type housingEntry struct {
Name string
Tier int
}
// loadHousingStreet returns everyone who owns a house, best homes first.
func loadHousingStreet(limit int) ([]housingEntry, error) {
rows, err := db.Get().Query(
`SELECT COALESCE(NULLIF(display_name, ''), user_id), house_tier
FROM player_meta
WHERE house_tier > 0
ORDER BY house_tier DESC, display_name
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []housingEntry
for rows.Next() {
var e housingEntry
if err := rows.Scan(&e.Name, &e.Tier); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// ── Pet showcase ─────────────────────────────────────────────────────────────
type petShowcaseEntry struct {
Name string
Type string
Level int
ArmorName string
}
// loadPetShowcase returns every active pet (arrived, not chased away). The
// arrived/chased flags live in pet_flags_json, so they're decoded in Go rather
// than in SQL.
func loadPetShowcase(limit int) ([]petShowcaseEntry, error) {
rows, err := db.Get().Query(
`SELECT COALESCE(NULLIF(display_name, ''), user_id),
pet_name, pet_type, pet_level, pet_armor_tier, pet_flags_json
FROM player_meta
WHERE pet_type != ''
ORDER BY pet_level DESC, pet_name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []petShowcaseEntry
for rows.Next() {
var (
owner, petName, petType, flagsRaw string
level, armorTier int
)
if err := rows.Scan(&owner, &petName, &petType, &level, &armorTier, &flagsRaw); err != nil {
return nil, err
}
var flags petFlagsJSON
if flagsRaw != "" {
_ = json.Unmarshal([]byte(flagsRaw), &flags)
}
if !flags.Arrived || flags.ChasedAway {
continue
}
out = append(out, petShowcaseEntry{
Name: firstNonEmpty(petName, "an unnamed companion") + " (" + owner + ")",
Type: petType,
Level: level,
ArmorName: petArmorName(petType, armorTier),
})
if len(out) >= limit {
break
}
}
return out, rows.Err()
}
// petArmorName maps a pet's armor tier to its barding name, or "" for none.
func petArmorName(petType string, tier int) string {
if tier <= 0 {
return ""
}
for _, a := range petArmorDefs(petType) {
if a.Tier == tier {
return a.Name
}
}
return ""
}
// ── Graveyard ────────────────────────────────────────────────────────────────
type graveEntry struct {
Name string
Source string // "adventure" | "arena" | ""
Location string
LastDeathDate string // "2006-01-02"
StillDown bool // alive == 0 (inside the 6h respawn window)
}
// loadGraveyard returns the most recent deaths across all players. Deaths are
// day-granularity (last_death_date), so ordering falls back to dead_until for
// the still-in-the-ground; a revived player keeps their last_death_date but
// clears dead_until.
func loadGraveyard(limit int) ([]graveEntry, error) {
rows, err := db.Get().Query(
`SELECT COALESCE(NULLIF(display_name, ''), user_id),
alive, death_source, death_location, last_death_date, dead_until
FROM player_meta
WHERE last_death_date != ''
ORDER BY last_death_date DESC, dead_until DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []graveEntry
for rows.Next() {
var (
e graveEntry
aliveInt int
deadUntil sql.NullTime
)
if err := rows.Scan(&e.Name, &aliveInt, &e.Source, &e.Location, &e.LastDeathDate, &deadUntil); err != nil {
return nil, err
}
e.StillDown = aliveInt == 0
out = append(out, e)
}
return out, rows.Err()
}
// ── Rivals board ─────────────────────────────────────────────────────────────
type rivalBoardEntry struct {
Name string
Wins int
Losses int
LastDuelAt *time.Time
}
// loadRivalsBoard aggregates adventure_rival_records into room-wide standings.
// The table is a directed per-pair ledger written from both duellists' sides,
// so summing a user_id's rows gives that player's total record.
func loadRivalsBoard(limit int) ([]rivalBoardEntry, error) {
rows, err := db.Get().Query(
`SELECT COALESCE(NULLIF(c.display_name, ''), r.user_id),
SUM(r.wins), SUM(r.losses), MAX(r.last_duel_at)
FROM adventure_rival_records r
LEFT JOIN player_meta c ON c.user_id = r.user_id
GROUP BY r.user_id
ORDER BY SUM(r.wins) DESC, SUM(r.losses) ASC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []rivalBoardEntry
for rows.Next() {
var (
e rivalBoardEntry
lastDuel sql.NullString
)
if err := rows.Scan(&e.Name, &e.Wins, &e.Losses, &lastDuel); err != nil {
return nil, err
}
// MAX() over a DATETIME column loses SQLite's type affinity and comes
// back as text, so parse it by hand rather than scanning a NullTime.
if lastDuel.Valid {
if t, ok := parseSQLiteTime(lastDuel.String); ok {
e.LastDuelAt = &t
}
}
out = append(out, e)
}
return out, rows.Err()
}
// ── Rendering (client-free, unit-testable) ───────────────────────────────────
func renderTownRegistry(civic []civicEntry, houses []housingEntry, pets []petShowcaseEntry) string {
var sb strings.Builder
sb.WriteString("🏛️ **The Town Registry**\n\n")
sb.WriteString("**Civic Pride** — the guild's most generous coffers-fillers\n")
if len(civic) == 0 {
sb.WriteString(" _The collection plate is empty. Tragic._\n")
} else {
for i, e := range civic {
sb.WriteString(fmt.Sprintf(" %2d. %-18s %s\n", i+1, truncName(e.Name, 18), fmtEuro(e.TotalPaid)))
}
}
sb.WriteString("\n")
sb.WriteString("**Housing Street** — who's put down roots\n")
if len(houses) == 0 {
sb.WriteString(" _Not a single deed on file. A town of drifters._\n")
} else {
for _, e := range houses {
tierName := "a house"
if def := houseTierByTier(e.Tier); def != nil {
tierName = def.Name
}
sb.WriteString(fmt.Sprintf(" %-18s %s\n", truncName(e.Name, 18), tierName))
}
}
sb.WriteString("\n")
sb.WriteString("**Pet Showcase** — the good companions\n")
if len(pets) == 0 {
sb.WriteString(" _No pets in town. Somebody adopt something._\n")
} else {
for _, e := range pets {
line := fmt.Sprintf(" %-30s L%d %s", truncName(e.Name, 30), e.Level, e.Type)
if e.ArmorName != "" {
line += " · " + e.ArmorName
}
sb.WriteString(line + "\n")
}
}
return strings.TrimRight(sb.String(), "\n")
}
func renderGraveyard(deaths []graveEntry, now time.Time) string {
var sb strings.Builder
sb.WriteString("⚰️ **St. Guildmore's Memorial Garden**\n")
sb.WriteString("_The groundskeeper tips his hat. \"They gave their all. Well — they gave enough.\"_\n\n")
if len(deaths) == 0 {
sb.WriteString("_Nobody's died lately. The garden's quiet. Enjoy it while it lasts._")
return sb.String()
}
for _, e := range deaths {
place := e.Location
if place == "" {
place = "parts unknown"
}
verb := "fell"
if e.Source == "arena" {
verb = "fell in the arena"
}
when := humanizeDeathDate(e.LastDeathDate, now)
line := fmt.Sprintf(" ⚰️ **%s** — %s at %s (%s)", truncName(e.Name, 24), verb, place, when)
if e.StillDown {
line += " · _still resting_"
} else {
line += " · _back on their feet_"
}
sb.WriteString(line + "\n")
}
return strings.TrimRight(sb.String(), "\n")
}
func renderRivalsBoard(board []rivalBoardEntry, now time.Time) string {
var sb strings.Builder
sb.WriteString("⚔️ **Rivalry Standings** — duels across the guild\n\n")
if len(board) == 0 {
sb.WriteString("_No duels on record. A suspiciously peaceful town._")
return sb.String()
}
for i, e := range board {
last := "—"
if e.LastDuelAt != nil {
last = humanizeDaysAgo(*e.LastDuelAt, now)
}
sb.WriteString(fmt.Sprintf(" %2d. %-18s %dW - %dL last duel: %s\n",
i+1, truncName(e.Name, 18), e.Wins, e.Losses, last))
}
return strings.TrimRight(sb.String(), "\n")
}
// ── Small render helpers ─────────────────────────────────────────────────────
func truncName(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
if max <= 1 {
return string(r[:max])
}
return string(r[:max-1]) + "…"
}
func firstNonEmpty(s, fallback string) string {
if strings.TrimSpace(s) == "" {
return fallback
}
return s
}
// parseSQLiteTime parses a SQLite CURRENT_TIMESTAMP string ("2006-01-02
// 15:04:05", UTC), tolerating an RFC3339 variant. Aggregates like MAX() strip
// the column's type affinity so the driver hands back text.
func parseSQLiteTime(s string) (time.Time, bool) {
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC(), true
}
}
return time.Time{}, false
}
// humanizeDaysAgo renders a duel/event time as "today" / "1 day ago" / "N days
// ago", matching the inline style in handleRivalsCmd.
func humanizeDaysAgo(t, now time.Time) string {
days := int(now.Sub(t).Hours() / 24)
switch {
case days <= 0:
return "today"
case days == 1:
return "1 day ago"
default:
return fmt.Sprintf("%d days ago", days)
}
}
// humanizeDeathDate renders a "2006-01-02" death date relative to now. Falls
// back to the raw string if it doesn't parse.
func humanizeDeathDate(dateStr string, now time.Time) string {
t, err := time.Parse("2006-01-02", dateStr)
if err != nil {
if dateStr == "" {
return "some time ago"
}
return dateStr
}
days := int(now.UTC().Truncate(24*time.Hour).Sub(t).Hours() / 24)
switch {
case days <= 0:
return "today"
case days == 1:
return "yesterday"
default:
return fmt.Sprintf("%d days ago", days)
}
}
// ── Handlers ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleTownCmd(ctx MessageContext) error {
civic, err := loadCivicPrideBoard(townCivicBoardLimit)
if err != nil {
return err
}
houses, err := loadHousingStreet(townListCap)
if err != nil {
return err
}
pets, err := loadPetShowcase(townListCap)
if err != nil {
return err
}
return p.SendReply(ctx.RoomID, ctx.EventID, renderTownRegistry(civic, houses, pets))
}
func (p *AdventurePlugin) handleGraveyardCmd(ctx MessageContext) error {
deaths, err := loadGraveyard(graveyardLimit)
if err != nil {
return err
}
return p.SendReply(ctx.RoomID, ctx.EventID, renderGraveyard(deaths, time.Now().UTC()))
}
// handleRivalsTopCmd routes the top-level !rivals command: "!rivals board" is
// the room-wide standings; anything else defers to the per-user record view.
func (p *AdventurePlugin) handleRivalsTopCmd(ctx MessageContext, args string) error {
if strings.EqualFold(strings.TrimSpace(args), "board") {
board, err := loadRivalsBoard(rivalsBoardLimit)
if err != nil {
return err
}
return p.SendReply(ctx.RoomID, ctx.EventID, renderRivalsBoard(board, time.Now().UTC()))
}
return p.handleRivalsCmd(ctx)
}

View File

@@ -0,0 +1,234 @@
package plugin
import (
"strings"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func townTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
// TestTownLoaders_ExerciseRealSchema seeds each source table and runs every
// loader against a real DB, catching column typos / GROUP BY errors that the
// pure render tests can't.
func TestTownLoaders_ExerciseRealSchema(t *testing.T) {
townTestDB(t)
alice := id.UserID("@alice:test.invalid")
bob := id.UserID("@bob:test.invalid")
if err := upsertPlayerMetaDisplayName(alice, "Alice"); err != nil {
t.Fatal(err)
}
if err := upsertPlayerMetaDisplayName(bob, "Bob"); err != nil {
t.Fatal(err)
}
// Civic pride.
trackTaxPaid(alice, 5000)
trackTaxPaid(bob, 100)
civic, err := loadCivicPrideBoard(townCivicBoardLimit)
if err != nil {
t.Fatalf("loadCivicPrideBoard: %v", err)
}
if len(civic) != 2 || civic[0].Name != "Alice" || civic[0].TotalPaid != 5000 {
t.Fatalf("civic board wrong: %+v", civic)
}
// Housing street.
if err := upsertPlayerMetaHouseState(alice, HouseState{Tier: 4}); err != nil {
t.Fatal(err)
}
if err := upsertPlayerMetaHouseState(bob, HouseState{Tier: 1}); err != nil {
t.Fatal(err)
}
houses, err := loadHousingStreet(townListCap)
if err != nil {
t.Fatalf("loadHousingStreet: %v", err)
}
if len(houses) != 2 || houses[0].Tier != 4 {
t.Fatalf("housing street wrong: %+v", houses)
}
// Pet showcase — only arrived, non-chased pets appear.
if err := upsertPlayerMetaPetState(alice, PetState{Type: "dog", Name: "Rex", Level: 5, ArmorTier: 2, Arrived: true}); err != nil {
t.Fatal(err)
}
if err := upsertPlayerMetaPetState(bob, PetState{Type: "cat", Name: "Ghost", Level: 3, Arrived: true, ChasedAway: true}); err != nil {
t.Fatal(err)
}
pets, err := loadPetShowcase(townListCap)
if err != nil {
t.Fatalf("loadPetShowcase: %v", err)
}
if len(pets) != 1 || !strings.Contains(pets[0].Name, "Rex") || pets[0].ArmorName != "Chain Dog Barding" {
t.Fatalf("pet showcase wrong (chased-away pet should be hidden): %+v", pets)
}
// Graveyard.
deadUntil := time.Now().Add(6 * time.Hour).UTC()
if err := upsertPlayerMetaDeathState(alice, DeathState{
Alive: false, DeadUntil: &deadUntil, LastDeathDate: "2026-07-10",
DeathSource: "arena", DeathLocation: "the Colosseum",
}); err != nil {
t.Fatal(err)
}
graves, err := loadGraveyard(graveyardLimit)
if err != nil {
t.Fatalf("loadGraveyard: %v", err)
}
if len(graves) != 1 || graves[0].Name != "Alice" || !graves[0].StillDown || graves[0].Source != "arena" {
t.Fatalf("graveyard wrong: %+v", graves)
}
// Rivals board — directed pair ledger summed per user.
upsertRivalRecord(alice, bob, true)
upsertRivalRecord(alice, bob, true)
upsertRivalRecord(bob, alice, false)
board, err := loadRivalsBoard(rivalsBoardLimit)
if err != nil {
t.Fatalf("loadRivalsBoard: %v", err)
}
if len(board) != 2 || board[0].Name != "Alice" || board[0].Wins != 2 {
t.Fatalf("rivals board wrong: %+v", board)
}
}
func TestRenderTownRegistry_AllThreeBoards(t *testing.T) {
civic := []civicEntry{{Name: "Alice", TotalPaid: 1234567}, {Name: "Bob", TotalPaid: 500}}
houses := []housingEntry{{Name: "Alice", Tier: 4}, {Name: "Bob", Tier: 1}}
pets := []petShowcaseEntry{{Name: "Rex (Alice)", Type: "dog", Level: 7, ArmorName: "Plate Dog Barding"}}
out := renderTownRegistry(civic, houses, pets)
for _, want := range []string{
"Civic Pride", "€1,234,567", "Alice",
"Housing Street", "Established", "Base House",
"Pet Showcase", "Rex (Alice)", "L7 dog", "Plate Dog Barding",
} {
if !strings.Contains(out, want) {
t.Errorf("town registry missing %q\n---\n%s", want, out)
}
}
}
func TestRenderTownRegistry_EmptyBoardsDontPanic(t *testing.T) {
out := renderTownRegistry(nil, nil, nil)
for _, want := range []string{"Civic Pride", "Housing Street", "Pet Showcase"} {
if !strings.Contains(out, want) {
t.Errorf("empty registry missing header %q", want)
}
}
}
func TestPetArmorName(t *testing.T) {
if got := petArmorName("dog", 0); got != "" {
t.Errorf("tier 0 should be no armor, got %q", got)
}
if got := petArmorName("dog", 3); got != "Plate Dog Barding" {
t.Errorf("dog tier 3 = %q, want Plate Dog Barding", got)
}
if got := petArmorName("cat", 5); got != "Dragonscale Cat Armor" {
t.Errorf("cat tier 5 = %q, want Dragonscale Cat Armor", got)
}
if got := petArmorName("dog", 99); got != "" {
t.Errorf("out-of-range tier should be empty, got %q", got)
}
}
func TestRenderGraveyard_StillDownVsRecovered(t *testing.T) {
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
deaths := []graveEntry{
{Name: "Alice", Source: "arena", Location: "the Colosseum", LastDeathDate: "2026-07-10", StillDown: true},
{Name: "Bob", Source: "adventure", Location: "", LastDeathDate: "2026-07-08", StillDown: false},
}
out := renderGraveyard(deaths, now)
if !strings.Contains(out, "fell in the arena") {
t.Errorf("arena death should read distinctly\n%s", out)
}
if !strings.Contains(out, "parts unknown") {
t.Errorf("blank location should fall back\n%s", out)
}
if !strings.Contains(out, "still resting") {
t.Errorf("Alice is still down\n%s", out)
}
if !strings.Contains(out, "back on their feet") {
t.Errorf("Bob has recovered\n%s", out)
}
if !strings.Contains(out, "today") || !strings.Contains(out, "2 days ago") {
t.Errorf("relative dates missing\n%s", out)
}
}
func TestRenderGraveyard_Empty(t *testing.T) {
out := renderGraveyard(nil, time.Now())
if !strings.Contains(out, "garden's quiet") {
t.Errorf("empty graveyard should be flavored\n%s", out)
}
}
func TestRenderRivalsBoard_OrdersAndFormats(t *testing.T) {
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
yesterday := now.Add(-24 * time.Hour)
board := []rivalBoardEntry{
{Name: "Alice", Wins: 9, Losses: 2, LastDuelAt: &yesterday},
{Name: "Bob", Wins: 3, Losses: 5, LastDuelAt: nil},
}
out := renderRivalsBoard(board, now)
if !strings.Contains(out, "9W - 2L") {
t.Errorf("Alice's record missing\n%s", out)
}
if !strings.Contains(out, "1 day ago") {
t.Errorf("relative last-duel missing\n%s", out)
}
if !strings.Contains(out, "last duel: —") {
t.Errorf("Bob has no recorded duel time\n%s", out)
}
// Alice ranks above Bob.
if strings.Index(out, "Alice") > strings.Index(out, "Bob") {
t.Errorf("board should be ordered by wins\n%s", out)
}
}
func TestHumanizeDeathDate(t *testing.T) {
now := time.Date(2026, 7, 10, 6, 0, 0, 0, time.UTC)
cases := map[string]string{
"2026-07-10": "today",
"2026-07-09": "yesterday",
"2026-07-05": "5 days ago",
"": "some time ago",
"garbage": "garbage",
}
for in, want := range cases {
if got := humanizeDeathDate(in, now); got != want {
t.Errorf("humanizeDeathDate(%q) = %q, want %q", in, got, want)
}
}
}
func TestTruncName(t *testing.T) {
if got := truncName("short", 18); got != "short" {
t.Errorf("short name unchanged, got %q", got)
}
long := truncName("this-is-a-very-long-display-name", 10)
if len([]rune(long)) != 10 {
t.Errorf("truncated to %d runes, want 10: %q", len([]rune(long)), long)
}
if !strings.HasSuffix(long, "…") {
t.Errorf("truncated name should end with ellipsis: %q", long)
}
}