"Newest episodes from my selected podcasts" is now buildable in the UI, end to end — the user could not find ANY podcast-related filter surface because none existed: the New Collection modal could only create book-scope smart collections, and the engine's episodes scope (item 3.1) had no authoring door. The scope door: - The create modal's Smart card gains a Rules-scope segmented control (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart means "match books by rules". Choosing Episodes seeds an EMPTY episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the collection is born episode-scoped: the editor opens with the episode vocabulary (feed/downloaded/played/published date) and membership runs the episode engine from the first rule. - ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay invalid) — the seed's legal shape. The root arrives at depth 1 by call convention, hence the depth>1 exemption. - The composers define the empty-tree semantics: zero children matches NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring EvaluateSmartCollection's zero-enabled-rules convention. Without the guard the composed query degrades to the bare scope head and a fresh collection would swallow every episode in scope — pinned by TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0) and TestEmptyRootTreeSeedsEpisodesScope. The curated chain order (plan §10 R2, user need: the briefing plays NEWEST-first): - view_settings.chain_order ("native"/"newest"/"oldest"/"reverse") stored on the collection — curated content, not a player-side knob. - PUT /api/collections/:id/view-settings merges the key into the stored JSONB (a blind write would clobber the playlist toggles). - buildSmartCollectionSequenceContext routes OrderEpisodes through handlers.CollectionChainOrder; the detail header's Play-as-Sequence gains the order select next to it (PlayCollectionButton), which PUTs and reloads. Pinned by chain_follows_the_curated_order (reverse flips the chain indexes and hops). Caught by the new test, a latent clobber fixed: UpdateCollection treated a NIL view_settings payload as "write NULL" — any edit that carried no settings dropped every stored key (chain_order, playlist toggles from API clients, imported provenance). Nil now means untouched, the same convention filterTree already had. Composer guards also cover ComposedCount, so the builder's live count reads the same semantics. Tests: collectionChainOrder unit matrix, empty-root parse unit pin, two integration pins (seed matches nothing; curated order drives the chain and survives whole-map edits).
31 lines
982 B
Go
31 lines
982 B
Go
package handlers
|
|
|
|
import "testing"
|
|
|
|
// The collection chain's curated order (plan §10 R2): the resolver
|
|
// reads view_settings.chain_order, tolerates absent/corrupt docs, and
|
|
// refuses unknown orders (they mean native).
|
|
func TestCollectionChainOrder(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
vs string
|
|
want string
|
|
}{
|
|
{"absent doc", "", ""},
|
|
{"empty object", `{}`, ""},
|
|
{"reverse", `{"chain_order":"reverse"}`, "reverse"},
|
|
{"newest", `{"chain_order":"newest"}`, "newest"},
|
|
{"merged with toggles", `{"hide_finished":true,"chain_order":"oldest"}`, "oldest"},
|
|
{"unknown order means native", `{"chain_order":"shuffle"}`, ""},
|
|
{"non-string means native", `{"chain_order":3}`, ""},
|
|
{"corrupt doc means native", `not json`, ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := CollectionChainOrder([]byte(tc.vs)); got != tc.want {
|
|
t.Fatalf("CollectionChainOrder(%s) = %q, want %q", tc.vs, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|