Restore and persist the package-based link collector with progressive metadata, bounded high-volume updates, safe hydration, visible selection, and complete localization. Harden download controls, snapshot ordering, history pagination, statistics recovery, settings saves, backup imports, notification persistence, and Windows storage races with regression coverage.
799 lines
38 KiB
TypeScript
799 lines
38 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,
|
|
reconcileCollectorCollapsedPackageIdsWithPackages,
|
|
reconcileCollectorCollapsedPackageIds,
|
|
reconcileCollectorSelectionAfterRemoval,
|
|
reconcileCollectorSelectionWithPackages,
|
|
selectCollectorPackageLinks,
|
|
setCollectorVisibleSelection,
|
|
type CollectorPackage
|
|
} from "../src/renderer/views/collector/collector-model";
|
|
import {
|
|
CollectorContent,
|
|
CollectorInputDialog,
|
|
MemoizedCollectorContent,
|
|
CollectorSidebar,
|
|
CollectorSidebarStatus,
|
|
CollectorTableHeader,
|
|
CollectorToolbar,
|
|
CollectorView,
|
|
collectorFileInteractionAttributes,
|
|
collectorHeaderScrollStyle,
|
|
collectorPackageIntrinsicBlockSize,
|
|
toggleAllCollectorPackageIds,
|
|
type CollectorViewActions
|
|
} from "../src/renderer/views/collector/CollectorView";
|
|
import {
|
|
getCollectorDisclosurePinnedIds,
|
|
getCollectorDisclosureViewportIds,
|
|
getCollectorFocusPackageId,
|
|
mergeCollectorPinnedIds,
|
|
resolveCollectorTransitionPins
|
|
} from "../src/renderer/views/collector/collector-disclosure";
|
|
|
|
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: () => {},
|
|
onSetVisibleSelection: () => {},
|
|
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("prunes rolled-back selection so an identical link reimport is not ghost-selected", () => {
|
|
const afterRollback = reconcileCollectorSelectionWithPackages(new Set(["link-1", "link-4"]), [packages[1]]);
|
|
const afterReimport = buildCollectorWorkspaceViewModel(packages, "all", "", false, [...afterRollback], [], "", true);
|
|
|
|
expect(afterRollback).toEqual(new Set(["link-4"]));
|
|
expect(afterReimport.selectedIds).toEqual(["link-4"]);
|
|
});
|
|
|
|
it("prunes removed collapsed package ids so an identical package reimport stays expanded", () => {
|
|
const afterRemoval = reconcileCollectorCollapsedPackageIdsWithPackages(new Set(["package-sbs", "package-mixed"]), [packages[1]]);
|
|
const afterReimport = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [...afterRemoval], "", true);
|
|
|
|
expect(afterRemoval).toEqual(new Set(["package-mixed"]));
|
|
expect(afterReimport.packages.map((pkg) => [pkg.id, pkg.collapsed])).toEqual([
|
|
["package-sbs", false],
|
|
["package-mixed", true]
|
|
]);
|
|
});
|
|
|
|
it("sets only visible link selection while preserving hidden ids", () => {
|
|
const selected = setCollectorVisibleSelection(new Set(["link-1", "hidden-link"]), ["link-1", "link-2", "link-4"], true);
|
|
expect(selected).toEqual(new Set(["link-1", "hidden-link", "link-2", "link-4"]));
|
|
expect(setCollectorVisibleSelection(selected, ["link-1", "link-2", "link-4"], false)).toEqual(new Set(["hidden-link"]));
|
|
});
|
|
|
|
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
|
|
})]);
|
|
expect(result.persistenceDelta).toEqual({
|
|
links: [{
|
|
url: "https://1fichier.com/?one11111",
|
|
previousPackageId: "pending",
|
|
previousLink: initial[0].links[0],
|
|
nextPackageId: "resolved",
|
|
nextLink: result.packages[0].links[0]
|
|
}],
|
|
packages: [{
|
|
package: { id: "resolved", name: "SBS14HD", nameSource: "inferred", addedAt: 1_000 },
|
|
linkCount: 1
|
|
}],
|
|
removedPackageIds: ["pending"],
|
|
packageCount: 1
|
|
});
|
|
});
|
|
|
|
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("preserves hidden selections when visible selected links are removed", () => {
|
|
const next = reconcileCollectorSelectionAfterRemoval(
|
|
new Set(["link-1", "link-4"]),
|
|
new Set(["link-1"])
|
|
);
|
|
|
|
expect(next).toEqual(new Set(["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("limits selection and action scope to links visible through filter and search", () => {
|
|
const model = buildCollectorWorkspaceViewModel(
|
|
packages,
|
|
"online",
|
|
"part02",
|
|
false,
|
|
["link-1", "link-2", "link-3"],
|
|
[],
|
|
"",
|
|
true
|
|
);
|
|
|
|
expect(model.visibleIds).toEqual(["link-2"]);
|
|
expect(model.selectedIds).toEqual(["link-2"]);
|
|
expect(model.selectedCount).toBe(1);
|
|
expect(model.packages[0].selectedCount).toBe(1);
|
|
});
|
|
|
|
it("keeps offline links selectable and removable but excludes them from transfers", () => {
|
|
const model = buildCollectorWorkspaceViewModel(
|
|
packages,
|
|
"all",
|
|
"",
|
|
false,
|
|
["link-1", "link-2", "link-3", "link-4"],
|
|
[],
|
|
"",
|
|
true
|
|
);
|
|
|
|
expect(model.visibleIds).toEqual(["link-1", "link-2", "link-3", "link-4"]);
|
|
expect(model.selectedIds).toEqual(["link-1", "link-2", "link-3", "link-4"]);
|
|
expect(model.visibleTransferableIds).toEqual(["link-1", "link-2", "link-3"]);
|
|
expect(model.selectedTransferableIds).toEqual(["link-1", "link-2", "link-3"]);
|
|
expect(model.selectedCount).toBe(4);
|
|
});
|
|
|
|
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("collector disclosure virtualization", () => {
|
|
it("pins only previously visible packages whose collapsed state changes", () => {
|
|
const previousCollapsed = new Map([
|
|
["package-1", false],
|
|
["package-2", false],
|
|
["package-3", false]
|
|
]);
|
|
const nextRows = [
|
|
{ id: "package-1", collapsed: true },
|
|
{ id: "package-2", collapsed: true },
|
|
{ id: "package-3", collapsed: true }
|
|
];
|
|
|
|
expect(getCollectorDisclosurePinnedIds(["package-1", "package-2"], previousCollapsed, nextRows, true)).toEqual(["package-1", "package-2"]);
|
|
expect(getCollectorDisclosurePinnedIds(["package-1", "package-2"], previousCollapsed, nextRows, false)).toEqual([]);
|
|
});
|
|
|
|
it("retains transitioning and keyboard-focused packages across virtual window changes", () => {
|
|
expect(getCollectorDisclosureViewportIds([
|
|
{ id: "visible", index: 1, pinned: false },
|
|
{ id: "transitioning", index: 99, pinned: true }
|
|
], 0, 3)).toEqual(["visible"]);
|
|
expect(mergeCollectorPinnedIds(["transitioning"], "focused")).toEqual(["transitioning", "focused"]);
|
|
expect(getCollectorFocusPackageId([
|
|
{ id: "package-1", focusIndexStart: 0, focusCount: 4 },
|
|
{ id: "package-2", focusIndexStart: 4, focusCount: 2 }
|
|
], 4)).toBe("package-2");
|
|
expect(resolveCollectorTransitionPins(["transitioning"], ["new-visible-1", "new-visible-2"], 3)).toEqual(["transitioning", "new-visible-1", "new-visible-2"]);
|
|
expect(resolveCollectorTransitionPins(["transitioning"], ["new-visible-1", "new-visible-2"], 2)).toEqual(["new-visible-1", "new-visible-2"]);
|
|
expect(resolveCollectorTransitionPins([], Array.from({ length: 12 }, (_, index) => `package-${index}`))).toEqual(Array.from({ length: 8 }, (_, index) => `package-${index + 4}`));
|
|
});
|
|
|
|
it("removes collapsed transition files from logical focus and ARIA indexing", () => {
|
|
expect(collectorFileInteractionAttributes(2, 0, 0, false)).toEqual({ rowIndex: 3, focusIndex: 2, tabIndex: undefined });
|
|
expect(collectorFileInteractionAttributes(2, 0, 0, true)).toEqual({ rowIndex: undefined, focusIndex: undefined, tabIndex: -1 });
|
|
});
|
|
});
|
|
|
|
describe("CollectorView", () => {
|
|
it("derives header selection without iterating visible ids and forwards them unchanged", () => {
|
|
const visibleIds = new Proxy(["link-1", "link-2"], {
|
|
get(target, property, receiver) {
|
|
if (property === Symbol.iterator || property === "reduce") throw new Error("visible ids must not be iterated");
|
|
return Reflect.get(target, property, receiver);
|
|
}
|
|
});
|
|
const base = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true);
|
|
const model = { ...base, visibleIds, selectedIds: [], selectedCount: 1 };
|
|
const calls: Array<{ ids: string[]; selected: boolean }> = [];
|
|
let header: ReactElement | undefined;
|
|
|
|
expect(() => {
|
|
header = CollectorTableHeader({
|
|
actions: createActions({ onSetVisibleSelection: (ids, selected) => calls.push({ ids, selected }) }),
|
|
model,
|
|
scrollLeft: 0
|
|
});
|
|
}).not.toThrow();
|
|
if (!header) return;
|
|
|
|
const checkbox = findElement(header, (element) => element.type === "input");
|
|
checkbox.props.onChange({ target: { checked: true } });
|
|
|
|
expect(checkbox.props["aria-checked"]).toBe("mixed");
|
|
expect(checkbox.props.checked).toBe(false);
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0].ids).toBe(visibleIds);
|
|
expect(calls[0].selected).toBe(true);
|
|
});
|
|
|
|
it("routes every visible link including offline through the header checkbox", () => {
|
|
const calls: unknown[] = [];
|
|
const model = buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1", "link-4"], [], "", true);
|
|
|
|
const header = CollectorTableHeader({
|
|
actions: createActions({ onSetVisibleSelection: (ids, selected) => calls.push([ids, selected]) }),
|
|
model,
|
|
scrollLeft: 0
|
|
});
|
|
const checkbox = findElement(header, (element) => element.type === "input");
|
|
const input = { indeterminate: false };
|
|
|
|
(checkbox as unknown as { ref: (element: typeof input) => void }).ref(input);
|
|
checkbox.props.onChange({ target: { checked: true } });
|
|
|
|
expect(checkbox.props["aria-label"]).toBe("Alle sichtbaren Links auswählen");
|
|
expect(checkbox.props["aria-checked"]).toBe("mixed");
|
|
expect(checkbox.props.checked).toBe(false);
|
|
expect(checkbox.props.disabled).toBe(false);
|
|
expect(input.indeterminate).toBe(true);
|
|
expect(calls).toEqual([[['link-1', 'link-2', 'link-3', 'link-4'], true]]);
|
|
});
|
|
|
|
it("checks or disables the header checkbox from visible selection state", () => {
|
|
const selectedHeader = CollectorTableHeader({
|
|
actions: createActions(),
|
|
model: buildCollectorWorkspaceViewModel(packages, "offline", "", false, ["link-4"], [], "", true),
|
|
scrollLeft: 19
|
|
});
|
|
const selectedCheckbox = findElement(selectedHeader, (element) => element.type === "input");
|
|
const emptyHeader = CollectorTableHeader({
|
|
actions: createActions(),
|
|
model: buildCollectorWorkspaceViewModel(packages, "online", "missing", false, [], [], "", true),
|
|
scrollLeft: 0
|
|
});
|
|
const emptyCheckbox = findElement(emptyHeader, (element) => element.type === "input");
|
|
|
|
expect(selectedCheckbox.props["aria-checked"]).toBe(true);
|
|
expect(selectedCheckbox.props.checked).toBe(true);
|
|
expect(selectedCheckbox.props.disabled).toBe(false);
|
|
expect(selectedHeader.props.style).toEqual({ transform: "translateX(-19px)" });
|
|
expect(emptyCheckbox.props["aria-checked"]).toBe(false);
|
|
expect(emptyCheckbox.props.checked).toBe(false);
|
|
expect(emptyCheckbox.props.disabled).toBe(true);
|
|
});
|
|
|
|
it("uses visible link counts and selection state for filtered package actions", () => {
|
|
const model = buildCollectorWorkspaceViewModel(packages, "online", "part02", false, ["link-2"], [], "", true);
|
|
const toolbar = CollectorToolbar({ actions: createActions(), model });
|
|
const content = renderToStaticMarkup(<CollectorContent actions={createActions()} model={model} />);
|
|
|
|
expect(findButton(toolbar, "Alle übergeben (1)").props.disabled).toBe(false);
|
|
expect(content).toMatch(/aria-checked="true" aria-label="Paket SBS14HD auswählen"/);
|
|
expect(content).not.toContain('aria-checked="mixed" aria-label="Paket SBS14HD auswählen"');
|
|
});
|
|
|
|
it("disables transfer actions for an offline selection while keeping removal enabled", () => {
|
|
const model = buildCollectorWorkspaceViewModel(packages, "offline", "", false, ["link-4"], [], "", true);
|
|
const toolbar = CollectorToolbar({ actions: createActions(), model });
|
|
|
|
expect(findButton(toolbar, "Auswahl übergeben (0)").props.disabled).toBe(true);
|
|
expect(findButton(toolbar, "Alle übergeben (0)").props.disabled).toBe(true);
|
|
expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false);
|
|
});
|
|
|
|
it("counts only transferable links in transfer actions", () => {
|
|
const model = buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1", "link-2", "link-3", "link-4"], [], "", true);
|
|
const toolbar = CollectorToolbar({ actions: createActions(), model });
|
|
|
|
expect(findButton(toolbar, "Auswahl übergeben (3)").props.disabled).toBe(false);
|
|
expect(findButton(toolbar, "Alle übergeben (3)").props.disabled).toBe(false);
|
|
expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false);
|
|
});
|
|
|
|
it("collapses newly imported packages while preserving existing disclosure state", () => {
|
|
const incoming = [{ ...packages[0], id: "temporary-import-package" }];
|
|
const collapsed = reconcileCollectorCollapsedPackageIds(new Set(["package-mixed"]), [packages[1]], packages, incoming, true);
|
|
|
|
expect(collapsed).toEqual(new Set(["package-mixed", "package-sbs"]));
|
|
});
|
|
|
|
it("does not collapse an expanded package for a duplicate-only import", () => {
|
|
const incoming = [{ ...packages[0], id: "temporary-import-package" }];
|
|
|
|
expect([...reconcileCollectorCollapsedPackageIds(new Set(), packages, packages, incoming, true)]).toEqual([]);
|
|
});
|
|
|
|
it("moves collapsed state to regrouped package ids during enrichment", () => {
|
|
const before = [{ ...packages[0], id: "package-before" }];
|
|
const after = [{ ...packages[0], id: "package-after" }];
|
|
|
|
expect([...reconcileCollectorCollapsedPackageIds(new Set(["package-before"]), before, after, after, false)]).toEqual(["package-after"]);
|
|
});
|
|
|
|
it("reserves the exact package height while content visibility skips offscreen rows", () => {
|
|
const expanded = buildCollectorWorkspaceViewModel([packages[0]], "all", "", false, [], [], "", true).packages[0];
|
|
const collapsed = { ...expanded, collapsed: true };
|
|
|
|
expect(collectorPackageIntrinsicBlockSize(expanded)).toBe(126);
|
|
expect(collectorPackageIntrinsicBlockSize(collapsed)).toBe(46);
|
|
expect(renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel([packages[0]], "all", "", false, [], [], "", true)} />)).toContain("contain-intrinsic-block-size:auto 126px");
|
|
});
|
|
|
|
it("keeps expanded child rows bounded to the virtual viewport for large collectors", () => {
|
|
const manyPackages: CollectorPackage[] = Array.from({ length: 50 }, (_, index) => ({
|
|
...packages[0],
|
|
id: `package-${index}`,
|
|
name: `Paket ${index}`,
|
|
links: packages[0].links.map((link, linkIndex) => ({
|
|
...link,
|
|
id: `link-${index}-${linkIndex}`,
|
|
url: `https://example.test/${index}/${linkIndex}`
|
|
}))
|
|
}));
|
|
|
|
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(manyPackages, "all", "", false, [], [], "", true)} />);
|
|
const renderedFileRows = html.match(/class="collector-file-row/g)?.length ?? 0;
|
|
|
|
expect(html).toContain("collector-virtual-spacer");
|
|
expect(renderedFileRows).toBeGreaterThan(0);
|
|
expect(renderedFileRows).toBeLessThanOrEqual(20);
|
|
});
|
|
|
|
it("does not replay the disclosure animation when an expanded virtual package mounts", () => {
|
|
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel([packages[0]], "all", "", false, [], [], "", true)} />);
|
|
|
|
expect(html).toContain("collector-package-items-frame");
|
|
expect(html).not.toContain("collector-package-items-frame is-animated");
|
|
});
|
|
|
|
it("exposes logical row counts and positions for virtualized assistive navigation", () => {
|
|
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel([packages[0]], "all", "", false, [], [], "", true)} />);
|
|
|
|
expect(html).toContain('aria-rowcount="4"');
|
|
expect(html).toContain('aria-rowindex="1"');
|
|
expect(html).toContain('aria-rowindex="2"');
|
|
expect(html).toContain('aria-rowindex="3"');
|
|
expect(html).toContain('aria-rowindex="4"');
|
|
expect(html).toContain('data-collector-focus-index="0"');
|
|
expect(html).toContain('data-collector-focus-index="1"');
|
|
expect(html).toContain('data-collector-focus-index="2"');
|
|
expect(html).toContain('data-collector-focus-index="3"');
|
|
});
|
|
|
|
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(">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 sidebarStatus = renderToStaticMarkup(<CollectorSidebarStatus model={model} />);
|
|
const toolbar = CollectorToolbar({ actions: createActions(), model });
|
|
|
|
expect(html).not.toContain("Analyse läuft im Hintergrund");
|
|
expect(sidebarStatus).toContain("Analyse läuft im Hintergrund");
|
|
expect(sidebarStatus).toContain('role="status"');
|
|
expect(html).toContain("SBS14HD.part01.rar");
|
|
expect(findButton(toolbar, "Auswahl übergeben (1)").props.disabled).toBe(false);
|
|
expect(findButton(toolbar, "Alle übergeben (3)").props.disabled).toBe(false);
|
|
expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false);
|
|
});
|
|
|
|
it("formats collector sidebar and filter counters as German integers", () => {
|
|
const base = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true);
|
|
const model = {
|
|
...base,
|
|
packageCount: 1_250,
|
|
totalCount: 3_180,
|
|
selectedCount: 1_000,
|
|
filters: base.filters.map((filter, index) => ({ ...filter, count: [3_180, 1_250, 1_000, 930][index] ?? 0 }))
|
|
};
|
|
const sidebar = renderToStaticMarkup(<CollectorSidebar actions={createActions()} model={model} />);
|
|
const status = renderToStaticMarkup(<CollectorSidebarStatus model={model} />);
|
|
|
|
for (const count of ["3.180", "1.250", "1.000", "930"]) expect(sidebar).toContain(`>${count}<`);
|
|
expect(status).toContain("Pakete: 1.250");
|
|
expect(status).toContain("Links: 3.180");
|
|
expect(status).toContain("Ausgewählt: 1.000");
|
|
expect(sidebar).not.toContain(">3180<");
|
|
expect(status).not.toContain("Links: 3180");
|
|
});
|
|
|
|
it("derives status exclusively from availability instead of filename readiness", () => {
|
|
const unknownReady = [{
|
|
...packages[0],
|
|
links: [{ ...packages[0].links[0], availability: "unknown" as const, status: "ready" as const }]
|
|
}];
|
|
const partial = [{
|
|
...packages[0],
|
|
links: [
|
|
{ ...packages[0].links[0], availability: "online" as const },
|
|
{ ...packages[0].links[1], availability: "unknown" as const }
|
|
]
|
|
}];
|
|
const offlineUnknown = [{
|
|
...packages[0],
|
|
links: [
|
|
{ ...packages[0].links[0], availability: "offline" as const },
|
|
{ ...packages[0].links[1], availability: "unknown" as const }
|
|
]
|
|
}];
|
|
|
|
const unknownHtml = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(unknownReady, "all", "", false, [], [], "", true)} />);
|
|
const partialHtml = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(partial, "all", "", false, [], [], "", true)} />);
|
|
const offlineUnknownHtml = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(offlineUnknown, "all", "", false, [], [], "", true)} />);
|
|
|
|
expect(unknownHtml).not.toContain(">Bereit<");
|
|
expect(unknownHtml).toContain(">Ungeprüft<");
|
|
expect(partialHtml).toContain(">Teilweise online<");
|
|
expect(partialHtml).toContain("1/2 online");
|
|
expect(offlineUnknownHtml).not.toContain(">Teilweise online<");
|
|
expect(offlineUnknownHtml).toContain(">Ungeprüft<");
|
|
});
|
|
|
|
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 (3)").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 positioned virtual rows", () => {
|
|
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-virtual-package\s*\{[^}]*position:\s*absolute;[^}]*transform:\s*translateY/s);
|
|
expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar\s*\{[^}]*flex-wrap:\s*wrap;/s);
|
|
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.collector-virtual-spacer\.is-motion-enabled \.collector-virtual-package\s*\{[^}]*transition-duration:\s*300ms, 300ms !important;/s);
|
|
expect(css).toMatch(/\.collector-package-items-frame\.is-expanding\s*\{[^}]*animation:\s*collector-items-expand/s);
|
|
expect(css).not.toMatch(/\.collector-package-items-frame\.is-animated\s*\{[^}]*animation:\s*collector-items-expand/s);
|
|
expect(css).toMatch(/\.collector-table-header-row > span:nth-child\(2\)\s*\{[^}]*padding-left:\s*48px;/s);
|
|
expect(css).toMatch(/\.collector-name-cell\.is-file\s*\{[^}]*padding-left:\s*32px;/s);
|
|
expect(css).toMatch(/\.collector-table-header,\s*\.collector-table-body\s*\{[^}]*scrollbar-gutter:\s*stable;/s);
|
|
expect(css).toMatch(/\.collector-size-cell,\s*\.collector-hoster-cell,\s*\.collector-status-cell,\s*\.collector-availability-cell,\s*\.collector-added-cell\s*\{[^}]*text-align:\s*center;/s);
|
|
expect(css).toMatch(/\.collector-table-header-row > span:nth-child\(n\+3\)\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
|
|
expect(collectorHeaderScrollStyle(37)).toEqual({ transform: "translateX(-37px)" });
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it("uses the green confirmation style for the add-links action", () => {
|
|
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
|
|
|
|
expect(css).toMatch(/\.collector-action-primary\s*\{[^}]*border-color:\s*var\(--ui-success\);[^}]*background:\s*var\(--ui-success\);[^}]*color:\s*#000000;/s);
|
|
expect(css).toMatch(/\.collector-action-primary:hover:not\(:disabled\)\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--ui-success\) 86%, #ffffff\);[^}]*color:\s*#000000;/s);
|
|
});
|
|
});
|