test(feed): cover the filter-bar gaps deep-review flagged

- test_feed_views: extend _seed_tagged so a channel carries 2+ tags in a
  non-topic category, and add language-AND / other-AND equivalence cases — the
  per-category any/all generalization was only exercised for topic.
- tagModes.test: coerceTagModes migration (legacy global tagMode -> topic),
  per-category preservation, invalid-value coercion, defaults.
- urlState.dom.test: per-category tag-mode URL round-trip + legacy ?tagmode=and
  migration into the topic slot.
- Combobox.dom.test: the any/all match footer (hidden below 2 selections, reports
  a mode change at 2+).
- chipOverflow.test: the all/some/count fit boundaries and the +N reservation.
This commit is contained in:
2026-08-22 06:33:48 +02:00
parent e90603d4db
commit 7692253636
5 changed files with 193 additions and 2 deletions
+14 -2
View File
@@ -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",
@@ -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(<Combobox {...props} selected={["Drama"]} />);
fireEvent.focus(screen.getByRole("combobox"));
expect(screen.queryByText("sidebar.all")).toBeNull();
// Two selections: footer appears with an any/all segment.
rerender(<FloatingTree>{<Combobox {...props} selected={["Drama", "Action"]} />}</FloatingTree>);
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(
+42
View File
@@ -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");
});
});
+93
View File
@@ -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> = {}): 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 });
});
});
+19
View File
@@ -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" });
});
});