feat(collections): new podcast smart collections are BORN reversed

Per user request, same shape as 0e29eff7 — a default flip, no internal
logic changes: POST /api/collections with an episodes-scope filter_tree
now seeds view_settings.chain_order = "reverse" on the new row, so the
collection lands with "Feed order (reversed)" selected in the detail
header's dropdown and the chain/page order follows the stored value as
if picked. Books-scope smart collections and statics get nothing
seeded; an explicit client chain_order is never clobbered; existing
collections are untouched (the seed exists only at creation).

Pins: TestSmartCollectionBornReversed (episodes seeded, explicit keep,
books-scope and static no-seed); TestEpisodeCollectionsFilterTree's
native expectations flip to the born-reversed reality (changelogged).
One misplaced pin from 81e54f89 corrected: the API envelope's match
list is not chain-ordered and never was — order pins live on the SSR
page and reader chain.
This commit is contained in:
John O'Keefe committed 2026-11-01 11:53:24 -05:00
1 parent 0e29eff7b7
commit d56031f334
3 files changed
+106 -8

No files matched your search

+71 -8
View File
@@ -1040,11 +1040,13 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) {
assert.Contains(t, h, "TreeColl New")
assert.Contains(t, h, "TreeColl New Two")
assert.NotContains(t, h, "TreeColl Old", "published 30d ago fails within_days")
// The list IS the play order: native feed order puts New
// (position 1) before New Two (position 5). Quote-bounded so
// "TreeColl New" never matches inside "TreeColl New Two".
assert.Less(t, strings.Index(h, `"TreeColl New"`), strings.Index(h, `"TreeColl New Two"`),
"native list order = feed positions")
// BORN REVERSED (2026-11-01): a new episodes smart collection is
// created with chain_order=reverse seeded into view_settings.
assert.Contains(t, h, `"chain_order":"reverse"`,
"new episodes smart collections are born with the reversed default")
// NOTE: the API envelope's match list is not chain-ordered (it
// never was — order pins live on the SSR page and the reader
// chain, which the curated-order subtest covers).
})
t.Run("chain_follows_the_tree", func(t *testing.T) {
@@ -1053,10 +1055,14 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) {
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"source":"collection"`))
assert.Contains(t, html, esc(`"episode_index":1`))
// BORN REVERSED (2026-11-01): the seeded chain_order=reverse puts
// feed position 1 (aNew) LAST — index 2/2 with a prev hop.
assert.Contains(t, html, esc(`"episode_index":2`))
assert.Contains(t, html, esc(`"index_total":2`), "the briefing tree = the 2 fresh downloaded episodes")
assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`),
"chain hops stay on the tree's live membership")
assert.Contains(t, html, esc(`"prev_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`),
"the reversed default chain hops back through feed position 5")
assert.NotContains(t, html, esc(`"next_url"`),
"feed position 1 has no next hop under the reversed default")
})
t.Run("invalid_tree_is_400", func(t *testing.T) {
@@ -1114,6 +1120,8 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) {
require.Contains(t, ph, "TreeColl New", "the page renders the smart cards at all")
assert.Less(t, strings.Index(ph, "TreeColl New Two"), strings.Index(ph, `"TreeColl New"`),
"reverse re-orders the page cards — the visible order is the play order")
assert.Contains(t, ph, `<option value="reverse" selected`,
"the collection's dropdown shows the reversed default selected")
// aNew is feed position 1: FIRST under native, LAST under reverse.
code, body = podcastAuthed(t, setup, regToken, "GET",
@@ -2352,3 +2360,58 @@ func TestRulesPageEmbedsIdLabels(t *testing.T) {
"a family-lens-hidden library is not named, even by explicit id")
})
}
// ---- creation seed: new podcast smart collections are BORN reversed ----
// TestSmartCollectionBornReversed pins the 2026-11-01 creation default:
// an episodes-scope smart collection is created with
// view_settings.chain_order = "reverse" (the dropdown's default
// selection and the chain's behavior come from the STORED value — no
// resolution-logic changes). Books-scope smart collections and statics
// get nothing seeded; an explicit client value is never clobbered.
func TestSmartCollectionBornReversed(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
create := func(t *testing.T, payload string) string {
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", payload)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
return coll.ID
}
t.Run("episodes_scope_seeded_reverse", func(t *testing.T) {
id := create(t, `{"name":"test BornRev Ep `+t.Name()+`","filter_tree":{"scope":"episodes","mode":"all","children":[]}}`)
_, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "")
assert.Contains(t, string(body), `"chain_order":"reverse"`,
"a new episodes smart collection carries the reversed default")
})
t.Run("explicit_view_settings_not_clobbered", func(t *testing.T) {
id := create(t, `{"name":"test BornRev Keep `+t.Name()+`","filter_tree":{"scope":"episodes","mode":"all","children":[]},"view_settings":{"chain_order":"native"}}`)
_, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "")
assert.Contains(t, string(body), `"chain_order":"native"`,
"an explicit client chain_order is never clobbered by the seed")
assert.NotContains(t, string(body), `"chain_order":"reverse"`)
})
t.Run("books_scope_not_seeded", func(t *testing.T) {
id := create(t, `{"name":"test BornRev Books `+t.Name()+`","filter_tree":{"scope":"books","mode":"all","children":[]}}`)
_, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "")
assert.NotContains(t, string(body), `"chain_order"`,
"books-scope smart collections get no chain_order seed")
})
t.Run("static_not_seeded", func(t *testing.T) {
id := create(t, `{"name":"test BornRev Static `+t.Name()+`"}`)
_, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "")
assert.NotContains(t, string(body), `"chain_order"`,
"static collections get no chain_order seed")
})
}