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")
|
||||||
assert.Contains(t, h, "TreeColl New Two")
|
assert.Contains(t, h, "TreeColl New Two")
|
||||||
assert.NotContains(t, h, "TreeColl Old", "published 30d ago fails within_days")
|
assert.NotContains(t, h, "TreeColl Old", "published 30d ago fails within_days")
|
||||||
// The list IS the play order: native feed order puts New
|
// BORN REVERSED (2026-11-01): a new episodes smart collection is
|
||||||
// (position 1) before New Two (position 5). Quote-bounded so
|
// created with chain_order=reverse seeded into view_settings.
|
||||||
// "TreeColl New" never matches inside "TreeColl New Two".
|
assert.Contains(t, h, `"chain_order":"reverse"`,
|
||||||
assert.Less(t, strings.Index(h, `"TreeColl New"`), strings.Index(h, `"TreeColl New Two"`),
|
"new episodes smart collections are born with the reversed default")
|
||||||
"native list order = feed positions")
|
// 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) {
|
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)
|
require.Equal(t, http.StatusOK, code)
|
||||||
html := string(body)
|
html := string(body)
|
||||||
assert.Contains(t, html, esc(`"source":"collection"`))
|
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(`"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+`"`),
|
assert.Contains(t, html, esc(`"prev_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`),
|
||||||
"chain hops stay on the tree's live membership")
|
"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) {
|
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")
|
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"`),
|
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")
|
"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.
|
// aNew is feed position 1: FIRST under native, LAST under reverse.
|
||||||
code, body = podcastAuthed(t, setup, regToken, "GET",
|
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")
|
"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
|
feed position 1 plays LAST for an unset feed; the podcast page's
|
||||||
dropdown renders `reverse` selected (TestPodcastPlayerOrderControl,
|
dropdown renders `reverse` selected (TestPodcastPlayerOrderControl,
|
||||||
replacing the former native pin).
|
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
|
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(
|
collection, err := h.collectionService.CreateCollection(
|
||||||
c.Request().Context(),
|
c.Request().Context(),
|
||||||
userUUID,
|
userUUID,
|
||||||
|
|||||||
Reference in new issue
Block a user