feat(podcasts): smart collections play as a sequence — the Daily Fiction acceptance (item 2.5)
ACCEPTANCE (plan §0 item 2.5): "Daily Fiction" = feed ∈ {A} ∧ unplayed
∧ <7d matches exactly today's episodes, and PLAY = today's episodes
back-to-back. Two pieces were missing after item 2.4:
- The chain builder only read static membership (collection_items) — a
smart collection has none. buildSmartCollectionSequenceContext now
evaluates the rules LIVE for the requesting user (mode from the
collection, family lens via the visible set) and chains the
downloaded episode matches in NATIVE feed order — the collection's
computed order is content (R1: never written back; OrderEpisodes is
the order chokepoint per R3). A match that aged out of the briefing
(played since, published beyond the window) falls back to the feed
queue instead of breaking the page.
- The smart-detail branch rendered match cards as books: episode
matches are now flagged (IsEpisode + download state) so they carry
the play affordance and the header's Play-as-Sequence button;
feed titles stay empty on smart cards for now (batch lookup is
polish, not acceptance).
Pinned by TestPodcastSmartCollections' acceptance subtests: the
briefing starts at index 1/2 and chains into the second with the
queue param on both hops; the order control stays hidden (computed
order); a stale match falls back to the feed queue honestly.
This commit is contained in:
1 parent
cc56b9ef2a
commit
69773ec307
3 files changed
+159
No files matched your search
@@ -812,6 +812,9 @@ func TestPodcastSmartCollections(t *testing.T) {
|
||||
|
||||
feedAID := uuid.UUID(feedA.ID.Bytes).String()
|
||||
|
||||
// Sequence config rides a data-json attribute — templ escapes quotes.
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) }
|
||||
|
||||
// Smart collection with the briefing's AND shape, created through
|
||||
// the real API.
|
||||
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
|
||||
@@ -892,6 +895,61 @@ func TestPodcastSmartCollections(t *testing.T) {
|
||||
assert.Contains(t, string(body), "SmartColl A Played", "played but downloaded — the downloaded rule alone doesn't exclude it")
|
||||
assert.NotContains(t, string(body), "SmartColl B Ep")
|
||||
})
|
||||
|
||||
// ---- ACCEPTANCE (plan item 2.5): the briefing PLAYS back-to-back ----
|
||||
|
||||
// One more briefing match so the chain has a next hop.
|
||||
aNew2 := upsertTestEpisode(t, setup, feedA, marker+"-new2", "SmartColl A New Two", 5)
|
||||
adminScopeExec(t, setup, adminCtxValue(t, setup), `
|
||||
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
|
||||
date_published = CURRENT_DATE WHERE id = $1`, aNew2.ID)
|
||||
|
||||
t.Run("acceptance_briefing_plays_back_to_back", func(t *testing.T) {
|
||||
// Back to ALL: today's unplayed feed-A episodes = aNew + aNew2,
|
||||
// chained in native feed order. Playing the FIRST match (the
|
||||
// header's Play-as-Sequence target) must chain into the second.
|
||||
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`)
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
|
||||
// The detail page (smart branch, All Libraries view) renders
|
||||
// episode cards with the play affordance + the header button.
|
||||
// (The library-scoped view filters matches to that library — the
|
||||
// switcher contract, same as books.)
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+collID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+regToken)
|
||||
req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"})
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(resp.Body)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
html := buf.String()
|
||||
assert.Contains(t, html, "Play as Sequence", "smart collections play as a sequence too")
|
||||
assert.Contains(t, html, "?queue=collection:"+collID, "smart episode cards chain through the queue param")
|
||||
|
||||
// Start from the first briefing match (feed position 1): index 1
|
||||
// of 2, next = the second, both hops carrying the queue param.
|
||||
code, body = podcastAuthed(t, setup, regToken, "GET",
|
||||
"/readers/"+uuid.UUID(aNew.ID.Bytes).String()+"?queue=collection:"+collID, "")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
html = string(body)
|
||||
assert.Contains(t, html, esc(`"source":"collection"`))
|
||||
assert.Contains(t, html, esc(`"episode_index":1`))
|
||||
assert.Contains(t, html, esc(`"index_total":2`), "exactly the briefing set: aOld fails <7d, aPlayed fails unplayed, aPending is un-downloaded, bEp is RLS-dropped")
|
||||
assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`))
|
||||
assert.NotContains(t, html, `id="player-order-select"`, "computed order — the order control stays hidden (R1)")
|
||||
})
|
||||
|
||||
t.Run("acceptance_stale_briefing_match_falls_back", func(t *testing.T) {
|
||||
// aPlayed is DOWNLOADED but no longer matches the briefing
|
||||
// (played). Opening its reader with the queue param: the smart
|
||||
// chain can't place it → feed-queue fallback, not a broken page.
|
||||
code, body := podcastAuthed(t, setup, regToken, "GET",
|
||||
"/readers/"+uuid.UUID(aPlayed.ID.Bytes).String()+"?queue=collection:"+collID, "")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, string(body), esc(`"source":"feed"`), "a match that aged out of the briefing falls back to the feed queue")
|
||||
})
|
||||
}
|
||||
|
||||
// ---- download worker: state machine, idempotency, file layout ----
|
||||
|
||||
Reference in new issue
Block a user