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")
})
}
+17
View File
@@ -668,3 +668,20 @@ page list + dropdown (`podcastOrderForFeed`), the player's feed chain
feed position 1 plays LAST for an unset feed; the podcast page's
dropdown renders `reverse` selected (TestPodcastPlayerOrderControl,
replacing the former native pin).
## 2026-11-01 — new podcast smart collections are BORN reversed
`POST /api/collections` with an EPISODES-scope `filter_tree` seeds
`view_settings.chain_order = "reverse"` on the new row (merge semantics:
an explicit client `chain_order` is never clobbered). The dropdown's
default selection and the chain/page behavior come from the STORED
value — no resolution-logic changes; books-scope smart collections and
statics get nothing seeded; existing collections are untouched.
Pinned (TestSmartCollectionBornReversed): episodes seed, explicit-keep,
books-scope no-seed, static no-seed; TestEpisodeCollectionsFilterTree's
born-reversed chain/page pins replace the former native expectations
(which changelog here per the expectation-change rule). One misplaced
pin from 81e54f89 was corrected while flipping: the API envelope's
match list is NOT chain-ordered and never was — order pins live on the
SSR page and the reader chain.
+18
View File
@@ -263,6 +263,24 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
}
treeJSON = b
}
// New podcast smart collections are BORN with the reversed listening
// default (2026-11-01 user request): seed chain_order into
// view_settings at creation — the dropdown shows "Feed order
// (reversed)" selected and the chain/page machinery reads the stored
// value exactly as if picked. Episodes-scope trees only (books have
// no episode chain); merge semantics never clobber a value the
// request itself sent; existing collections are untouched.
if treeJSON != nil {
if t, perr := services.ParseFilterTree(string(treeJSON)); perr == nil &&
t.FilterScope() == services.ScopeEpisodes {
if req.ViewSettings == nil {
req.ViewSettings = map[string]interface{}{}
}
if _, exists := req.ViewSettings["chain_order"]; !exists {
req.ViewSettings["chain_order"] = "reverse"
}
}
}
collection, err := h.collectionService.CreateCollection(
c.Request().Context(),
userUUID,