diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py index a8d04736..cb05aa8e 100644 --- a/backend/tests/test_feed_views.py +++ b/backend/tests/test_feed_views.py @@ -96,6 +96,7 @@ def _seed_tagged(db, make_user): "T1": Tag(user_id=None, name="Tech", category="topic"), "T2": Tag(user_id=None, name="Gaming", category="topic"), "O1": Tag(user_id=None, name="Favourite", category="other"), + "O2": Tag(user_id=None, name="Docs", category="other"), # A USER-owned tag (+ a user-owned ChannelTag link below) so the visibility branch # `ChannelTag.user_id == user.id` is exercised, not only the system `user_id IS NULL` one. "U1": Tag(user_id=user.id, name="MyPin", category="other"), @@ -104,8 +105,15 @@ def _seed_tagged(db, make_user): chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chA", "chB", "chC", "chD")} db.add_all(chans.values()) db.flush() - # channel -> (system) tag keys - links = {"chA": ["L1", "T1"], "chB": ["L1", "T2"], "chC": ["L2", "T1", "O1"], "chD": []} + # channel -> (system) tag keys. chA carries two languages (L1+L2) and chC two "other" tags + # (O1+O2) so the per-category any/all match narrows non-trivially for language and other, not + # only topic (chA/chB share topic tags T1/T2). + links = { + "chA": ["L1", "L2", "T1"], + "chB": ["L1", "T2"], + "chC": ["L2", "T1", "O1", "O2"], + "chD": [], + } for cid, tkeys in links.items(): db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False)) for tk in tkeys: @@ -129,6 +137,10 @@ _SUMMARY_CASES = [ "show=all&tags={L1}&tags={T1}", "show=all&tags={T1}&tags={T2}&tag_mode_topic=or", "show=all&tags={T1}&tags={T2}&tag_mode_topic=and", + # Per-category any/all is NOT topic-only — language and other must drill down identically too. + "show=all&tags={L1}&tags={L2}&tag_mode_language=or", + "show=all&tags={L1}&tags={L2}&tag_mode_language=and", + "show=all&tags={O1}&tags={O2}&tag_mode_other=and", "show=all&tags={U1}", # user-owned tag (visibility == user.id branch) "show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category "show=all&q=clip", diff --git a/frontend/src/components/Combobox.dom.test.tsx b/frontend/src/components/Combobox.dom.test.tsx index e675a58f..19401cc5 100644 --- a/frontend/src/components/Combobox.dom.test.tsx +++ b/frontend/src/components/Combobox.dom.test.tsx @@ -205,6 +205,31 @@ describe("Combobox", () => { expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces }); + it("shows the any/all match footer only at 2+ selections and reports a mode change", () => { + // The per-category any/all toggle: meaningless below 2 selections (any === all), so it appears + // only at >= 2, and clicking 'all' reports the mode change out. + const onMatchModeChange = vi.fn(); + const props = { + label: "Topic", + options: OPTIONS, + onChange: vi.fn(), + matchMode: "or" as const, + onMatchModeChange, + }; + // One selection: no footer. + const { rerender } = wrap(); + fireEvent.focus(screen.getByRole("combobox")); + expect(screen.queryByText("sidebar.all")).toBeNull(); + + // Two selections: footer appears with an any/all segment. + rerender({}); + fireEvent.focus(screen.getByRole("combobox")); + const all = screen.getByText("sidebar.all"); + expect(all).toBeTruthy(); + fireEvent.click(all); + expect(onMatchModeChange).toHaveBeenCalledWith("and"); + }); + it("single-select: picking replaces the value and closes the list", () => { const onChange = vi.fn(); wrap( diff --git a/frontend/src/lib/api/tagModes.test.ts b/frontend/src/lib/api/tagModes.test.ts new file mode 100644 index 00000000..33b5cecf --- /dev/null +++ b/frontend/src/lib/api/tagModes.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { coerceTagModes, DEFAULT_TAG_MODES } from "./tagModes"; + +// coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL +// parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`. +describe("coerceTagModes", () => { + it("defaults to all-'or' for empty/undefined input", () => { + expect(coerceTagModes(undefined)).toEqual(DEFAULT_TAG_MODES); + expect(coerceTagModes({})).toEqual(DEFAULT_TAG_MODES); + expect(coerceTagModes(null)).toEqual(DEFAULT_TAG_MODES); + }); + + it("migrates a legacy global tagMode:'and' into the topic slot only", () => { + expect(coerceTagModes({ tagMode: "and" })).toEqual({ + language: "or", + topic: "and", + other: "or", + }); + }); + + it("ignores a legacy tagMode:'or' (stays all-'or')", () => { + expect(coerceTagModes({ tagMode: "or" })).toEqual(DEFAULT_TAG_MODES); + }); + + it("preserves an explicit per-category tagModes object", () => { + expect(coerceTagModes({ tagModes: { language: "and", topic: "or", other: "and" } })).toEqual({ + language: "and", + topic: "or", + other: "and", + }); + }); + + it("an explicit per-category value wins over the legacy scalar", () => { + // A blob carrying both should honour the new per-category topic, not the legacy global. + expect(coerceTagModes({ tagMode: "and", tagModes: { topic: "or" } }).topic).toBe("or"); + }); + + it("coerces invalid/garbage values to 'or'", () => { + expect(coerceTagModes({ tagModes: { topic: "x" as unknown as "or" } }).topic).toBe("or"); + expect(coerceTagModes({ tagModes: { language: 1 as unknown as "or" } }).language).toBe("or"); + }); +}); diff --git a/frontend/src/lib/chipOverflow.test.ts b/frontend/src/lib/chipOverflow.test.ts new file mode 100644 index 00000000..02ae1d99 --- /dev/null +++ b/frontend/src/lib/chipOverflow.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { decideChipLayout, type ChipFitInput } from "./chipOverflow"; + +// Build measurer-style right edges for `n` chips of a fixed width, starting after a label, with a gap. +function rights(labelRight: number, widths: number[], gap: number): number[] { + const out: number[] = []; + let cursor = labelRight; + for (const w of widths) { + cursor += gap + w; + out.push(cursor); + } + return out; +} + +const base = (over: Partial = {}): ChipFitInput => { + const gap = 6; + const labelRight = 60; + const widths = [50, 50, 50]; // 3 chips + return { + n: 3, + labelRight, + chipRights: rights(labelRight, widths, gap), + plusW: 40, + clearW: 6 + 60, + avail: 1000, + gap, + narrowPx: 420, + ...over, + }; +}; + +describe("decideChipLayout", () => { + it("shows all chips when the whole set plus Clear all fits", () => { + expect(decideChipLayout(base({ avail: 1000 }))).toEqual({ mode: "all", visible: 3 }); + }); + + it("empty chip set is a no-op 'all' with 0 visible", () => { + expect(decideChipLayout(base({ n: 0, chipRights: [] }))).toEqual({ mode: "all", visible: 0 }); + }); + + it("collapses to a single count pill on a narrow lane when overflowing", () => { + // last chip right edge is ~228; avail below NARROW and below that -> count. + expect(decideChipLayout(base({ avail: 200, narrowPx: 420 }))).toEqual({ + mode: "count", + visible: 0, + }); + }); + + it("fits as many chips as leave room for the +N pill on a wide-but-tight lane", () => { + // chip right edges: 116, 172, 228; clearW 66. all-fit needs 228+66=294 <= avail; keep avail below + // that (so not 'all') but >= narrowPx (so not 'count'). Loop reserves gap(6)+plus(40)=46 per chip: + // chip2 228+46=274 > 250 -> dropped; chip1 218 <= 250 -> visible 2. + expect(decideChipLayout(base({ avail: 250, narrowPx: 200 }))).toEqual({ + mode: "some", + visible: 2, + }); + }); + + it("reserves the +N pill width so a later chip is dropped when it wouldn't fit beside it", () => { + // Two chips of width 100 after a label, gap 6, plus pill 40. Right edges: 166, 272. avail 300, + // narrowPx 100 (so 300 >= narrow -> 'some'): chip0 166+46=212 <= 300 ok; chip1 272+46=318 > 300 + // -> dropped. visible 1. + const input = base({ + n: 2, + chipRights: rights(60, [100, 100], 6), + plusW: 40, + clearW: 6 + 60, + avail: 300, + gap: 6, + narrowPx: 100, + }); + expect(decideChipLayout(input)).toEqual({ mode: "some", visible: 1 }); + }); + + it("falls back to the count pill when not even one chip fits beside the +N pill", () => { + // Wide-ish lane (>= narrow) but a single very wide chip: label 60, chip width 500 -> right 566. + // avail 430 >= narrow(420); all-fit 566+clearW > 430; loop: 566+6+40 > 430 -> k=0; labelRight + // 60+6+40=106 <= 430 so NOT count by that guard... use narrow-avail instead: keep avail 430 but + // make label+gap+plus exceed it to force count. + const input = base({ + n: 1, + labelRight: 400, + chipRights: [966], + plusW: 40, + clearW: 0, + avail: 430, + gap: 6, + narrowPx: 420, + }); + // label 400 + gap 6 + plus 40 = 446 > 430 and k===0 -> count. + expect(decideChipLayout(input)).toEqual({ mode: "count", visible: 0 }); + }); +}); diff --git a/frontend/src/lib/urlState.dom.test.ts b/frontend/src/lib/urlState.dom.test.ts index 237f85db..3e444bcd 100644 --- a/frontend/src/lib/urlState.dom.test.ts +++ b/frontend/src/lib/urlState.dom.test.ts @@ -121,4 +121,23 @@ describe("filterParamUpdates", () => { expect(back.scope).toBe("all"); expect(back.includeShorts).toBe(true); }); + + it("round-trips per-category tag modes (only 'and' is emitted)", () => { + const f: FeedFilters = { + ...DEFAULTS, + tags: [1, 2], + tagModes: { language: "and", topic: "and", other: "or" }, + }; + const params = filtersToParams(f); + expect(params.get("tagmode_language")).toBe("and"); + expect(params.get("tagmode_topic")).toBe("and"); + expect(params.has("tagmode_other")).toBe(false); // 'or' is the default, not emitted + const back = paramsToFilters(params, DEFAULTS); + expect(back.tagModes).toEqual({ language: "and", topic: "and", other: "or" }); + }); + + it("migrates a legacy global ?tagmode=and into the topic slot only", () => { + const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS); + expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" }); + }); });