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:
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user