refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,149 @@
// @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";
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,
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");
});
});