Files
Pete/internal/web/orders_undo_test.go
T
prosolis b07abc1d13 adventure: let a player back out from the web, not only in Matrix
Three verbs to match gogobee's: call off an expedition, turn back out of
somebody else's party, send the pet sitter home. Which one this page
offers is derived here rather than pushed — leadership is already legible
in the party seats and the sitter's standing is already in the babysit
offer, so nothing new crosses the wire.

Two things running it turned up that no test would have. An applied
abandon left "Pull out of the run" sitting under a verdict saying the
expedition was over, so an applied verb now also hides the other verbs it
just made untrue. And a party member was being offered that same button
in the first place, beside the one that actually works — Pete knows from
the seat it just read that gogobee would refuse it, so it is withheld.

Also: heal the Matrix handle onto push rows stored before the column
existed, on its own endpoint rather than through the subscribe upsert,
which resets both watermarks and would have silenced the digest for
anybody who reads the site regularly. And stack the board row below sm —
four flex columns that wrapped to six lines on a phone, pre-existing.
2026-07-24 21:42:59 -07:00

254 lines
11 KiB
Go

package web
import (
"testing"
"time"
"pete/internal/storage"
)
// W9: the three verbs that undo something. Two halves are worth pinning.
//
// The first is offersToUndo, which is the only place on this site where Pete
// decides what a player MAY do from facts rather than from a list gogobee handed
// it. Getting it wrong in the generous direction puts "Call the whole thing off"
// — a button that throws away four people's day — in front of somebody who is not
// the leader, so the interesting cases are the ones where it must stay quiet.
//
// The second is that the two new verdict names round-trip. gogobee 400s on an
// unknown verdict and parks the order, so a name that exists on one side and not
// the other is a player watching "asked for…" forever.
func seat(kind, name, token string, level int) partySeat {
return partySeat{Kind: kind, Name: name, Token: token, Level: level}
}
// TestOffersToUndoReadsTheViewersOwnSeat is the core of the phase. A shared
// expedition publishes a seat per body, so which button this page offers is
// decided by finding the viewer among them — never by "there is a party, so
// somebody can abandon it".
func TestOffersToUndoReadsTheViewersOwnSeat(t *testing.T) {
party := []partySeat{
seat("leader", "Josie", "tok-josie", 14),
seat("member", "Camcast", "tok-cam", 11),
seat("companion", "Pete", "", 9),
}
cases := []struct {
name string
token, status string
haveParty bool
party []partySeat
self storage.PlayerDetail
abandon, leave, cancel bool
}{
{
name: "leader of a party is offered the abandon",
token: "tok-josie", status: "expedition", haveParty: true, party: party,
abandon: true,
},
{
name: "member of a party is offered the exit, never the abandon",
token: "tok-cam", status: "expedition", haveParty: true, party: party,
leave: true,
},
{
// A solo run publishes no party at all (partySeatViews returns nil below
// two seats), so an empty list on a live run means "nobody else", not
// "we don't know" — and the one body down there is the leader.
name: "solo run is offered the abandon",
token: "tok-josie", status: "expedition", haveParty: true,
abandon: true,
},
{
// The fail-closed case. A party we cannot find ourselves in is a
// snapshot we do not understand, and the safe answer is to offer
// nothing rather than guess which of the two buttons applies.
name: "a party with no seat for the viewer offers nothing",
token: "tok-nobody", status: "expedition", haveParty: true, party: party,
},
{
// The one that came out of running it. An undecodable public sheet
// gives the same empty slice as a solo run, and treating the two alike
// offered a party MEMBER the abandon — convincingly, with the rest of
// the page looking fine. haveParty is what tells them apart.
name: "a run whose sheet did not decode offers nothing",
token: "tok-cam", status: "expedition", haveParty: false,
},
{
name: "standing in town with nothing open offers nothing",
token: "tok-josie", status: "idle", haveParty: true, party: nil,
},
{
// The case the roster status cannot see: an extracted expedition is
// still its owner's to close, and its owner reads as idle in town with
// no party. The resume offer is the only sign the run is still open.
name: "an extracted run is abandonable from town",
token: "tok-josie", status: "idle",
self: storage.PlayerDetail{Resume: &storage.ResumeOffer{ZoneID: "holymachina", Day: 3}},
abandon: true,
},
{
name: "an engaged sitter can be sent home",
token: "tok-josie", status: "idle",
self: storage.PlayerDetail{Babysit: &storage.BabysitOffer{Active: true}},
cancel: true,
},
{
name: "an unengaged sitter cannot",
token: "tok-josie", status: "idle",
self: storage.PlayerDetail{Babysit: &storage.BabysitOffer{Active: false, WeekCost: 700}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
abandon, leave, cancel := offersToUndo(tc.token, tc.status, tc.haveParty, tc.party, tc.self)
if abandon != tc.abandon || leave != tc.leave || cancel != tc.cancel {
t.Fatalf("offers = abandon:%v leave:%v cancel:%v, want abandon:%v leave:%v cancel:%v",
abandon, leave, cancel, tc.abandon, tc.leave, tc.cancel)
}
})
}
}
// TestAbandonAndLeaveAreNeverBothOffered. They are opposite claims about the
// same person, and a page showing both would be asking the reader to work out
// which one they are. No input may produce the pair.
//
// This test found a real one: a member seated in somebody else's live run who
// ALSO has their own extracted run waiting has both facts true at once, about two
// different expeditions. offersToUndo suppresses the abandon in that case; see
// the comment on the Resume clause.
func TestAbandonAndLeaveAreNeverBothOffered(t *testing.T) {
for _, kind := range []string{"leader", "member", "companion", "", "nonsense"} {
party := []partySeat{seat("leader", "Josie", "tok-josie", 14), seat(kind, "Me", "tok-me", 8)}
for _, status := range []string{"expedition", "idle"} {
for _, resume := range []*storage.ResumeOffer{nil, {ZoneID: "z", Day: 2}} {
abandon, leave, _ := offersToUndo("tok-me", status, true, party, storage.PlayerDetail{Resume: resume})
if abandon && leave {
t.Fatalf("kind=%q status=%q resume=%v offered both ways out at once", kind, status, resume != nil)
}
}
}
}
}
// TestUndoOrdersAreAccepted: the three verbs must survive the action allow-list
// and land as pending orders. A verb Pete does not know is a 400 at the door,
// which is a dead button rather than a refusal anybody can read.
func TestUndoOrdersAreAccepted(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
for _, action := range []string{storage.AdvActionAbandon, storage.AdvActionLeave} {
if w := placeAction(t, s, "holymachina", action); w.Code != 200 {
t.Fatalf("%s = %d (%s)", action, w.Code, w.Body.String())
}
}
// Per verb, so the two do not block each other or anything already queued.
pending, err := storage.PendingAdvOrders(0)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 2 {
t.Fatalf("pending = %d orders, want 2", len(pending))
}
}
// TestAbandonIsOfferedToAMarkStandingInTown is W5a's asymmetry restated for the
// two expedition verbs, and it is the reason neither has a snapshot pre-check.
// The board is up to two minutes stale, and an EXTRACTED run is abandonable
// while its owner reads as idle — so refusing on "the mark is in town" would
// refuse the case the verb exists for. gogobee answers rejected_not_running if
// the run really has gone.
func TestAbandonIsOfferedToAMarkStandingInTown(t *testing.T) {
s := seedActions(t, "holymachina", "idle")
if w := placeAction(t, s, "holymachina", storage.AdvActionAbandon); w.Code != 200 {
t.Fatalf("abandon from town = %d (%s), want it queued and answered by the game box",
w.Code, w.Body.String())
}
if w := placeAction(t, s, "holymachina", storage.AdvActionLeave); w.Code != 200 {
t.Fatalf("leave from town = %d (%s)", w.Code, w.Body.String())
}
}
// TestBabysitCancelRefusesWhenThereIsNoSitter is the one W9 pre-check that IS
// allowed to be the last word locally, and the comment in resolveAdvOrderParams
// says why: an engagement is a fact about the character rather than about where
// they are standing, so it does not go stale the way "on an expedition" does.
//
// A MISSING offer is still not a refusal — that is a gogobee too old to push one,
// not a player without a sitter — and the second half here pins that.
func TestBabysitCancelRefusesWhenThereIsNoSitter(t *testing.T) {
s := seedActions(t, "holymachina", "idle")
now := time.Now().Unix()
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
Localpart: "holymachina", Token: "tok-josie",
Babysit: &storage.BabysitOffer{Active: false, WeekCost: 700, MonthCost: 2400},
}}}); w.Code != 200 {
t.Fatalf("detail push = %d", w.Code)
}
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 409 {
t.Fatalf("cancel with no sitter = %d, want 409", w.Code)
}
// Sitter engaged: allowed.
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
Localpart: "holymachina", Token: "tok-josie",
Babysit: &storage.BabysitOffer{Active: true, WeekCost: 700, MonthCost: 2400},
}}}); w.Code != 200 {
t.Fatalf("detail push = %d", w.Code)
}
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 200 {
t.Fatalf("cancel with a sitter = %d (%s)", w.Code, w.Body.String())
}
// No babysit offer at all: a gogobee that predates the offer push. Queue it
// and let the game box answer, rather than making the button dead.
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
Localpart: "holymachina", Token: "tok-josie",
}}}); w.Code != 200 {
t.Fatalf("detail push = %d", w.Code)
}
storage.Get().Exec(`DELETE FROM adventure_orders`)
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 200 {
t.Fatalf("cancel with no offer pushed = %d (%s), want it deferred to gogobee",
w.Code, w.Body.String())
}
}
// TestNewVerdictsRoundTrip: gogobee 400s on a verdict Pete will not take, and
// that parks the order — the player watches "asked for…" and nothing ever
// answers. So every status the game box can file has to be accepted here.
func TestNewVerdictsRoundTrip(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
for _, tc := range []struct{ action, verdict string }{
{storage.AdvActionLeave, storage.AdvRejectedIsLeader},
{storage.AdvActionBabysitCancel, storage.AdvRejectedNothingToCancel},
{storage.AdvActionAbandon, storage.AdvRejectedNotLeader},
} {
storage.Get().Exec(`DELETE FROM adventure_orders`)
w := placeAction(t, s, "holymachina", tc.action)
if w.Code != 200 {
t.Fatalf("place %s = %d (%s)", tc.action, w.Code, w.Body.String())
}
pending, err := storage.PendingAdvOrders(0)
if err != nil || len(pending) != 1 {
t.Fatalf("pending = %v (%v)", pending, err)
}
rec := postVerdict(t, s, "tok", advOrderVerdict{
GUID: pending[0].GUID, Status: tc.verdict, Detail: "because.",
})
if rec.Code != 200 {
t.Fatalf("verdict %s = %d (%s) — an unknown status parks the order forever",
tc.verdict, rec.Code, rec.Body.String())
}
got, err := storage.AdvOrderByGUID(pending[0].GUID)
if err != nil {
t.Fatalf("read back: %v", err)
}
if got.Status != tc.verdict {
t.Fatalf("stored status = %q, want %q", got.Status, tc.verdict)
}
}
}