feat(podcasts): the rules page retires — the collection builder IS the filter builder (item 3.4)

The smart-collection editor at /collections/:id/rules now hosts the
SAME builder island as All-Books (FilterBuilderRows — the rows markup
extracted into one shared templ both mounts render; the shared
filterBuilderExtra component). One vocabulary, one engine, one row UI;
the collection differences are exactly three: the payload is the
collection's scope (episode trees get the episode fields — books
trees hide them), Apply writes the tree to the collection (PUT
filter_tree through the validated write path), and a legacy rules
array preloads as its CONVERTED tree (RulesToFilterTree rendered
server-side) so the upgrade lands at the first Apply — read-compat
never mutates data silently.

Retired with the page: the per-rule CRUD endpoints and test-rules
(their handler bodies, routes, api-docs rows, and the dead collections.ts
island functions — the file's LIVE halves, the collection modal/navigate/
grid-refresh, stay; the dead rules half went). match-mode endpoint and
preview survive (legacy channel + the dashboard custom-section-builder,
flagged for its own migration). Contract-pin changelog documents the
deletions per the asymmetry rule.

The builder component gains collection mounts: scopeKey (the row
universe for counts/apply), data-tree prefill, treeJSON (the apply
wire with scope riding the top level), and an apply hook (the page
owns the write; All-Books never sets one). ensureReadyRule defaults to
the scope's first field instead of hardcoding author.

Tests migrated onto trees: TestSmartCollections' live-membership /
reject-mutations / include-auto / system-collection subtests now drive
TREE collections; the deleted-endpoint subtests die with them.
This commit is contained in:
John O'Keefe committed 2026-10-30 19:22:17 -04:00
1 parent eb68672390
commit 4f71eda6be
16 files changed
+518 -1422

No files matched your search

+5 -25
View File
@@ -869,31 +869,11 @@ func TestPodcastSmartCollections(t *testing.T) {
assert.Equal(t, http.StatusForbidden, code, "only the owner flips the mode: %s", string(body))
})
t.Run("test_rules_preview_honors_mode", func(t *testing.T) {
// The rules page preview speaks the same evaluator: all-mode
// requires every rule (aNew passes feed∧unplayed∧<7d; aOld
// fails published_within).
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections/test-rules",
`{"match_mode":"all","rules":[
{"field":"feed","operator":"in","value":"`+feedAID+`"},
{"field":"played","operator":"equals","value":"false"},
{"field":"published_within","operator":"greater_than","value":"7"}
]}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
assert.Contains(t, string(body), "SmartColl A New")
assert.NotContains(t, string(body), "SmartColl A Old")
})
t.Run("downloaded_and_contains_rules", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections/test-rules",
`{"match_mode":"all","rules":[
{"field":"downloaded","operator":"equals","value":"true"},
{"field":"title","operator":"contains","value":"SmartColl A"}
]}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
assert.Contains(t, string(body), "SmartColl A New")
assert.Contains(t, string(body), "SmartColl A Played", "played but downloaded — the downloaded rule alone doesn't exclude it")
assert.NotContains(t, string(body), "SmartColl B Ep")
t.Run("match_mode_endpoint_still_serves_legacy", func(t *testing.T) {
// Item 3.4 retired the rules PAGE, not the legacy channel: the
// match-mode endpoint keeps serving rules-array collections.
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"any"}`)
assert.Equal(t, http.StatusOK, code, "%s", string(body))
})
// ---- ACCEPTANCE (plan item 2.5): the briefing PLAYS back-to-back ----