Restore package-based link collector without blocking DLC drops
Reintroduce expandable collector packages, background metadata enrichment, filters, selection, and controlled queue transfer. Keep DLC files dropped outside the collector on the direct addContainers path without settings persistence or metadata waits. Protect collector state with stable URL identities, non-degrading metadata merges, and per-URL generations so stale enrichment responses cannot overwrite newer data or resurrect removed links.
This commit is contained in:
@@ -4,7 +4,8 @@ import type { ElectronApi } from "../src/shared/preload-api";
|
||||
|
||||
const electron = vi.hoisted(() => ({
|
||||
api: undefined as ElectronApi | undefined,
|
||||
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined)
|
||||
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined),
|
||||
getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc")
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
@@ -18,7 +19,8 @@ vi.mock("electron", () => ({
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
send: vi.fn()
|
||||
}
|
||||
},
|
||||
webUtils: { getPathForFile: electron.getPathForFile }
|
||||
}));
|
||||
|
||||
describe("account preload contract", () => {
|
||||
@@ -109,4 +111,27 @@ describe("account preload contract", () => {
|
||||
expect(electron.invoke).toHaveBeenCalledWith(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST);
|
||||
expect(result).toEqual({ passwords });
|
||||
});
|
||||
|
||||
it("exposes separate collector preparation and enrichment channels", async () => {
|
||||
const textRequest = { rawText: "https://example.com/file", addedAt: 1234 };
|
||||
const packages = [{ id: "package", name: "Paket", nameSource: "inferred" as const, addedAt: 1234, links: [] }];
|
||||
|
||||
await electron.api?.prepareCollectorText(textRequest);
|
||||
await electron.api?.prepareCollectorContainers(["C:\\Imports\\sample.dlc"], 2345);
|
||||
await electron.api?.enrichCollectorPackages({ packages });
|
||||
|
||||
expect(electron.invoke.mock.calls).toEqual([
|
||||
[IPC_CHANNELS.PREPARE_COLLECTOR_TEXT, textRequest],
|
||||
[IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, ["C:\\Imports\\sample.dlc"], 2345],
|
||||
[IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, { packages }]
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves dropped files through Electron webUtils without IPC", () => {
|
||||
const file = { name: "dropped.dlc" } as File;
|
||||
|
||||
expect(electron.api?.getPathForDroppedFile(file)).toBe("C:\\Imports\\dropped.dlc");
|
||||
expect(electron.getPathForFile).toHaveBeenCalledWith(file);
|
||||
expect(electron.invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,20 +20,12 @@ describe("desktop shell", () => {
|
||||
expect(source).toContain("Maskierte Kennung kopiert");
|
||||
});
|
||||
|
||||
it("confirms before removing a collector tab", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
const removal = source.slice(source.indexOf("const removeCollectorTab"), source.indexOf("const openCollectorInput"));
|
||||
|
||||
expect(removal).toContain("askConfirmPrompt");
|
||||
expect(removal.indexOf("askConfirmPrompt")).toBeLessThan(removal.indexOf("planCollectorTabRemoval"));
|
||||
});
|
||||
|
||||
it("confirms before removing selected collector links", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
const removal = source.slice(source.indexOf("const removeSelectedCollectorRows"), source.indexOf("const onPackageStartEdit"));
|
||||
const removal = source.slice(source.indexOf("const removeSelectedCollectorLinks"), source.indexOf("const onPackageStartEdit"));
|
||||
|
||||
expect(removal).toContain("askConfirmPrompt");
|
||||
expect(removal.indexOf("askConfirmPrompt")).toBeLessThan(removal.indexOf("setCollectorTabs"));
|
||||
expect(removal.indexOf("askConfirmPrompt")).toBeLessThan(removal.indexOf("setCollectorPackages"));
|
||||
expect(removal).toContain('title: "Ausgewählte Links löschen"');
|
||||
});
|
||||
|
||||
@@ -62,7 +54,7 @@ describe("desktop shell", () => {
|
||||
|
||||
it("redraws the header speed sparkline on the same 750 ms cadence", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
const sparklineBlock = source.slice(source.indexOf("const DownloadSpeedSparkline"), source.indexOf("const initialCollectorTabs"));
|
||||
const sparklineBlock = source.slice(source.indexOf("const DownloadSpeedSparkline"), source.indexOf("function createScheduleId"));
|
||||
|
||||
expect(sparklineBlock).toContain("window.setInterval(tick, 750)");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveDroppedDlcPaths, routeDroppedDlcFiles } from "../src/renderer/collector-drop";
|
||||
|
||||
describe("collector DLC drop routing", () => {
|
||||
it("resolves native DLC paths without using File.path", () => {
|
||||
const first = { name: "first.dlc" } as File;
|
||||
const ignored = { name: "notes.txt" } as File;
|
||||
const second = { name: "SECOND.DLC" } as File;
|
||||
const getPath = vi.fn((file: File) => file === first ? "C:\\Drops\\first.dlc" : "C:\\Drops\\second.dlc");
|
||||
|
||||
expect(resolveDroppedDlcPaths([first, ignored, second], getPath)).toEqual([
|
||||
"C:\\Drops\\first.dlc",
|
||||
"C:\\Drops\\second.dlc"
|
||||
]);
|
||||
expect(getPath.mock.calls.map(([file]) => file)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it("sends Downloads drops directly to addContainers and never to an inspector", async () => {
|
||||
const file = { name: "queue.dlc" } as File;
|
||||
const addContainers = vi.fn(async () => ({ addedPackages: 2, addedLinks: 16 }));
|
||||
const inspectContainers = vi.fn(async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }));
|
||||
|
||||
await expect(routeDroppedDlcFiles([file], "downloads", () => "C:\\Drops\\queue.dlc", {
|
||||
addContainers,
|
||||
inspectContainers
|
||||
})).resolves.toEqual({
|
||||
kind: "downloads",
|
||||
result: { addedPackages: 2, addedLinks: 16 }
|
||||
});
|
||||
expect(addContainers).toHaveBeenCalledTimes(1);
|
||||
expect(inspectContainers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends Collector drops only to structure inspection", async () => {
|
||||
const file = { name: "preview.dlc" } as File;
|
||||
const addContainers = vi.fn(async () => ({ addedPackages: 1, addedLinks: 1 }));
|
||||
const structure = { packages: [{ id: "package-1", name: "Serie", links: [], addedAt: 1000 }], invalidCount: 0, duplicateCount: 0 };
|
||||
const inspectContainers = vi.fn(async () => structure);
|
||||
|
||||
await expect(routeDroppedDlcFiles([file], "collector", () => "C:\\Drops\\preview.dlc", {
|
||||
addContainers,
|
||||
inspectContainers
|
||||
}, 1000)).resolves.toEqual({ kind: "collector", result: structure });
|
||||
expect(inspectContainers).toHaveBeenCalledWith(["C:\\Drops\\preview.dlc"], 1000);
|
||||
expect(addContainers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let a hanging Collector inspection block a Downloads drop", async () => {
|
||||
const collectorFile = { name: "slow.dlc" } as File;
|
||||
const downloadFile = { name: "fast.dlc" } as File;
|
||||
const inspectContainers = vi.fn(() => new Promise<never>(() => {}));
|
||||
const addContainers = vi.fn(async () => ({ addedPackages: 1, addedLinks: 8 }));
|
||||
|
||||
void routeDroppedDlcFiles([collectorFile], "collector", () => "C:\\Drops\\slow.dlc", { addContainers, inspectContainers }, 1000);
|
||||
await expect(routeDroppedDlcFiles([downloadFile], "downloads", () => "C:\\Drops\\fast.dlc", {
|
||||
addContainers,
|
||||
inspectContainers
|
||||
})).resolves.toEqual({
|
||||
kind: "downloads",
|
||||
result: { addedPackages: 1, addedLinks: 8 }
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a controlled empty result when no native DLC path is available", async () => {
|
||||
const file = { name: "missing.dlc" } as File;
|
||||
const addContainers = vi.fn();
|
||||
const inspectContainers = vi.fn();
|
||||
|
||||
await expect(routeDroppedDlcFiles([file], "downloads", () => "", { addContainers, inspectContainers })).resolves.toEqual({ kind: "empty" });
|
||||
expect(addContainers).not.toHaveBeenCalled();
|
||||
expect(inspectContainers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
beginCollectorEnrichment,
|
||||
filterCurrentCollectorEnrichment
|
||||
} from "../src/renderer/collector-enrichment";
|
||||
import type { CollectorPackage } from "../src/shared/collector";
|
||||
|
||||
function collectorPackage(url: string, status: "ready" | "offline" | "unknown" = "unknown"): CollectorPackage {
|
||||
return {
|
||||
id: `package-${url}`,
|
||||
name: "Paket",
|
||||
nameSource: "inferred",
|
||||
addedAt: 1,
|
||||
links: [{
|
||||
id: `link-${url}`,
|
||||
url,
|
||||
fileName: "download.bin",
|
||||
fileSizeBytes: null,
|
||||
hoster: "example",
|
||||
availability: status === "ready" ? "online" : status,
|
||||
status,
|
||||
addedAt: 1
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
describe("collector enrichment generations", () => {
|
||||
it("rejects an older response after the same URL starts a newer enrichment", () => {
|
||||
const current = new Map<string, number>();
|
||||
const packages = [collectorPackage("https://example.test/file")];
|
||||
const first = beginCollectorEnrichment(packages, current);
|
||||
const second = beginCollectorEnrichment(packages, current);
|
||||
|
||||
expect(filterCurrentCollectorEnrichment([collectorPackage("https://example.test/file", "offline")], first, current)).toEqual([]);
|
||||
expect(filterCurrentCollectorEnrichment([collectorPackage("https://example.test/file", "ready")], second, current)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import {
|
||||
enrichCollectorPackages,
|
||||
prepareCollectorContainers,
|
||||
prepareCollectorText
|
||||
} from "../src/main/collector-inspection";
|
||||
import {
|
||||
validateCollectorContainerPreparationRequest,
|
||||
validateCollectorEnrichmentRequest,
|
||||
validateCollectorTextPreparationRequest
|
||||
} from "../src/shared/collector";
|
||||
|
||||
describe("collector preparation", () => {
|
||||
it("returns a stable package skeleton without requesting metadata", () => {
|
||||
const fetchRequest = vi.spyOn(globalThis, "fetch");
|
||||
const rawText = [
|
||||
"# Package: Staffel A",
|
||||
"# File: episode.part01.rar",
|
||||
"https://example.com/a",
|
||||
"https://example.com/a",
|
||||
"invalid"
|
||||
].join("\n");
|
||||
|
||||
const first = prepareCollectorText({ rawText, addedAt: 1_000 });
|
||||
const second = prepareCollectorText({ rawText, addedAt: 2_000 });
|
||||
|
||||
expect(fetchRequest).not.toHaveBeenCalled();
|
||||
expect(first.invalidCount).toBe(1);
|
||||
expect(first.duplicateCount).toBe(1);
|
||||
expect(first.packages).toHaveLength(1);
|
||||
expect(first.packages[0]).toEqual(expect.objectContaining({
|
||||
name: "Staffel A",
|
||||
nameSource: "explicit"
|
||||
}));
|
||||
expect(first.packages[0].links[0]).toEqual(expect.objectContaining({
|
||||
url: "https://example.com/a",
|
||||
fileName: "episode.part01.rar",
|
||||
availability: "unknown",
|
||||
status: "ready"
|
||||
}));
|
||||
expect(second.packages[0].id).toBe(first.packages[0].id);
|
||||
expect(second.packages[0].links[0].id).toBe(first.packages[0].links[0].id);
|
||||
fetchRequest.mockRestore();
|
||||
});
|
||||
|
||||
it("decrypts selected DLC files into a skeleton without metadata enrichment", async () => {
|
||||
const importContainers = vi.fn(async () => [{
|
||||
name: "DLC Paket",
|
||||
links: ["https://1fichier.com/?abc123def456ghi789jk"],
|
||||
fileNames: ["episode.part01.rar"]
|
||||
}]);
|
||||
|
||||
const result = await prepareCollectorContainers(
|
||||
["C:\\Imports\\sample.dlc"],
|
||||
3_000,
|
||||
{ importContainers }
|
||||
);
|
||||
|
||||
expect(importContainers).toHaveBeenCalledWith(["C:\\Imports\\sample.dlc"]);
|
||||
expect(result.packages[0]).toEqual(expect.objectContaining({
|
||||
name: "DLC Paket",
|
||||
nameSource: "explicit"
|
||||
}));
|
||||
expect(result.packages[0].links[0]).toEqual(expect.objectContaining({
|
||||
fileName: "episode.part01.rar",
|
||||
availability: "unknown",
|
||||
status: "ready"
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects oversized text and invalid container paths", () => {
|
||||
expect(() => validateCollectorTextPreparationRequest({ rawText: "x".repeat(2_000_001), addedAt: 1 })).toThrow(/ungültig/i);
|
||||
expect(() => validateCollectorTextPreparationRequest({ rawText: "ä".repeat(1_100_000), addedAt: 1 })).toThrow(/ungültig/i);
|
||||
expect(() => validateCollectorContainerPreparationRequest({
|
||||
filePaths: Array.from({ length: 101 }, (_, index) => `C:\\Imports\\${index}.dlc`),
|
||||
addedAt: 1
|
||||
})).toThrow(/ungültig/i);
|
||||
expect(() => validateCollectorContainerPreparationRequest({ filePaths: ["relative.dlc"], addedAt: 1 })).toThrow(/ungültig/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collector enrichment", () => {
|
||||
it("updates known links by URL while preserving their stable ids", async () => {
|
||||
const prepared = prepareCollectorText({
|
||||
rawText: "https://1fichier.com/?abc123def456ghi789jk",
|
||||
addedAt: 4_000
|
||||
});
|
||||
const linkBefore = prepared.packages[0].links[0];
|
||||
|
||||
const result = await enrichCollectorPackages(
|
||||
{ packages: prepared.packages },
|
||||
defaultSettings(),
|
||||
{
|
||||
checkOneFichier: async () => new Map([[linkBefore.url, {
|
||||
online: true,
|
||||
fileName: "Show.S01E01.part01.rar",
|
||||
fileSizeBytes: 471_859_200,
|
||||
accessRestricted: false
|
||||
}]])
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.packages).toHaveLength(1);
|
||||
expect(result.packages[0].name).toBe("Show.S01E01");
|
||||
expect(result.packages[0].links[0]).toEqual(expect.objectContaining({
|
||||
id: linkBefore.id,
|
||||
url: linkBefore.url,
|
||||
fileName: "Show.S01E01.part01.rar",
|
||||
fileSizeBytes: 471_859_200,
|
||||
availability: "online",
|
||||
status: "ready"
|
||||
}));
|
||||
});
|
||||
|
||||
it("runs independent enrichments concurrently instead of serializing them globally", async () => {
|
||||
const first = prepareCollectorText({ rawText: "https://1fichier.com/?first", addedAt: 5_000 });
|
||||
const second = prepareCollectorText({ rawText: "https://1fichier.com/?second", addedAt: 6_000 });
|
||||
const started: string[] = [];
|
||||
const resolvers: Array<() => void> = [];
|
||||
const checkOneFichier = async (links: string[]) => {
|
||||
started.push(links[0]);
|
||||
await new Promise<void>((resolve) => resolvers.push(resolve));
|
||||
return new Map();
|
||||
};
|
||||
|
||||
const firstRun = enrichCollectorPackages({ packages: first.packages }, defaultSettings(), { checkOneFichier });
|
||||
const secondRun = enrichCollectorPackages({ packages: second.packages }, defaultSettings(), { checkOneFichier });
|
||||
await vi.waitFor(() => expect(started).toHaveLength(2));
|
||||
resolvers.forEach((resolve) => resolve());
|
||||
await Promise.all([firstRun, secondRun]);
|
||||
|
||||
expect(started).toEqual([
|
||||
"https://1fichier.com/?first",
|
||||
"https://1fichier.com/?second"
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects enrichment payloads that do not contain prepared absolute links", () => {
|
||||
expect(() => validateCollectorEnrichmentRequest({ packages: [] })).toThrow(/ungültig/i);
|
||||
expect(() => validateCollectorEnrichmentRequest({
|
||||
packages: [{
|
||||
id: "package",
|
||||
name: "Paket",
|
||||
nameSource: "inferred",
|
||||
addedAt: 1,
|
||||
links: [{
|
||||
id: "link",
|
||||
url: "relative",
|
||||
fileName: "",
|
||||
fileSizeBytes: null,
|
||||
hoster: "",
|
||||
availability: "unknown",
|
||||
status: "unknown",
|
||||
addedAt: 1
|
||||
}]
|
||||
}]
|
||||
})).toThrow(/ungültig/i);
|
||||
});
|
||||
});
|
||||
+367
-295
@@ -1,326 +1,398 @@
|
||||
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 {
|
||||
mergeCollectorDraftText,
|
||||
planCollectorTabRemoval,
|
||||
planCollectorTextReplacement
|
||||
} from "../src/renderer/App";
|
||||
import {
|
||||
buildCollectorRows,
|
||||
buildCollectorViewModel,
|
||||
type CollectorSourceTab
|
||||
} from "../src/renderer/views/collector/collector-model";
|
||||
import {
|
||||
CollectorInputDialog,
|
||||
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,
|
||||
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 {
|
||||
onTabSelect: () => {},
|
||||
onTabAdd: () => {},
|
||||
onTabRemove: () => {},
|
||||
onOpenInput: () => {},
|
||||
onImportDlc: () => {},
|
||||
onImportFile: () => {},
|
||||
onExportQueue: () => {},
|
||||
onSubmit: () => {},
|
||||
onQueryChange: () => {},
|
||||
onSelectionChange: () => {},
|
||||
onRemoveSelected: () => {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const populatedTabs: CollectorSourceTab[] = [
|
||||
{
|
||||
id: "tab-a",
|
||||
name: "Sammlung A",
|
||||
text: "https://example.test/a\n\n https://example.test/b "
|
||||
}
|
||||
];
|
||||
|
||||
describe("collector model", () => {
|
||||
it("derives stable rows from non-empty raw lines without validating or regrouping them", () => {
|
||||
const rows = buildCollectorRows(populatedTabs, "tab-a", "");
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map((row) => row.id)).toEqual(["tab-a:0", "tab-a:2"]);
|
||||
expect(rows.map((row) => row.originalLineIndex)).toEqual([0, 2]);
|
||||
expect(rows.map((row) => row.value)).toEqual([
|
||||
"https://example.test/a",
|
||||
"https://example.test/b"
|
||||
]);
|
||||
expect(rows[0].linkCount).toBe(2);
|
||||
expect(rows[1].linkCount).toBe(2);
|
||||
});
|
||||
|
||||
it("filters presentation rows while keeping source counts and original line identities", () => {
|
||||
const model = buildCollectorViewModel(populatedTabs, "tab-a", "EXAMPLE.TEST/B", false, ["tab-a:0"]);
|
||||
|
||||
expect(model.rows.map((row) => row.id)).toEqual(["tab-a:2"]);
|
||||
expect(model.tabs).toEqual([{ id: "tab-a", name: "Sammlung A", linkCount: 2 }]);
|
||||
expect(model.selectedIds).toEqual(["tab-a:0"]);
|
||||
expect(model.empty).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves clipboard and drop appends that arrive while an input draft is open", () => {
|
||||
expect(mergeCollectorDraftText(
|
||||
"https://example.test/old",
|
||||
"https://example.test/old\nhttps://example.test/clipboard",
|
||||
"https://example.test/edited"
|
||||
)).toBe("https://example.test/edited\nhttps://example.test/clipboard");
|
||||
expect(mergeCollectorDraftText("old", "old", "edited")).toBe("edited");
|
||||
});
|
||||
|
||||
it("moves the active identity to an existing neighbor before later appends arrive", () => {
|
||||
const tabs: CollectorSourceTab[] = [
|
||||
{ id: "tab-a", name: "Sammlung A", text: "a" },
|
||||
{ id: "tab-b", name: "Sammlung B", text: "b" },
|
||||
{ id: "tab-c", name: "Sammlung C", text: "c" }
|
||||
];
|
||||
|
||||
expect(planCollectorTabRemoval(tabs, "tab-b", "tab-b")).toEqual({
|
||||
tabs: [tabs[0], tabs[2]],
|
||||
activeTabId: "tab-a"
|
||||
});
|
||||
expect(planCollectorTabRemoval(tabs, "tab-c", "tab-a")).toEqual({
|
||||
tabs: [tabs[1], tabs[2]],
|
||||
activeTabId: "tab-c"
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates positional row selection whenever raw text is replaced", () => {
|
||||
const tabs: CollectorSourceTab[] = [
|
||||
{ id: "tab-a", name: "Sammlung A", text: "old-a\nold-b" },
|
||||
{ id: "tab-b", name: "Sammlung B", text: "untouched" }
|
||||
];
|
||||
|
||||
expect(planCollectorTextReplacement(tabs, "tab-a", "new-a")).toEqual({
|
||||
tabs: [
|
||||
{ id: "tab-a", name: "Sammlung A", text: "new-a" },
|
||||
tabs[1]
|
||||
],
|
||||
selectedIds: []
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CollectorView", () => {
|
||||
it("marks collections for one measured vertical selection indicator", () => {
|
||||
const model = buildCollectorViewModel([
|
||||
{ id: "tab-a", name: "Sammlung A", text: "https://example.test/a" },
|
||||
{ id: "tab-b", name: "Sammlung B", text: "https://example.test/b" }
|
||||
], "tab-b", "", false, []);
|
||||
const html = renderToStaticMarkup(<CollectorSidebar actions={createActions()} model={model} />);
|
||||
CollectorView,
|
||||
toggleAllCollectorPackageIds,
|
||||
type CollectorViewActions
|
||||
} from "../src/renderer/views/collector/CollectorView";
|
||||
|
||||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(2);
|
||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||
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("keeps empty, busy and error states inside the same table body", () => {
|
||||
const empty = renderToStaticMarkup(
|
||||
<CollectorView
|
||||
actions={createActions()}
|
||||
model={buildCollectorViewModel([{ id: "tab-a", name: "Sammlung A", text: "" }], "tab-a", "", false, [])}
|
||||
/>
|
||||
);
|
||||
const busy = renderToStaticMarkup(
|
||||
<CollectorView
|
||||
actions={createActions()}
|
||||
model={{ ...buildCollectorViewModel([], "", "", true, []), error: "" }}
|
||||
/>
|
||||
);
|
||||
const failed = renderToStaticMarkup(
|
||||
<CollectorView
|
||||
actions={createActions()}
|
||||
model={{ ...buildCollectorViewModel([], "", "", false, []), error: "Import fehlgeschlagen" }}
|
||||
/>
|
||||
);
|
||||
|
||||
for (const [html, state] of [
|
||||
[empty, "Noch keine Links"],
|
||||
[busy, "Links werden verarbeitet"],
|
||||
[failed, "Import fehlgeschlagen"]
|
||||
]) {
|
||||
expect(html.indexOf(state)).toBeGreaterThan(html.indexOf("data-visual-region=\"collector-table-body\""));
|
||||
}
|
||||
expect(empty).toContain("data-visual-region=\"collector-empty-state\"");
|
||||
expect(empty).not.toContain("aria-label=\"Seitennavigation\"");
|
||||
});
|
||||
|
||||
it("renders compact occupied rows and removes the empty marker", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CollectorView
|
||||
actions={createActions()}
|
||||
model={buildCollectorViewModel(populatedTabs, "tab-a", "", false, [])}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html.match(/class=\"collector-row(?: is-selected)?\"/g)).toHaveLength(2);
|
||||
expect(html).not.toContain("data-visual-region=\"collector-empty-state\"");
|
||||
expect(html).toContain("data-visual-region=\"collector-sidebar\"");
|
||||
expect(html).toContain("data-visual-region=\"collector-toolbar\"");
|
||||
expect(html).toContain("data-visual-region=\"collector-table-body\"");
|
||||
expect(html).not.toContain("data-visual-region=\"downloads-toolbar\"");
|
||||
expect(html).not.toContain("aria-label=\"Seitennavigation\"");
|
||||
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("gives every row checkbox a unique accessible name with its link and collection", () => {
|
||||
const content = CollectorContent({
|
||||
actions: createActions(),
|
||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, [])
|
||||
});
|
||||
const labels: string[] = [];
|
||||
visitElements(content, (element) => {
|
||||
if (element.type === "input" && element.props.type === "checkbox") {
|
||||
labels.push(element.props["aria-label"]);
|
||||
}
|
||||
});
|
||||
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(labels).toEqual([
|
||||
"https://example.test/a aus Sammlung A, Zeile 1 auswählen",
|
||||
"https://example.test/b aus Sammlung A, Zeile 3 auswählen"
|
||||
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"]]
|
||||
]);
|
||||
expect(new Set(labels).size).toBe(labels.length);
|
||||
});
|
||||
|
||||
it("uses a high-contrast table heading token in both themes", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
|
||||
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" }]
|
||||
}];
|
||||
|
||||
expect(css).toMatch(/\.collector-table-header-row\s*{[^}]*color:\s*var\(--ui-text-secondary\);/s);
|
||||
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("uses the semantic danger text token for the removal action", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
|
||||
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" }]
|
||||
}];
|
||||
|
||||
expect(css).toMatch(/\.collector-action-danger:not\(:disabled\)\s*{[^}]*color:\s*var\(--ui-danger-text\);/s);
|
||||
const result = mergeCollectorPackages(current, incoming);
|
||||
|
||||
expect(result.packages[0].nameSource).toBe("explicit");
|
||||
});
|
||||
|
||||
it("disables queue submission only when the active collection has no links", () => {
|
||||
const emptyActive = CollectorToolbar({
|
||||
actions: createActions(),
|
||||
model: buildCollectorViewModel([
|
||||
{ id: "tab-a", name: "Sammlung A", text: "" },
|
||||
{ id: "tab-b", name: "Sammlung B", text: "https://example.test/b" }
|
||||
], "tab-a", "", false, [])
|
||||
});
|
||||
const filteredActive = CollectorToolbar({
|
||||
actions: createActions(),
|
||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "kein-treffer", false, [])
|
||||
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("keeps the animated disclosure frame mounted for compact packages", () => {
|
||||
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], ["package-sbs"], "", true)} />);
|
||||
expect(html).toContain("collector-package-items-frame is-collapsed is-animated");
|
||||
expect(html).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)
|
||||
});
|
||||
|
||||
expect(findButton(emptyActive, "An Downloads übergeben").props.disabled).toBe(true);
|
||||
expect(findButton(filteredActive, "An Downloads übergeben").props.disabled).toBe(false);
|
||||
findButton(toolbar, "Auswahl übergeben (1)").props.onClick();
|
||||
findButton(toolbar, "Alle übergeben (4)").props.onClick();
|
||||
expect(selected).toBe(1);
|
||||
expect(all).toBe(1);
|
||||
});
|
||||
|
||||
it("separates local input, queue submission, search, selection and local removal callbacks", () => {
|
||||
let inputOpens = 0;
|
||||
let queueSubmits = 0;
|
||||
let query = "";
|
||||
let selected = "";
|
||||
let removals = 0;
|
||||
const actions = createActions({
|
||||
onOpenInput: () => { inputOpens += 1; },
|
||||
onSubmit: () => { queueSubmits += 1; },
|
||||
onQueryChange: (value) => { query = value; },
|
||||
onSelectionChange: (rowId) => { selected = rowId; },
|
||||
onRemoveSelected: () => { removals += 1; }
|
||||
});
|
||||
const toolbar = CollectorToolbar({
|
||||
actions,
|
||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
|
||||
});
|
||||
const content = CollectorContent({
|
||||
actions,
|
||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
|
||||
});
|
||||
|
||||
findButton(toolbar, "Links hinzufügen").props.onClick();
|
||||
expect(inputOpens).toBe(1);
|
||||
expect(queueSubmits).toBe(0);
|
||||
|
||||
findButton(toolbar, "An Downloads übergeben").props.onClick();
|
||||
expect(queueSubmits).toBe(1);
|
||||
|
||||
const search = findElement(toolbar, (element) => element.props.label === "Links durchsuchen");
|
||||
search.props.onChange({ target: { value: "release" } });
|
||||
expect(query).toBe("release");
|
||||
|
||||
const checkbox = findElement(content, (element) => element.type === "input" && element.props.type === "checkbox");
|
||||
checkbox.props.onChange();
|
||||
findButton(toolbar, "Auswahl entfernen").props.onClick();
|
||||
expect(selected).toBe("tab-a:0");
|
||||
expect(removals).toBe(1);
|
||||
expect(queueSubmits).toBe(1);
|
||||
});
|
||||
|
||||
it("names the input dialog and commits only through the local draft callback", () => {
|
||||
let value = "";
|
||||
let commits = 0;
|
||||
const dialog = CollectorInputDialog({
|
||||
open: true,
|
||||
tabName: "Sammlung A",
|
||||
value,
|
||||
onChange: (next) => { value = next; },
|
||||
onClose: () => {},
|
||||
onCommit: () => { commits += 1; }
|
||||
});
|
||||
const html = renderToStaticMarkup(dialog);
|
||||
|
||||
expect(html).toContain("role=\"dialog\"");
|
||||
expect(html).toContain("aria-label=\"Links\"");
|
||||
expect(html).toContain("Links hinzufügen");
|
||||
expect(html).toContain("Übernehmen");
|
||||
|
||||
const textbox = findElement(dialog, (element) => element.type === "textarea" && element.props["aria-label"] === "Links");
|
||||
textbox.props.onChange({ target: { value: "https://example.test/new" } });
|
||||
findButton(dialog, "Übernehmen").props.onClick();
|
||||
expect(value).toBe("https://example.test/new");
|
||||
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("moves the search field onto a separate compact row instead of overlapping actions", () => {
|
||||
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);
|
||||
expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar \.ui-toolbar-search\s*\{[^}]*flex:\s*1 0 100%;[^}]*width:\s*100%;/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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,7 @@ describe("global Escape selection routing", () => {
|
||||
expect(api.resolveEscapeSelectionScope?.("settings", "accounts", "BODY")).toBe("accounts");
|
||||
expect(api.resolveEscapeSelectionScope?.("settings", "accounts", "INPUT", "text")).toBeNull();
|
||||
expect(api.resolveEscapeSelectionScope?.("downloads", "allgemein", "DIV")).toBe("downloads");
|
||||
expect(api.resolveEscapeSelectionScope?.("collector", "allgemein", "DIV")).toBe("collector");
|
||||
expect(api.resolveEscapeSelectionScope?.("history", "accounts", "DIV")).toBe("history");
|
||||
});
|
||||
|
||||
|
||||
@@ -59,6 +59,10 @@ export function createVisualElectronApi(
|
||||
deleteAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }),
|
||||
addLinks: async () => ({ addedPackages: 0, addedLinks: 0, invalidCount: 0 }),
|
||||
addContainers: async () => ({ addedPackages: 0, addedLinks: 0 }),
|
||||
prepareCollectorText: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }),
|
||||
prepareCollectorContainers: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }),
|
||||
enrichCollectorPackages: async (request) => ({ packages: clone(request.packages), invalidCount: 0, duplicateCount: 0 }),
|
||||
getPathForDroppedFile: () => "",
|
||||
getStartConflicts: async () => [],
|
||||
resolveStartConflict: async (_packageId, policy) => ({
|
||||
skipped: policy === "skip",
|
||||
|
||||
Reference in New Issue
Block a user