Render known hosters with their existing provider icons in collector package and file rows while retaining the full hoster name as a tooltip. Fall back to the compact text label if an icon cannot be loaded.
227 lines
11 KiB
TypeScript
227 lines
11 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 type { CollectorPackage } from "../src/shared/collector";
|
|
import {
|
|
buildCollectorTransferPackages,
|
|
buildCollectorWorkspaceViewModel,
|
|
mergeCollectorPackages,
|
|
removeCollectorLinks,
|
|
selectCollectorPackageLinks
|
|
} from "../src/renderer/views/collector/collector-model";
|
|
import { CollectorContent, CollectorInputDialog, CollectorSidebar, CollectorToolbar, CollectorView, 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: () => {},
|
|
onRemoveSelected: () => {},
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
const packages: CollectorPackage[] = [{
|
|
id: "package-sbs",
|
|
name: "SBS14HD",
|
|
addedAt: 1000,
|
|
links: [
|
|
{ id: "link-1", url: "https://1fichier.com/?one11111", fileName: "SBS14HD.part01.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 1000 },
|
|
{ id: "link-2", url: "https://1fichier.com/?two22222", fileName: "SBS14HD.part02.rar", fileSizeBytes: 471_859_200, hoster: "1fichier", availability: "online", status: "ready", addedAt: 1000 }
|
|
]
|
|
}, {
|
|
id: "package-mixed",
|
|
name: "Mixed",
|
|
addedAt: 2000,
|
|
links: [
|
|
{ id: "link-3", url: "https://example.test/unknown", fileName: "unknown.bin", fileSizeBytes: null, hoster: "example", availability: "unknown", status: "unknown", addedAt: 2000 },
|
|
{ id: "link-4", url: "https://example.test/offline", fileName: "offline.bin", fileSizeBytes: null, hoster: "example", availability: "offline", status: "offline", addedAt: 2000 }
|
|
]
|
|
}];
|
|
|
|
describe("collector workspace model", () => {
|
|
it("merges packages by name while deduplicating URLs", () => {
|
|
const incoming: CollectorPackage[] = [{
|
|
id: "incoming",
|
|
name: "SBS14HD",
|
|
addedAt: 3000,
|
|
links: [
|
|
{ ...packages[0].links[0], id: "duplicate" },
|
|
{ id: "link-5", url: "https://1fichier.com/?three333", fileName: "SBS14HD.part03.rar", fileSizeBytes: 10, hoster: "1fichier", availability: "online", status: "ready", addedAt: 3000 }
|
|
]
|
|
}];
|
|
const result = mergeCollectorPackages(packages, incoming);
|
|
|
|
expect(result.addedLinks).toBe(1);
|
|
expect(result.duplicateLinks).toBe(1);
|
|
expect(result.packages[0].id).toBe("package-sbs");
|
|
expect(result.packages[0].links.map((link) => link.id)).toEqual(["link-1", "link-2", "link-5"]);
|
|
});
|
|
|
|
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 Links", count: 4 }, { id: "online", label: "Online", count: 2 }, { id: "unknown", label: "Ungeprüft", count: 1 }, { id: "offline", label: "Offline", count: 1 }
|
|
]);
|
|
});
|
|
});
|
|
|
|
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<");
|
|
expect(html).not.toContain(">Lokal<");
|
|
});
|
|
|
|
it("renders known hosters as icons with their full name as tooltip", () => {
|
|
const ddownloadPackages: CollectorPackage[] = [{
|
|
id: "package-ddownload",
|
|
name: "Archive",
|
|
addedAt: 3000,
|
|
links: [{
|
|
id: "link-ddownload",
|
|
url: "https://ddownload.com/ntwscdw62gyb",
|
|
fileName: "Archive.part01.rar",
|
|
fileSizeBytes: 526_385_152,
|
|
hoster: "ddownload",
|
|
availability: "online",
|
|
status: "ready",
|
|
addedAt: 3000
|
|
}]
|
|
}];
|
|
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(ddownloadPackages, "all", "", false, [], [], "", true)} />);
|
|
|
|
expect(html).toContain('class="collector-hoster-label" title="DDownload"');
|
|
expect(html).toContain('class="collector-hoster-icon" data-hoster="ddownload" src="./provider-icons/ddownload.ico"');
|
|
expect(html).not.toContain('title="DDownload">DD<');
|
|
});
|
|
|
|
it("renders collapsed packages without child rows", () => {
|
|
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("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 busy, error and empty states inside the table body", () => {
|
|
const actions = createActions();
|
|
const empty = renderToStaticMarkup(<CollectorContent actions={actions} model={buildCollectorWorkspaceViewModel([], "all", "", false, [], [], "", true)} />);
|
|
const busy = renderToStaticMarkup(<CollectorContent actions={actions} model={buildCollectorWorkspaceViewModel([], "all", "", true, [], [], "", true)} />);
|
|
const failed = renderToStaticMarkup(<CollectorContent actions={actions} model={buildCollectorWorkspaceViewModel([], "all", "", false, [], [], "Import fehlgeschlagen", true)} />);
|
|
expect(empty).toContain("Noch keine Links");
|
|
expect(busy).toContain("Links werden analysiert");
|
|
expect(failed).toContain("Import fehlgeschlagen");
|
|
});
|
|
|
|
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 werden geprüft und automatisch zu Downloadpaketen gruppiert.");
|
|
expect(html).toContain("Analysieren");
|
|
findElement(dialog, (element) => element.type === "textarea").props.onChange({ target: { value: "https://1fichier.com/?abc" } });
|
|
findButton(dialog, "Analysieren").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, \.collector-package-row, \.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);
|
|
});
|
|
});
|