Surface replenish failures, follow the active batch, scope deck keys, keep three deck depths
CI / ci (push) Successful in 3m2s
CI / ci (pull_request) Successful in 2m44s

Fixes the Feature 13 review findings on the Discover deck (Maestro task
4fc551f0-3c11-4c15-b638-f2c08a28eb29):

- F13-01: a batch failure (start, poll or job) is shown with its Retry
  whatever the deck has, so a replenish that fails while cards remain is
  no longer silent until the deck runs out.
- F13-02: GET /api/v1/suggestions/deck reports the batch pending or
  running (active_batch, read in the deck's snapshot), and a decision
  that calls for a replenish returns the job of the batch it started or
  joined. The page follows a newly reported active batch, so a reopened
  page or a second session shows skeleton cards while the batch runs
  instead of "All caught up".
- F13-03: ← / → leave the deck alone while it is inert behind a modal
  (the Quest log sheet or its confirmation).
- F13-04: a one- or two-card deck keeps all three depths: skeleton cards
  stand in for the missing ones while a batch finds more Side quests,
  blank stack-edge cards while none is coming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 05:04:20 -04:00
co-authored by Claude Opus 5.5
parent 4d9d8afd8d
commit af58caac94
10 changed files with 424 additions and 89 deletions
+36 -7
View File
@@ -329,7 +329,7 @@ func (e *env) deckNames(t *testing.T) []string {
t.Fatal(err)
}
names := []string{}
for _, s := range deck {
for _, s := range deck.Suggestions {
names = append(names, s.Name)
}
return names
@@ -362,15 +362,15 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(deck) != 2 {
t.Fatalf("deck has %d suggestions, want 2", len(deck))
if len(deck.Suggestions) != 2 {
t.Fatalf("deck has %d suggestions, want 2", len(deck.Suggestions))
}
want := []struct{ name, reason, regular string }{
{"Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"},
{"Chili Verde", "Your Beef Chili gone green.", "Beef Chili"},
}
for i, w := range want {
s := deck[i]
s := deck.Suggestions[i]
if s.ID != res.SuggestionIDs[i] || s.Name != w.name || s.Reason != w.reason || s.Status != suggest.StatusPending {
t.Errorf("deck[%d] = %+v, want %s pending with reason %q", i, s, w.name, w.reason)
}
@@ -452,6 +452,33 @@ func TestDeckIsOldestFirst(t *testing.T) {
}
}
func TestDeckReportsTheActiveBatch(t *testing.T) {
e := newEnv(t) // runner not started: the batch stays pending
e.seedDeck(t, "Pho")
deck, err := e.svc.Deck(t.Context())
if err != nil {
t.Fatal(err)
}
if deck.ActiveBatch != nil {
t.Fatalf("active batch = %+v with none pending or running, want nil", deck.ActiveBatch)
}
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
deck, err = e.svc.Deck(t.Context())
if err != nil {
t.Fatal(err)
}
if len(deck.Suggestions) != 1 {
t.Errorf("deck has %d suggestions, want 1", len(deck.Suggestions))
}
want := suggest.Batch{ID: b.ID, JobID: b.JobID, Trigger: suggest.TriggerManual, Status: suggest.BatchPending}
if deck.ActiveBatch == nil || *deck.ActiveBatch != want {
t.Errorf("active batch = %+v, want %+v", deck.ActiveBatch, want)
}
}
func TestDeckLeavesOutDinnersNoLongerSuggested(t *testing.T) {
e := newEnv(t)
ids := e.seedDeck(t, "Pho", "Ramen")
@@ -459,7 +486,7 @@ func TestDeckLeavesOutDinnersNoLongerSuggested(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := db.New(e.st.Writer).ArchiveDinner(t.Context(), db.ArchiveDinnerParams{ID: sug[0].DinnerID, UpdatedAt: store.Now().String()}); err != nil {
if _, err := db.New(e.st.Writer).ArchiveDinner(t.Context(), db.ArchiveDinnerParams{ID: sug.Suggestions[0].DinnerID, UpdatedAt: store.Now().String()}); err != nil {
t.Fatal(err)
}
if got := e.deckNames(t); !slices.Equal(got, []string{"Ramen"}) {
@@ -608,8 +635,10 @@ func TestReplenishIsSingleFlight(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if second.ReplenishJobID != nil {
t.Errorf("second decision enqueued replenish job %s while one is pending", *second.ReplenishJobID)
// The second decision joins the pending batch and reports its job, so a
// client that did not start it can follow it.
if second.ReplenishJobID == nil || *second.ReplenishJobID != *first.ReplenishJobID {
t.Errorf("second decision replenish job = %v, want the pending batch's job %s", second.ReplenishJobID, *first.ReplenishJobID)
}
manual, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {