Files
Multi-Debrid-Downloader/tests/collector-view.test.tsx
T
Sucukdeluxe da4c2fb069 Prevent collector snapshot rerender stalls
Memoize the collector content across unrelated app snapshots and release collapsed child rows from the DOM after their closing animation. Draw the idle speed sparkline as a pixel-aligned one-physical-pixel stroke so its thickness remains uniform across display scales.
2026-08-26 14:09:01 +02:00

410 lines
18 KiB
TypeScript

import { readFileSync } from "node:fs";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import {
buildCollectorTransferPackages,
buildCollectorWorkspaceViewModel,
mergeCollectorEnrichment,
mergeCollectorPackages,
removeCollectorLinks,
selectCollectorPackageLinks,
type CollectorPackage
} from "../src/renderer/views/collector/collector-model";
import {
CollectorContent,
CollectorInputDialog,
MemoizedCollectorContent,
CollectorSidebar,
CollectorToolbar,
CollectorView,
toggleAllCollectorPackageIds,
type CollectorViewActions
} from "../src/renderer/views/collector/CollectorView";
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
if (Array.isArray(node)) {
node.forEach((child) => visitElements(child, visit));
return;
}
if (!isValidElement(node)) return;
visit(node);
visitElements(node.props.children, visit);
visitElements(node.props.actions, visit);
}
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
let result: ReactElement | null = null;
visitElements(node, (element) => {
if (!result && predicate(element)) result = element;
});
if (!result) throw new Error("Element not found");
return result;
}
function findButton(node: ReactNode, label: string): ReactElement {
return findElement(node, (element) => element.type === "button" && element.props.children === label);
}
function createActions(overrides: Partial<CollectorViewActions> = {}): CollectorViewActions {
return {
onFilterChange: () => {},
onOpenInput: () => {},
onImportDlc: () => {},
onImportFile: () => {},
onSubmitSelected: () => {},
onSubmitAll: () => {},
onQueryChange: () => {},
onLinkSelectionChange: () => {},
onPackageSelectionChange: () => {},
onPackageCollapseChange: () => {},
onToggleAllPackages: () => {},
onRemoveSelected: () => {},
...overrides
};
}
const packages: CollectorPackage[] = [{
id: "package-sbs",
name: "SBS14HD",
nameSource: "inferred",
addedAt: 1_000,
links: [
{ id: "link-1", url: "https://1fichier.com/?one11111", fileName: "SBS14HD.part01.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 1_000 },
{ id: "link-2", url: "https://1fichier.com/?two22222", fileName: "SBS14HD.part02.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 1_000 }
]
}, {
id: "package-mixed",
name: "Mixed",
nameSource: "inferred",
addedAt: 2_000,
links: [
{ id: "link-3", url: "https://example.test/unknown", fileName: "download.bin", fileSizeBytes: null, hoster: "example", availability: "unknown", status: "unknown", addedAt: 2_000 },
{ id: "link-4", url: "https://example.test/offline", fileName: "offline.bin", fileSizeBytes: null, hoster: "example", availability: "offline", status: "offline", addedAt: 2_000 }
]
}];
describe("collector workspace model", () => {
it("merges late enrichment by URL without duplicates and moves the link into its resolved package", () => {
const initial: CollectorPackage[] = [{
id: "pending",
name: "Unsortiert",
nameSource: "inferred",
addedAt: 1_000,
links: [{ id: "stable-link", url: "https://1fichier.com/?one11111", fileName: "download.bin", fileSizeBytes: null, hoster: "1fichier", availability: "unknown", status: "unknown", addedAt: 1_000 }]
}];
const enriched: CollectorPackage[] = [{
id: "resolved",
name: "SBS14HD",
nameSource: "inferred",
addedAt: 2_000,
links: [{ id: "replacement-id", url: "https://1fichier.com/?one11111", fileName: "SBS14HD.part01.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 2_000 }]
}];
const result = mergeCollectorPackages(initial, enriched);
expect(result).toEqual(expect.objectContaining({ addedLinks: 0, duplicateLinks: 0, enrichedLinks: 1 }));
expect(result.packages).toHaveLength(1);
expect(result.packages[0].name).toBe("SBS14HD");
expect(result.packages[0].links).toEqual([expect.objectContaining({
id: "stable-link",
fileName: "SBS14HD.part01.rar",
fileSizeBytes: 471_859_200,
availability: "online",
status: "ready",
addedAt: 1_000
})]);
});
it("deduplicates repeated incoming URLs while preserving distinct links", () => {
const incoming: CollectorPackage[] = [{
id: "incoming",
name: "SBS14HD",
nameSource: "inferred",
addedAt: 3_000,
links: [
{ ...packages[0].links[0], id: "duplicate" },
{ ...packages[0].links[0], id: "duplicate-again" },
{ id: "link-5", url: "https://1fichier.com/?three333", fileName: "SBS14HD.part03.rar", fileSizeBytes: 10, hoster: "1fichier", availability: "online", status: "ready", addedAt: 3_000 }
]
}];
const result = mergeCollectorPackages(packages, incoming);
expect(result.addedLinks).toBe(1);
expect(result.duplicateLinks).toBe(2);
expect(result.packages[0].links.map((link) => link.id)).toEqual(["link-1", "link-2", "link-5"]);
});
it("does not restore links removed while background enrichment is running", () => {
const current = [{ ...packages[0], links: [packages[0].links[0]] }];
const incoming = [{ ...packages[0], links: packages[0].links.map((link) => ({ ...link, status: "ready" as const })) }];
expect(mergeCollectorEnrichment(current, incoming).packages[0].links.map((link) => link.id)).toEqual(["link-1"]);
expect(mergeCollectorEnrichment([], incoming).packages).toEqual([]);
});
it("does not replace known metadata with a repeated unknown skeleton", () => {
const current = [{ ...packages[0], links: [packages[0].links[0]] }];
const incoming: CollectorPackage[] = [{
id: "pending",
name: "1Fichier",
nameSource: "inferred",
addedAt: 4_000,
links: [{
...packages[0].links[0],
id: "replacement",
fileName: "download.bin",
fileSizeBytes: null,
availability: "unknown",
status: "unknown",
addedAt: 4_000
}]
}];
const result = mergeCollectorPackages(current, incoming);
expect(result.packages).toHaveLength(1);
expect(result.packages[0].name).toBe("SBS14HD");
expect(result.packages[0].links[0]).toEqual(expect.objectContaining({
id: "link-1",
fileName: "SBS14HD.part01.rar",
fileSizeBytes: 471_859_200,
availability: "online",
status: "ready",
addedAt: 1_000
}));
});
it("applies a resolved package name even when link metadata was already complete", () => {
const current: CollectorPackage[] = [{
id: "pending",
name: "Unsortiert",
nameSource: "inferred",
addedAt: 1_000,
links: [{ ...packages[0].links[0], id: "stable-link" }]
}];
const incoming: CollectorPackage[] = [{
id: "resolved",
name: "SBS14HD",
nameSource: "inferred",
addedAt: 2_000,
links: [{ ...packages[0].links[0], id: "replacement-link" }]
}];
const result = mergeCollectorPackages(current, incoming);
expect(result.enrichedLinks).toBe(1);
expect(result.duplicateLinks).toBe(0);
expect(result.packages.map((pkg) => [pkg.name, pkg.links.map((link) => link.id)])).toEqual([
["SBS14HD", ["stable-link"]]
]);
});
it("keeps explicit package names authoritative over inferred enrichment", () => {
const current: CollectorPackage[] = [{
id: "explicit",
name: "Meine Staffel",
nameSource: "explicit",
addedAt: 1_000,
links: [{ ...packages[0].links[0], id: "stable-link", availability: "unknown", status: "unknown" }]
}];
const incoming: CollectorPackage[] = [{
id: "inferred",
name: "SBS14HD",
nameSource: "inferred",
addedAt: 2_000,
links: [{ ...packages[0].links[0], id: "replacement-link" }]
}];
const result = mergeCollectorPackages(current, incoming);
expect(result.packages).toHaveLength(1);
expect(result.packages[0]).toEqual(expect.objectContaining({ name: "Meine Staffel", nameSource: "explicit" }));
expect(result.packages[0].links[0]).toEqual(expect.objectContaining({ id: "stable-link", availability: "online", status: "ready" }));
});
it("upgrades an inferred package identity when explicit metadata arrives", () => {
const current: CollectorPackage[] = [{
id: "inferred",
name: "SBS14HD",
nameSource: "inferred",
addedAt: 1_000,
links: [{ ...packages[0].links[0], id: "stable-link", availability: "unknown", status: "unknown" }]
}];
const incoming: CollectorPackage[] = [{
id: "explicit",
name: "SBS14HD",
nameSource: "explicit",
addedAt: 2_000,
links: [{ ...packages[0].links[0], id: "replacement-link" }]
}];
const result = mergeCollectorPackages(current, incoming);
expect(result.packages[0].nameSource).toBe("explicit");
});
it("supports whole-package selection, partial transfers and partial removal", () => {
const selected = selectCollectorPackageLinks(new Set(["link-3"]), packages[0], true);
expect([...selected].sort()).toEqual(["link-1", "link-2", "link-3"]);
expect(buildCollectorTransferPackages(packages, new Set(["link-2", "link-3"])).map((pkg) => [pkg.name, pkg.links.map((link) => link.id)])).toEqual([
["SBS14HD", ["link-2"]], ["Mixed", ["link-3"]]
]);
expect(removeCollectorLinks(packages, new Set(["link-2", "link-3"])).map((pkg) => [pkg.name, pkg.links.map((link) => link.id)])).toEqual([
["SBS14HD", ["link-1"]], ["Mixed", ["link-4"]]
]);
});
it("derives aggregates, filters and search once for the view", () => {
const model = buildCollectorWorkspaceViewModel(packages, "online", "part02", false, ["link-2"], ["package-mixed"], "", true);
expect(model.packages).toHaveLength(1);
expect(model.packages[0]).toEqual(expect.objectContaining({ totalBytes: 943_718_400, unknownSizeCount: 0, onlineCount: 2, totalCount: 2, selectedCount: 1, collapsed: false }));
expect(model.packages[0].links.map((link) => link.id)).toEqual(["link-2"]);
expect(model.filters).toEqual([
{ id: "all", label: "Alle", count: 4 },
{ id: "online", label: "Online", count: 2 },
{ id: "unknown", label: "Ungeprüft", count: 1 },
{ id: "offline", label: "Offline", count: 1 }
]);
});
it("keeps initially unknown links visible while background analysis runs", () => {
const model = buildCollectorWorkspaceViewModel([packages[1]], "all", "", true, [], [], "", true);
expect(model.analyzing).toBe(true);
expect(model.empty).toBe(false);
expect(model.packages[0].links.map((link) => link.fileName)).toContain("download.bin");
});
it("toggles all package identities independent of active filters", () => {
const packageIds = packages.map((pkg) => pkg.id);
expect([...toggleAllCollectorPackageIds(packageIds, new Set())].sort()).toEqual(["package-mixed", "package-sbs"]);
expect([...toggleAllCollectorPackageIds(packageIds, new Set(["package-sbs"]))].sort()).toEqual(["package-mixed", "package-sbs"]);
expect([...toggleAllCollectorPackageIds(packageIds, new Set(packageIds))]).toEqual([]);
});
});
describe("CollectorView", () => {
it("renders expandable package and file rows with preview columns", () => {
const html = renderToStaticMarkup(<CollectorView actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true)} />);
for (const heading of ["Name", "Größe", "Hoster", "Status", "Verfügbarkeit", "Hinzugefügt"]) expect(html).toContain(`>${heading}<`);
expect(html).toContain("SBS14HD");
expect(html).toContain("SBS14HD.part01.rar");
expect(html).toContain("SBS14HD.part02.rar");
expect(html).toContain("2/2 online");
expect(html).toContain("aria-label=\"SBS14HD einklappen\"");
expect(html).not.toContain("URL oder Rohzeile");
expect(html).not.toContain(">Zeile<");
});
it("keeps rows and actions available during background analysis", () => {
const model = buildCollectorWorkspaceViewModel(packages, "all", "", true, ["link-1"], [], "", true);
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={model} />);
const toolbar = CollectorToolbar({ actions: createActions(), model });
expect(html).toContain("Analyse läuft im Hintergrund");
expect(html).toContain("SBS14HD.part01.rar");
expect(findButton(toolbar, "Auswahl übergeben (1)").props.disabled).toBe(false);
expect(findButton(toolbar, "Alle übergeben (4)").props.disabled).toBe(false);
expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false);
});
it("renders known hosters as icons with their full name as tooltip", () => {
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true)} />);
expect(html).toContain('class="collector-hoster-label" title="1Fichier"');
expect(html).toContain('class="collector-hoster-icon" data-hoster="1fichier" src="./provider-icons/onefichier.png"');
});
it("renders collapsed packages without child rows when animations are disabled", () => {
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], ["package-sbs"], "", false)} />);
expect(html).toContain("aria-label=\"SBS14HD ausklappen\"");
expect(html).not.toContain("SBS14HD.part01.rar");
});
it("removes collapsed child rows from the DOM even when animations are enabled", () => {
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel([packages[0]], "all", "", false, [], ["package-sbs"], "", true)} />);
expect(html).not.toContain("collector-package-items-frame");
expect(html).not.toContain("SBS14HD.part01.rar");
});
it("reuses the collector content when app snapshots keep the same collector model", () => {
const model = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true);
const compare = (MemoizedCollectorContent as unknown as {
compare: (previous: { model: typeof model; actions: CollectorViewActions }, next: { model: typeof model; actions: CollectorViewActions }) => boolean;
}).compare;
expect(compare({ model, actions: createActions() }, { model, actions: createActions() })).toBe(true);
expect(compare({ model, actions: createActions() }, { model: { ...model, query: "neu" }, actions: createActions() })).toBe(false);
});
it("offers selected and all transfer actions", () => {
let selected = 0;
let all = 0;
const toolbar = CollectorToolbar({
actions: createActions({ onSubmitSelected: () => { selected += 1; }, onSubmitAll: () => { all += 1; } }),
model: buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1"], [], "", true)
});
findButton(toolbar, "Auswahl übergeben (1)").props.onClick();
findButton(toolbar, "Alle übergeben (4)").props.onClick();
expect(selected).toBe(1);
expect(all).toBe(1);
});
it("renders accessible mixed package selection", () => {
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1"], [], "", true)} />);
expect(html).toContain("aria-label=\"Paket SBS14HD auswählen\"");
expect(html).toContain("aria-checked=\"mixed\"");
});
it("routes status filters and search independently", () => {
let filter = "";
let query = "";
const model = buildCollectorWorkspaceViewModel(packages, "online", "", false, [], [], "", true);
const actions = createActions({ onFilterChange: (value) => { filter = value; }, onQueryChange: (value) => { query = value; } });
const sidebar = CollectorSidebar({ actions, model });
findElement(sidebar, (element) => element.type === "button" && element.props["aria-current"] === "page").props.onClick();
const toolbar = CollectorToolbar({ actions, model });
findElement(toolbar, (element) => element.props.label === "Links durchsuchen").props.onChange({ target: { value: "part02" } });
expect(filter).toBe("online");
expect(query).toBe("part02");
});
it("keeps errors visible without replacing existing packages", () => {
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "Ein Link konnte nicht geprüft werden", true)} />);
expect(html).toContain("Ein Link konnte nicht geprüft werden");
expect(html).toContain("SBS14HD.part01.rar");
});
it("uses an analysis dialog instead of a raw tab editor", () => {
let value = "";
let commits = 0;
const dialog = CollectorInputDialog({ open: true, value, onChange: (next) => { value = next; }, onClose: () => {}, onCommit: () => { commits += 1; } });
const html = renderToStaticMarkup(dialog);
expect(html).toContain("Links erscheinen sofort und werden anschließend im Hintergrund geprüft.");
expect(html).toContain("Hinzufügen");
findElement(dialog, (element) => element.type === "textarea").props.onChange({ target: { value: "https://1fichier.com/?abc" } });
findButton(dialog, "Hinzufügen").props.onClick();
expect(value).toBe("https://1fichier.com/?abc");
expect(commits).toBe(1);
});
it("uses aligned responsive package grids and content visibility", () => {
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
expect(css).toMatch(/\.collector-table-header-row,\s*\.collector-package-row,\s*\.collector-file-row\s*\{[^}]*grid-template-columns:/s);
expect(css).toMatch(/\.collector-package-group\s*\{[^}]*content-visibility:\s*auto;/s);
expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar\s*\{[^}]*flex-wrap:\s*wrap;/s);
});
it("uses one consistent gap across collector toolbar groups", () => {
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
const toolbarGap = css.match(/\.collector-toolbar\s*\{[^}]*gap:\s*([^;]+);/s)?.[1]?.trim();
const groupGap = css.match(/\.collector-toolbar \.ui-toolbar-group\s*\{[^}]*gap:\s*([^;]+);/s)?.[1]?.trim();
expect(toolbarGap).toBeTruthy();
expect(groupGap).toBe(toolbarGap);
});
});