mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Applying /code-review high findings on the review-follow-up stack: - expeditionCmdStart: the resumable-extraction guard swallowed a partySize error and fell open, starting a new expedition on top of a still-seated party — the exact orphaning it exists to prevent. Now checks extractionLapsed first (no DB call on the reap path) and treats a roster-read error as "assume occupied → refuse". - Lapsed reap on !expedition start silently unseated members. Extracted a shared reapLapsedExtraction helper (reap + notify the roster) and routed both the hourly sweeper and the start-path reap through it. - stepRoundEnd: moved Misty's crowd/heal seat-loop after the concentration tick so a caster whose lingering aura would kill the enemy that round wins before the end-of-round crowd swing, honoring the concentration block's "a lethal pulse settles the fight" intent. - Promoted the misty_heal event-log scan to a shared hasAction helper. - Renamed the new sweep test off the dnd_ prefix. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
159 lines
5.3 KiB
Go
159 lines
5.3 KiB
Go
package plugin
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// seedExtraction inserts an 'extracting' row whose window closed `age` ago.
|
|
// A negative age is a live extraction.
|
|
func seedExtraction(t *testing.T, expeditionID string, owner id.UserID, age time.Duration) {
|
|
t.Helper()
|
|
seedExpedition(t, expeditionID, owner, ExpeditionStatusExtracting)
|
|
if _, err := db.Get().Exec(
|
|
`UPDATE dnd_expedition SET completed_at = ? WHERE expedition_id = ?`,
|
|
time.Now().UTC().Add(-extractResumeWindow-age), expeditionID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func seatedOn(t *testing.T, member id.UserID) string {
|
|
t.Helper()
|
|
e, err := seatedExpeditionFor(member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return e.ID
|
|
}
|
|
|
|
// The window is the only thing bounding how long a leader's roster holds their
|
|
// party. Nothing else flips an 'extracting' row terminal unless the leader
|
|
// personally types !resume, so if the sweep misses, members are stuck forever.
|
|
func TestSweepLapsedExtractions_ReleasesTheRosterItStranded(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
leader := id.UserID("@sweep-leader:example.org")
|
|
member := id.UserID("@sweep-member:example.org")
|
|
|
|
seedExtraction(t, "exp-lapsed", leader, time.Hour)
|
|
if err := joinParty("exp-lapsed", member); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := seatedOn(t, member); got != "exp-lapsed" {
|
|
t.Fatalf("member seated on %q before sweep, want exp-lapsed", got)
|
|
}
|
|
|
|
(&AdventurePlugin{}).sweepLapsedExtractions(time.Now().UTC())
|
|
|
|
e, err := getExpedition("exp-lapsed")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e.Status != ExpeditionStatusFailed {
|
|
t.Errorf("status = %q, want failed", e.Status)
|
|
}
|
|
if got := seatedOn(t, member); got != "" {
|
|
t.Errorf("member still seated on %q after sweep", got)
|
|
}
|
|
if n, _ := partySize("exp-lapsed"); n != 1 {
|
|
t.Errorf("roster survived the sweep: partySize = %d", n)
|
|
}
|
|
}
|
|
|
|
// The mirror: a leader who extracted an hour ago still has six days to !resume,
|
|
// and their party must still be there when they do.
|
|
func TestSweepLapsedExtractions_LeavesALiveExtractionAlone(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
leader := id.UserID("@sweep-live-leader:example.org")
|
|
member := id.UserID("@sweep-live-member:example.org")
|
|
|
|
seedExtraction(t, "exp-live", leader, -6*24*time.Hour) // one day in
|
|
if err := joinParty("exp-live", member); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
(&AdventurePlugin{}).sweepLapsedExtractions(time.Now().UTC())
|
|
|
|
e, _ := getExpedition("exp-live")
|
|
if e.Status != ExpeditionStatusExtracting {
|
|
t.Errorf("status = %q, want extracting — the resume window is still open", e.Status)
|
|
}
|
|
if got := seatedOn(t, member); got != "exp-live" {
|
|
t.Errorf("member unseated from a resumable expedition (seated on %q)", got)
|
|
}
|
|
}
|
|
|
|
// completed_at is the only clock the window has. A row without one can never
|
|
// lapse, so it must read as already lapsed rather than as immortal.
|
|
func TestExtractionLapsed_NullCompletedAtIsLapsed(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
if !extractionLapsed(&Expedition{}, now) {
|
|
t.Error("nil completed_at should read as lapsed")
|
|
}
|
|
fresh := now.Add(-time.Hour)
|
|
if extractionLapsed(&Expedition{CompletedAt: &fresh}, now) {
|
|
t.Error("an hour-old extraction is not lapsed")
|
|
}
|
|
old := now.Add(-extractResumeWindow - time.Second)
|
|
if !extractionLapsed(&Expedition{CompletedAt: &old}, now) {
|
|
t.Error("a row past the window is lapsed")
|
|
}
|
|
}
|
|
|
|
// A leader must be able to let an extracted expedition go without paying to
|
|
// !resume it first — that is the escape hatch !expedition start points them at.
|
|
func TestAbandonExpedition_ReachesAnExtractedRowAndFreesTheParty(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
leader := id.UserID("@abandon-extracted-leader:example.org")
|
|
member := id.UserID("@abandon-extracted-member:example.org")
|
|
|
|
seedExtraction(t, "exp-extracted", leader, -24*time.Hour) // live, resumable
|
|
if err := joinParty("exp-extracted", member); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := abandonExpedition(leader); err != nil {
|
|
t.Fatalf("abandonExpedition on an extracting row: %v", err)
|
|
}
|
|
e, _ := getExpedition("exp-extracted")
|
|
if e.Status != ExpeditionStatusAbandoned {
|
|
t.Errorf("status = %q, want abandoned", e.Status)
|
|
}
|
|
if got := seatedOn(t, member); got != "" {
|
|
t.Errorf("member still seated on %q", got)
|
|
}
|
|
}
|
|
|
|
// expeditionCmdStart's run-spawn rollback calls abandonExpedition to tear down
|
|
// the row it just created. A leader who also owns an older extracted row must
|
|
// not have that one reaped instead.
|
|
func TestAbandonExpedition_PrefersTheActiveRowOverAnExtractedOne(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
leader := id.UserID("@abandon-prefers-active:example.org")
|
|
|
|
seedExtraction(t, "exp-old-extracted", leader, -24*time.Hour)
|
|
seedExpedition(t, "exp-new-active", leader, ExpeditionStatusActive)
|
|
|
|
if err := abandonExpedition(leader); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e, _ := getExpedition("exp-new-active"); e.Status != ExpeditionStatusAbandoned {
|
|
t.Errorf("active row status = %q, want abandoned", e.Status)
|
|
}
|
|
if e, _ := getExpedition("exp-old-extracted"); e.Status != ExpeditionStatusExtracting {
|
|
t.Errorf("extracted row was collateral damage: status = %q", e.Status)
|
|
}
|
|
}
|
|
|
|
func TestAbandonExpedition_NoLiveRow(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
if err := abandonExpedition(id.UserID("@abandon-nothing:example.org")); err != ErrNoActiveExpedition {
|
|
t.Errorf("err = %v, want ErrNoActiveExpedition", err)
|
|
}
|
|
}
|