fix(filter-builder): multi-pick id values matched nothing; hint the multi boxes

Comma-joined multi-picks matched ZERO: clauseFeed/clauseLibrary/
clauseCollection parsed each rule value as ONE uuid, but the builder's
pick loop stores several picks as a single comma-joined value
(["id1,id2"]) — uuid.Parse failed on the joined string, the id list
came back empty, and "feeds in [A, B]" matched no episodes (one pick
worked, which is why the briefing recipe survived). parseUUIDValues
now splits every value on commas (trimming) before parsing, accepting
both the builder's joined shape and hand-written separate elements —
same leniency the suggest-side id lookup already has.

The multi-value cells now say so: id-kind value inputs get the
placeholder "pick one or more" (the only boxes on the page that accept
several values; every other cell replaces its value).

Pins: comma_joined_multi_feed_matches_both — a tree collection built
with the exact comma-joined wire the client sends, asserting BOTH
feeds' episodes match. Filter-builder vitest 35/35, container filter +
episode-tree slices green.
This commit is contained in:
John O'Keefe committed 2026-11-01 00:20:31 -04:00
1 parent 09f8f0875f
commit cce488b76f
6 files changed
+78 -22

No files matched your search

+42
View File
@@ -943,6 +943,48 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) {
// Sequence config rides a data-json attribute — templ escapes quotes.
esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) }
// MULTI-PICK wire shape (2026-11-01 user report): the builder's pick
// loop stores several feeds as ONE comma-joined value — "feeds in
// [A, B]" must match BOTH feeds' episodes, not zero.
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "TreeColl Feed B " + marker,
})
require.NoError(t, err)
bEp := upsertTestEpisode(t, setup, feedB, marker+"-b", "TreeColl B Ep", 1)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE WHERE id = $1`, bEp.ID)
subscribeRaw(t, setup, regUUID, feedB.ID)
t.Run("comma_joined_multi_feed_matches_both", func(t *testing.T) {
joined := feedAID + "," + uuid.UUID(feedB.ID.Bytes).String()
multi, _ := json.Marshal(map[string]interface{}{
"scope": "episodes", "mode": "any",
"children": []map[string]interface{}{
{"field": "feed", "op": "in", "values": []string{joined}},
},
})
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test TreeColl Multi `+marker+`","filter_tree":`+string(multi)+`}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal([]byte(body), &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code)
h := string(body)
assert.Contains(t, h, "TreeColl New", "feed A's episodes match")
assert.Contains(t, h, "TreeColl B Ep", "feed B's episodes match")
assert.Contains(t, h, "TreeColl Old", "no date rule — every episode of both feeds")
})
// The Daily Fiction briefing AS A TREE (item 2.5's shape, unified DSL).
briefing, _ := json.Marshal(map[string]interface{}{
"scope": "episodes", "mode": "all",