feat(collections): new podcast smart collections are BORN reversed
Per user request, same shape as0e29eff7— 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 from81e54f89corrected: 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:
1 parent
0e29eff7b7
commit
d56031f334
3 files changed
+106
-8
No files matched your search
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user