The channel manager lists when each follow reached the list (the subscription import that first saw it, or a subscribe made here) as a sortable Added date after Last upload. The About cell's card opens after the pointer rests on it for half a second instead of at once, and a scroll puts an open one away.
189 lines
6.6 KiB
TypeScript
189 lines
6.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// applySyncFilter is the sync-status pre-filter the top chips drive (the Sync column was retired and
|
|
// its status folded into a Stored charm). It replaced a column `filter.test`, so it carries the exact
|
|
// "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change
|
|
// which channels the top chips show.
|
|
import { describe, it, expect } from "vitest";
|
|
import {
|
|
applySyncFilter,
|
|
buildChannelColumns,
|
|
tagFilterChips,
|
|
tagsColumnTest,
|
|
} from "./channelColumns";
|
|
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api";
|
|
import type { TFunction } from "i18next";
|
|
import { render, screen } from "@testing-library/react";
|
|
import { formatDate } from "../../lib/format";
|
|
|
|
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
|
|
id: "c",
|
|
title: null,
|
|
handle: null,
|
|
thumbnail_url: null,
|
|
subscriber_count: null,
|
|
video_count: null,
|
|
description: null,
|
|
stored_videos: 0,
|
|
last_video_at: null,
|
|
added_at: "2026-06-10T08:00:00+00:00",
|
|
total_duration_seconds: 0,
|
|
priority: 0,
|
|
hidden: false,
|
|
deep_requested: false,
|
|
deep_in_queue: false,
|
|
tag_ids: [],
|
|
details_synced: false,
|
|
recent_synced: false,
|
|
backfill_done: false,
|
|
...over,
|
|
});
|
|
|
|
const rows = [
|
|
ch({ id: "fully", recent_synced: true, backfill_done: true }),
|
|
ch({ id: "recentOnly", recent_synced: true, backfill_done: false }),
|
|
ch({ id: "none", recent_synced: false, backfill_done: false }),
|
|
];
|
|
|
|
describe("applySyncFilter", () => {
|
|
it("returns every row for the empty/all value (and unknown values)", () => {
|
|
expect(applySyncFilter(rows, "").map((r) => r.id)).toEqual(["fully", "recentOnly", "none"]);
|
|
expect(applySyncFilter(rows, undefined)).toHaveLength(3);
|
|
expect(applySyncFilter(rows, "bogus")).toHaveLength(3);
|
|
});
|
|
|
|
it("'fully' keeps only channels with recent uploads AND full history in", () => {
|
|
expect(applySyncFilter(rows, "fully").map((r) => r.id)).toEqual(["fully"]);
|
|
});
|
|
|
|
it("'needsFull' keeps every channel whose back-catalogue is not done", () => {
|
|
expect(applySyncFilter(rows, "needsFull").map((r) => r.id)).toEqual(["recentOnly", "none"]);
|
|
});
|
|
});
|
|
|
|
describe("tagsColumnTest (item B — 'No tag')", () => {
|
|
const tagged = ch({ id: "tagged", tag_ids: [1, 2] });
|
|
const untagged = ch({ id: "untagged", tag_ids: [] });
|
|
|
|
it("the No-tag sentinel keeps only channels with no user tag", () => {
|
|
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL])).toBe(false);
|
|
expect(tagsColumnTest(untagged, [NO_TAG_SENTINEL])).toBe(true);
|
|
});
|
|
|
|
it("real tag values match ANY selected id (sentinel absent)", () => {
|
|
expect(tagsColumnTest(tagged, ["2"])).toBe(true);
|
|
expect(tagsColumnTest(tagged, ["3"])).toBe(false);
|
|
expect(tagsColumnTest(untagged, ["1"])).toBe(false);
|
|
});
|
|
|
|
it("the sentinel wins if it somehow co-exists with real ids", () => {
|
|
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL, "1"])).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("tagFilterChips — a pick whose tag is gone", () => {
|
|
const mine = { id: 1, name: "Music", system: false } as Tag;
|
|
const t = ((k: string) => k) as unknown as TFunction;
|
|
type State = "loaded" | "loading" | "failed";
|
|
const chips = (state: State, picked: string[]) =>
|
|
tagFilterChips({
|
|
userTags: state === "loaded" ? [mine] : [],
|
|
tags: {
|
|
data: state === "loaded" ? [mine] : undefined,
|
|
isSuccess: state === "loaded",
|
|
isPending: state === "loading",
|
|
},
|
|
picked,
|
|
noTagOn: false,
|
|
toggle: () => {},
|
|
toggleNoTag: () => {},
|
|
t,
|
|
});
|
|
|
|
it("stays a visible, active chip instead of an empty table with no reason", () => {
|
|
expect(chips("loaded", ["1", "77"]).map((c) => [c.key, c.label, c.active])).toEqual([
|
|
["t77", "common.deletedTag", true], // first: the "+N" overflow must not fold it away
|
|
["t1", "Music", true],
|
|
[NO_TAG_SENTINEL, "channels.tags.noTag", false],
|
|
]);
|
|
});
|
|
|
|
// Before, a pick had NO chip at all until the list loaded — or ever, after a failed
|
|
// load — while it still narrowed the table.
|
|
it("is a placeholder while the tag list loads, never called deleted", () => {
|
|
expect(chips("loading", ["77"]).map((c) => [c.key, c.label, c.pending])).toEqual([
|
|
["t77", "77", true],
|
|
[NO_TAG_SENTINEL, "channels.tags.noTag", undefined],
|
|
]);
|
|
});
|
|
|
|
it("shows the id after a failed load, still a visible, removable chip", () => {
|
|
expect(chips("failed", ["77"]).map((c) => [c.key, c.label, c.pending, c.active])).toEqual([
|
|
["t77", "77", false, true],
|
|
[NO_TAG_SENTINEL, "channels.tags.noTag", undefined, false],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("buildChannelColumns — the row's end", () => {
|
|
it('ends every row in the channel\'s "…", with no action buttons before it', () => {
|
|
const more = (c: ManagedChannel) => `more:${c.id}`;
|
|
const noop = () => {};
|
|
const cols = buildChannelColumns({
|
|
t: ((k: string) => k) as never,
|
|
lang: "en",
|
|
prioOptions: [],
|
|
userTags: [],
|
|
bumpPriority: noop,
|
|
setPriority: noop,
|
|
more,
|
|
onView: noop,
|
|
toggleTag: noop,
|
|
createAndAttachTag: noop,
|
|
onFilterByTag: noop,
|
|
suggestionsFor: () => [],
|
|
onAcceptSuggestion: noop,
|
|
onDismissSuggestion: noop,
|
|
});
|
|
expect(cols[cols.length - 1]!.key).toBe("actions");
|
|
expect(cols.filter((c) => c.key === "actions")).toHaveLength(1);
|
|
expect(cols[cols.length - 1]!.render(ch({ id: "UCz" }))).toBe("more:UCz");
|
|
});
|
|
});
|
|
|
|
describe("buildChannelColumns — Added", () => {
|
|
const noop = () => {};
|
|
const cols = buildChannelColumns({
|
|
t: ((k: string) => k) as never,
|
|
lang: "en",
|
|
prioOptions: [],
|
|
userTags: [],
|
|
bumpPriority: noop,
|
|
setPriority: noop,
|
|
more: () => null,
|
|
onView: noop,
|
|
toggleTag: noop,
|
|
createAndAttachTag: noop,
|
|
onFilterByTag: noop,
|
|
suggestionsFor: () => [],
|
|
onAcceptSuggestion: noop,
|
|
onDismissSuggestion: noop,
|
|
});
|
|
const added = cols.find((c) => c.key === "added");
|
|
|
|
it("follows Last upload, and sorts by when the follow reached the list", () => {
|
|
expect(added?.header).toBe("channels.cols.added");
|
|
const keys = cols.map((c) => c.key);
|
|
expect(keys.indexOf("added")).toBe(keys.indexOf("lastUpload") + 1);
|
|
expect(added?.sortable).toBe(true);
|
|
const early = ch({ added_at: "2026-06-10T08:00:00+00:00" });
|
|
const late = ch({ added_at: "2026-10-12T08:00:00+00:00" });
|
|
expect(added!.sortValue!(late) as number).toBeGreaterThan(added!.sortValue!(early) as number);
|
|
});
|
|
|
|
it("shows the day, not a relative time", () => {
|
|
render(<>{added!.render(ch({ added_at: "2026-10-12T08:00:00+00:00" }))}</>);
|
|
expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy();
|
|
});
|
|
});
|