test(sidebar): cover the date preset/custom selection logic (R13 S2c)

The deep-review flagged that the feed sidebar's new date single-select
derivation was the one bit of migrated logic without a test, while its sibling
primitives (FilterChipGroup/SegmentedToggle/SortControl) all gained one. Lift
the pure logic into lib/dateFilter (deriveDateKey + applyDateChip), same split
as columnSort, and cover it: dateKey for none/preset/custom/off-preset-value,
the preset toggle-off, the preset-not-active-while-custom-open case, and the
Custom open/close + maxAgeDays clearing. Sidebar now calls the helpers; the
onChange is behaviour-identical (self-E2E'd: preset on/off, Custom editor).
11 new tests (vitest 136 -> 147). gate green. Branch not merged.
This commit is contained in:
2026-08-15 02:27:22 +02:00
parent 80cdafdbfe
commit 003043a989
3 changed files with 145 additions and 25 deletions
+6 -25
View File
@@ -10,6 +10,7 @@ import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget"; import SavedViewsWidget from "./SavedViewsWidget";
import SidePanel from "./SidePanel"; import SidePanel from "./SidePanel";
@@ -152,17 +153,8 @@ export default function Sidebar({
case "channel": case "channel":
return <ChannelPicker filters={filters} setFilters={setFilters} />; return <ChannelPicker filters={filters} setFilters={setFilters} />;
case "date": { case "date": {
// Single-select across the presets + Custom: a preset is active only when its maxAge matches // Single-select across the presets + Custom; pure logic lives in lib/dateFilter.
// and no custom range is open; otherwise Custom owns the selection (or none). const dateKey = deriveDateKey(filters, customDates, DATE_PRESETS);
const activePresetDays = DATE_PRESETS.find(
(p) => filters.maxAgeDays === p.days && !customDates,
)?.days;
const dateKey =
customDates || filters.dateFrom || filters.dateTo
? "custom"
: activePresetDays != null
? String(activePresetDays)
: "";
return ( return (
<> <>
<FilterChipGroup <FilterChipGroup
@@ -176,20 +168,9 @@ export default function Sidebar({
{ value: "custom", label: t("sidebar.custom") }, { value: "custom", label: t("sidebar.custom") },
]} ]}
onChange={(key) => { onChange={(key) => {
if (key === "custom") { const change = applyDateChip(key, filters, customDates);
const next = !customDates; if (change.customDates !== undefined) setCustomDates(change.customDates);
setCustomDates(next); if (change.filters) setFilters(change.filters);
if (next) setFilters({ ...filters, maxAgeDays: undefined });
return;
}
const days = Number(key);
const activePreset = filters.maxAgeDays === days && !customDates;
setFilters({
...filters,
maxAgeDays: activePreset ? undefined : days,
dateFrom: undefined,
dateTo: undefined,
});
}} }}
/> />
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import { applyDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
import { type FeedFilters } from "./api";
const PRESETS: DatePreset[] = [
{ days: 1, key: "24h" },
{ days: 7, key: "1week" },
{ days: 30, key: "1month" },
{ days: 180, key: "6months" },
{ days: 365, key: "1year" },
];
const base = (over: Partial<FeedFilters> = {}): FeedFilters => ({
tags: [],
tagMode: "or",
q: "",
sort: "date",
scope: "my",
includeNormal: true,
includeShorts: true,
includeLive: true,
show: "unwatched",
channelIds: [],
...over,
});
describe("deriveDateKey", () => {
it("is empty when no date filter is set", () => {
expect(deriveDateKey(base(), false, PRESETS)).toBe("");
});
it("is the preset day-count when maxAgeDays matches a preset", () => {
expect(deriveDateKey(base({ maxAgeDays: 7 }), false, PRESETS)).toBe("7");
});
it("is empty when maxAgeDays matches no preset (e.g. a shared-link value)", () => {
expect(deriveDateKey(base({ maxAgeDays: 45 }), false, PRESETS)).toBe("");
});
it("is 'custom' when the custom editor is open", () => {
expect(deriveDateKey(base(), true, PRESETS)).toBe("custom");
});
it("is 'custom' when a from/to range is set, even over a stale maxAgeDays", () => {
expect(deriveDateKey(base({ dateFrom: "2026-01-01", maxAgeDays: 7 }), false, PRESETS)).toBe(
"custom",
);
});
});
describe("applyDateChip", () => {
it("picking a preset sets maxAgeDays and clears any custom range", () => {
const change = applyDateChip(
"7",
base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }),
false,
);
expect(change.customDates).toBeUndefined();
expect(change.filters).toMatchObject({ maxAgeDays: 7, dateFrom: undefined, dateTo: undefined });
});
it("re-picking the active preset toggles it off", () => {
const change = applyDateChip("7", base({ maxAgeDays: 7 }), false);
expect(change.filters?.maxAgeDays).toBeUndefined();
});
it("a preset is not 'active' while a custom range is open, so it sets rather than toggling off", () => {
const change = applyDateChip("7", base({ maxAgeDays: 7 }), true);
expect(change.filters?.maxAgeDays).toBe(7);
});
it("opening Custom sets the flag and clears a preset's maxAgeDays", () => {
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), false);
expect(change.customDates).toBe(true);
expect(change.filters).toMatchObject({ maxAgeDays: undefined });
});
it("closing Custom clears the flag and leaves filters untouched", () => {
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), true);
expect(change.customDates).toBe(false);
expect(change.filters).toBeUndefined();
});
it("preserves unrelated filter fields when picking a preset", () => {
const change = applyDateChip("30", base({ q: "hello", tags: [1, 2] }), false);
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
});
});
+51
View File
@@ -0,0 +1,51 @@
import { type FeedFilters } from "./api";
// Pure date-filter selection logic for the feed sidebar's "Upload date" chip row, lifted out of the
// component so the single-select derivation + the preset/custom dispatch can be unit-tested without
// mounting Sidebar (same split as columnSort.ts for sort). The row is single-select across the day
// presets plus a "Custom" range chip.
export interface DatePreset {
days: number;
key: string;
}
/** Which date chip is active: a preset's day-count (as a string), "custom", or "" (none). A custom
* range (the editor is open, or a from/to is set) owns the selection; otherwise a preset matches only
* when no custom range is active; a maxAge that matches no preset (e.g. a shared-link value) is none. */
export function deriveDateKey(
filters: Pick<FeedFilters, "maxAgeDays" | "dateFrom" | "dateTo">,
customDates: boolean,
presets: readonly DatePreset[],
): string {
if (customDates || filters.dateFrom || filters.dateTo) return "custom";
const preset = presets.find((p) => filters.maxAgeDays === p.days);
return preset ? String(preset.days) : "";
}
/** The state change when a date chip is picked. A preset is single-select with toggle-off (re-picking
* the active preset clears it) and clears any custom range; "custom" toggles the range editor and,
* when opened, clears a preset. Returns the next `filters` (only when they change) and/or the next
* `customDates` flag (only when it changes) so the caller applies exactly what moved. */
export function applyDateChip(
key: string,
filters: FeedFilters,
customDates: boolean,
): { filters?: FeedFilters; customDates?: boolean } {
if (key === "custom") {
const next = !customDates;
return next
? { customDates: true, filters: { ...filters, maxAgeDays: undefined } }
: { customDates: false };
}
const days = Number(key);
const activePreset = filters.maxAgeDays === days && !customDates;
return {
filters: {
...filters,
maxAgeDays: activePreset ? undefined : days,
dateFrom: undefined,
dateTo: undefined,
},
};
}