release: publish Multi-Debrid Downloader v2.0.14

Add live English and German localization, queue availability and metadata resolution, responsive package controls, polished navigation and drag interactions, clearer history and account states, and a rebuilt public README. Harden Windows packaging with verified icons and version metadata, archive inspection, and expanded release tests.
This commit is contained in:
Sucukdeluxe
2026-08-10 19:42:49 +02:00
parent 069babfd54
commit a5758aa905
61 changed files with 9117 additions and 6754 deletions
+17
View File
@@ -0,0 +1,17 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveAppIconPath } from "../src/main/app-icon";
describe("application icon path", () => {
it("uses the unpacked resource copy in packaged builds", () => {
expect(resolveAppIconPath(true, "C:\\app\\resources\\app.asar", "C:\\app\\resources")).toBe(
path.join("C:\\app\\resources", "assets", "app_icon.ico")
);
});
it("uses the source asset in development", () => {
expect(resolveAppIconPath(false, "C:\\repo", "C:\\app\\resources")).toBe(
path.join("C:\\repo", "assets", "app_icon.ico")
);
});
});
+113 -74
View File
@@ -1,87 +1,126 @@
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/AvatarMenu";
import { AppShell } from "../src/renderer/shell/AppShell";
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
import { AppHeader } from "../src/renderer/shell/AppHeader";
import { AppShell } from "../src/renderer/shell/AppShell";
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
describe("desktop shell", () => {
it("keeps application menus mounted for animated opening and closing", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
expect(source.match(/menu-dropdown\$\{openMenu ===/g)).toHaveLength(3);
expect(css).toMatch(/\.menu-dropdown\s*\{[^}]*opacity:\s*0;[^}]*transform:\s*translateY\(-8px\);[^}]*visibility:\s*hidden;/s);
expect(css).toMatch(/\.menu-dropdown\.is-open\s*\{[^}]*opacity:\s*1;[^}]*transform:\s*translateY\(0\);[^}]*visibility:\s*visible;/s);
expect(shellCss).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-application-menu-tree \.menu-dropdown\s*\{[^}]*transition-duration:\s*150ms, 180ms, 0s !important;/);
});
it("uses the product asset in the header brand", () => {
const html = renderToStaticMarkup(<AppHeader activeView="downloads" actions={null} onViewChange={() => {}} />);
expect(html).toContain("Multi-Debrid Downloader");
expect(html).toContain("app_icon.png");
});
it("exposes all five views with exactly one active item", () => {
const items = buildMainNavigation("downloads");
expect(items.map((item) => item.id)).toEqual(["downloads", "collector", "settings", "history", "statistics"]);
expect(items.filter((item) => item.active)).toHaveLength(1);
const items = buildMainNavigation("downloads");
expect(items.map((item) => item.id)).toEqual(["downloads", "collector", "settings", "history", "statistics"]);
expect(items.filter((item) => item.active)).toHaveLength(1);
});
it("renders context regions without global placeholders", () => {
const html = renderToStaticMarkup(
<AppShell
activeView="downloads"
onViewChange={() => {}}
sidebar={<div>Filter</div>}
sidebarStatus={<div>2 Downloads</div>}
headerActions={null}
toolbar={<div>Aktionen</div>}
footer={<div>11 von 1</div>}
contextInfo={null}
sidebarCollapsed={false}
onSidebarCollapsedChange={() => {}}
>
<div>Inhalt</div>
</AppShell>
);
expect(html).toContain("data-ui-region=\"header\"");
expect(html).toContain("data-ui-region=\"sidebar\"");
expect(html).toContain("data-ui-region=\"sidebar-status\"");
expect(html).toContain("data-ui-region=\"main\"");
expect(html).toContain("11 von 1");
});
it("renders one measured sliding indicator behind the active navigation item", () => {
const html = renderToStaticMarkup(<AppHeader activeView="downloads" actions={null} onViewChange={() => {}} />);
const source = readFileSync(new URL("../src/renderer/shell/AppHeader.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
expect(html).toContain("data-main-view=\"downloads\"");
expect(source).toContain("ResizeObserver");
expect(css).toMatch(/\.md-shell-navigation::before\s*\{[^}]*transform:\s*translate3d\(var\(--md-navigation-active-x[^}]*transition:\s*transform 420ms cubic-bezier\(0\.22, 0\.76, 0\.22, 1\), width 420ms cubic-bezier\(0\.22, 0\.76, 0\.22, 1\)/s);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-shell-navigation::before\s*\{[^}]*transition-duration:\s*420ms, 420ms, 420ms, 120ms !important;/);
expect(css).toMatch(/\.md-shell-navigation-item\.is-active\s*\{[^}]*background:\s*transparent;/s);
});
it("renders context regions without global placeholders", () => {
const html = renderToStaticMarkup(
<AppShell
activeView="downloads"
onViewChange={() => {}}
sidebar={<div>Filter</div>}
sidebarStatus={<div>2 Downloads</div>}
headerActions={null}
toolbar={<div>Aktionen</div>}
footer={<div>11 von 1</div>}
contextInfo={null}
sidebarCollapsed={false}
onSidebarCollapsedChange={() => {}}
>
<div>Inhalt</div>
</AppShell>
);
expect(html).toContain("data-ui-region=\"header\"");
expect(html).toContain("data-ui-region=\"sidebar\"");
expect(html).toContain("data-ui-region=\"sidebar-status\"");
expect(html).toContain("data-ui-region=\"main\"");
expect(html).toContain("11 von 1");
});
it("does not reserve a collapsed sidebar column when sidebar slots are empty", () => {
const html = renderToStaticMarkup(
<AppShell
activeView="settings"
onViewChange={() => {}}
sidebar={null}
sidebarStatus={null}
headerActions={null}
toolbar={null}
footer={null}
contextInfo={null}
sidebarCollapsed
onSidebarCollapsedChange={() => {}}
>
<div>Einstellungen</div>
</AppShell>
);
expect(html).not.toContain("has-collapsed-sidebar");
expect(html).not.toContain("data-ui-region=\"sidebar\"");
expect(html).not.toContain("data-ui-region=\"toolbar\"");
const html = renderToStaticMarkup(
<AppShell
activeView="settings"
onViewChange={() => {}}
sidebar={null}
sidebarStatus={null}
headerActions={null}
toolbar={null}
footer={null}
contextInfo={null}
sidebarCollapsed
onSidebarCollapsedChange={() => {}}
>
<div>Einstellungen</div>
</AppShell>
);
expect(html).not.toContain("has-collapsed-sidebar");
expect(html).not.toContain("data-ui-region=\"sidebar\"");
expect(html).not.toContain("data-ui-region=\"toolbar\"");
expect(html).not.toContain("data-ui-region=\"footer\"");
});
it("renders the account popover only while open", () => {
expect(renderToStaticMarkup(<AvatarMenu open={false} accountLabel="konto@example.test" actions={[]} onClose={() => {}} />)).toBe("");
const html = renderToStaticMarkup(
<AvatarMenu
open
accountLabel="konto@example.test"
actions={[{ id: "logout", label: "Abmelden", danger: true, onSelect: () => {} }]}
onClose={() => {}}
/>
);
expect(html).toContain("role=\"menu\"");
expect(html).toContain("aria-label=\"Kontomenü\"");
expect(html).toContain("konto@example.test");
expect(html).toContain("Abmelden");
expect(html).toContain("autofocus=\"\"");
});
it("keeps every view sidebar on the same content axis", () => {
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
it("maps menu keys to wrapped focus movement and closing", () => {
expect(getAvatarMenuKeyboardAction("ArrowDown", 0, 3)).toEqual({ type: "focus", index: 1 });
expect(getAvatarMenuKeyboardAction("ArrowDown", 2, 3)).toEqual({ type: "focus", index: 0 });
expect(getAvatarMenuKeyboardAction("ArrowUp", 0, 3)).toEqual({ type: "focus", index: 2 });
expect(getAvatarMenuKeyboardAction("Home", 2, 3)).toEqual({ type: "focus", index: 0 });
expect(getAvatarMenuKeyboardAction("End", 0, 3)).toEqual({ type: "focus", index: 2 });
expect(getAvatarMenuKeyboardAction("Escape", 1, 3)).toEqual({ type: "close" });
expect(getAvatarMenuKeyboardAction("Tab", 1, 3)).toBeNull();
expect(shellCss).toMatch(/\.md-shell-sidebar-scroll > :is\(\.collector-sidebar, \.settings-sidebar, \.history-sidebar, \.statistics-sidebar\)\s*\{[^}]*gap:\s*8px;[^}]*padding:\s*0;[^}]*width:\s*100%;/s);
expect(shellCss).toMatch(/\.md-shell-sidebar-scroll > :is\(\.collector-sidebar, \.settings-sidebar, \.history-sidebar, \.statistics-sidebar\) > :is\(\.collector-sidebar-heading, \.settings-sidebar-heading, \.history-sidebar-heading, \.statistics-sidebar-heading\)\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*28px;[^}]*padding:\s*0;/s);
expect(shellCss).toMatch(/\.md-shell-sidebar-scroll \.collector-sidebar-select\s*\{[^}]*padding:\s*0 10px;/s);
});
});
it("renders the account popover only while open", () => {
expect(renderToStaticMarkup(<AvatarMenu open={false} accountLabel="konto@example.test" actions={[]} onClose={() => {}} />)).toBe("");
const html = renderToStaticMarkup(
<AvatarMenu
open
accountLabel="konto@example.test"
actions={[{ id: "logout", label: "Abmelden", danger: true, onSelect: () => {} }]}
onClose={() => {}}
/>
);
expect(html).toContain("role=\"menu\"");
expect(html).toContain("aria-label=\"Kontomenü\"");
expect(html).toContain("konto@example.test");
expect(html).toContain("Abmelden");
expect(html).toContain("autofocus=\"\"");
});
it("maps menu keys to wrapped focus movement and closing", () => {
expect(getAvatarMenuKeyboardAction("ArrowDown", 0, 3)).toEqual({ type: "focus", index: 1 });
expect(getAvatarMenuKeyboardAction("ArrowDown", 2, 3)).toEqual({ type: "focus", index: 0 });
expect(getAvatarMenuKeyboardAction("ArrowUp", 0, 3)).toEqual({ type: "focus", index: 2 });
expect(getAvatarMenuKeyboardAction("Home", 2, 3)).toEqual({ type: "focus", index: 0 });
expect(getAvatarMenuKeyboardAction("End", 0, 3)).toEqual({ type: "focus", index: 2 });
expect(getAvatarMenuKeyboardAction("Escape", 1, 3)).toEqual({ type: "close" });
expect(getAvatarMenuKeyboardAction("Tab", 1, 3)).toBeNull();
});
});
+265 -252
View File
@@ -1,257 +1,270 @@
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 {
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,
CollectorContent,
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: []
});
});
});
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} />);
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);
});
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("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["aria-label"] === "Link auswählen");
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");
expect(commits).toBe(1);
});
});
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("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["aria-label"] === "Link auswählen");
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");
expect(commits).toBe(1);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import { calculateColumnDragPreview, updateDownloadColumnDrag, type DownloadColumnDragSession } from "../src/renderer/views/downloads/column-drag";
const columns = [
{ id: "name", left: 0, width: 300 },
{ id: "size", left: 300, width: 150 },
{ id: "status", left: 450, width: 100 }
];
describe("animated download column drag", () => {
it("moves a narrow right column left while adjacent columns make room", () => {
expect(calculateColumnDragPreview(columns, "status", -420)).toEqual({
order: ["status", "name", "size"],
offsets: { name: 100, size: 100, status: -420 },
settleOffsets: { name: 100, size: 100, status: -450 }
});
});
it("moves a wide left column right using the measured column widths", () => {
expect(calculateColumnDragPreview(columns, "name", 500)).toEqual({
order: ["size", "status", "name"],
offsets: { name: 500, size: -300, status: -300 },
settleOffsets: { name: 250, size: -300, status: -300 }
});
});
it("keeps the original order while the dragged center has not crossed a neighbor", () => {
expect(calculateColumnDragPreview(columns, "size", 20).order).toEqual(["name", "size", "status"]);
});
it("writes only the continuous active offset while the target slot stays unchanged", () => {
const setProperty = vi.fn();
const root = {
classList: { add: vi.fn() },
dataset: { columnDragging: "size" },
querySelectorAll: vi.fn(() => []),
style: { setProperty }
} as unknown as HTMLElement;
const session = {
active: true,
draggedId: "size",
measurements: columns,
pointerId: 1,
preview: calculateColumnDragPreview(columns, "size", 20),
root,
startX: 0
} as DownloadColumnDragSession;
updateDownloadColumnDrag(session, 25);
expect(setProperty).toHaveBeenCalledTimes(1);
expect(setProperty).toHaveBeenCalledWith("--downloads-active-drag-x", "25px");
});
});
+33 -1
View File
@@ -4,7 +4,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
const originalFetch = globalThis.fetch;
@@ -2749,6 +2749,38 @@ describe("normalizeResolvedFilename", () => {
});
});
describe("parseRapidgatorFileSize", () => {
it("converts hoster size labels to bytes", () => {
expect(parseRapidgatorFileSize("1.50 GB")).toBe(1_610_612_736);
expect(parseRapidgatorFileSize("658,25 MB")).toBe(690_225_152);
expect(parseRapidgatorFileSize("1024 B")).toBe(1024);
});
it("rejects missing and malformed values", () => {
expect(parseRapidgatorFileSize(null)).toBeNull();
expect(parseRapidgatorFileSize("unknown")).toBeNull();
});
});
describe("checkRapidgatorOnline", () => {
it("loads metadata directly without waiting for a separate HEAD request", async () => {
const methods: string[] = [];
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
methods.push(String(init?.method || "GET"));
return new Response('<html><title>episode.part01.rar</title><div>File size: <strong>1.50 GB</strong></div></html>', { status: 200 });
}) as typeof fetch;
const result = await checkRapidgatorOnline("https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
expect(methods).toEqual(["GET"]);
expect(result).toEqual({
online: true,
fileName: "episode.part01.rar",
fileSizeBytes: 1_610_612_736
});
});
});
describe("filenameFromRapidgatorUrlPath", () => {
it("extracts filename from standard rapidgator URL", () => {
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html"))
+4 -2
View File
@@ -393,7 +393,8 @@ describe("debug-server", () => {
const payload = await response.json() as Record<string, any>;
expect(payload.enabled).toBe(true);
expect(payload.status).toBe("ok");
expect(Array.isArray(payload.warnings)).toBe(true);
expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok");
expect(payload.runtimeBaseDir).toBe(fixture.baseDir);
expect(payload.host).toBe("0.0.0.0");
expect(payload.localOnly).toBe(false);
@@ -420,7 +421,8 @@ describe("debug-server", () => {
const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>;
expect(payload.status).toBe("ok");
expect(Array.isArray(payload.warnings)).toBe(true);
expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok");
expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0);
});
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { DEV_SERVER_PORT, DEV_SERVER_URL } from "../src/main/dev-server-url";
describe("development server URL", () => {
it("uses the isolated downloader development port by default", () => {
expect(DEV_SERVER_PORT).toBe("5180");
expect(DEV_SERVER_URL).toBe("http://localhost:5180");
});
});
+43 -2
View File
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
import { EventEmitter, once } from "node:events";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, resolveArchiveItemsFromList } from "../src/main/download-manager";
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager";
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
import { defaultSettings } from "../src/main/constants";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
@@ -20,7 +20,26 @@ import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/
import { UnrestrictedLink } from "../src/main/realdebrid";
const tempDirs: string[] = [];
const originalFetch = globalThis.fetch;
const originalFetch = globalThis.fetch;
describe("runWithLimitedConcurrency", () => {
it("processes the full batch without exceeding the configured worker count", async () => {
let active = 0;
let peak = 0;
const completed: number[] = [];
await runWithLimitedConcurrency([1, 2, 3, 4, 5, 6], 3, async (value) => {
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 5));
completed.push(value);
active -= 1;
});
expect(peak).toBe(3);
expect(completed.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);
});
});
describe("extractArchiveNameFromExtractorLogMessage", () => {
it("detects archive names from extractor log variants", () => {
@@ -166,6 +185,28 @@ afterEach(async () => {
});
describe("download manager", () => {
it("stores RapidGator metadata before a download starts", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "metadata", links: ["https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] }]);
const snapshot = manager.getSnapshot();
const itemId = snapshot.session.packages[snapshot.session.packageOrder[0]].itemIds[0];
const internal = manager as unknown as {
session: typeof snapshot.session;
applyRapidgatorCheckResult: (item: typeof snapshot.session.items[string], result: { online: boolean; fileName: string; fileSizeBytes: number | null }) => void;
};
internal.applyRapidgatorCheckResult(internal.session.items[itemId], {
online: true,
fileName: "episode.part01.rar",
fileSizeBytes: 1_610_612_736
});
expect(internal.session.items[itemId].totalBytes).toBe(1_610_612_736);
expect(internal.session.items[itemId].status).toBe("queued");
});
it("applies an imported settings snapshot without touching queued items or filesystem workflows", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-import-"));
tempDirs.push(root);
+213 -20
View File
@@ -1,4 +1,6 @@
import { readFileSync } from "node:fs";
import fs from "node:fs";
import path from "node:path";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
@@ -7,6 +9,7 @@ import {
buildDownloadSidebarCounts,
buildDownloadsViewModel,
classifyDownloadStatus,
getDownloadQueueTotalBytes,
type DownloadSidebarFilter,
type DownloadsModelInput
} from "../src/renderer/views/downloads/downloads-model";
@@ -21,13 +24,64 @@ import {
} from "../src/renderer/views/downloads/DownloadsView";
import {
DownloadsTableHeader,
ItemRowContent,
PackageCardContent,
areItemRowPropsEqual,
arePackageCardPropsEqual
arePackageCardPropsEqual,
downloadColumnDefinitions,
getAvailabilitySummary
} from "../src/renderer/views/downloads/DownloadsTable";
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
describe("Downloadtabellen-Spalten", () => {
it("verteilt die Breite mit ausreichend Platz für vollständige Überschriften", () => {
expect(downloadColumnDefinitions.name.width).toBe("minmax(290px, 2.3fr)");
expect(downloadColumnDefinitions.progress.width).toBe("minmax(105px, 0.85fr)");
expect(downloadColumnDefinitions.prio.width).toBe("minmax(85px, 0.8fr)");
expect(downloadColumnDefinitions.speed).toEqual(expect.objectContaining({ label: "Geschwindigkeit", width: "minmax(120px, 1fr)" }));
expect(downloadColumnDefinitions.availability).toEqual(expect.objectContaining({ label: "Verfügbarkeit", width: "minmax(110px, 1fr)" }));
});
it("uses the normal text color for sortable and static column headers", () => {
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*color:\s*var\(--ui-text\);/s);
});
it("moves complete columns through pointer capture and animated transforms", () => {
const source = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/DownloadsTable.tsx"), "utf8");
const dragSource = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/column-drag.ts"), "utf8");
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(source).toContain("onColumnPointerDown");
expect(source).toContain("setPointerCapture");
expect(source).toContain("onPointerMove");
expect(source).not.toMatch(/className="downloads-column-header"[\s\S]{0,180}\sdraggable/);
expect(dragSource).toContain('root.style.setProperty(`--downloads-column-drag-${id}`');
expect(css).toMatch(/\.downloads-table\.is-column-drag-active \[data-download-column\]\s*\{[^}]*transform:\s*translate3d\(var\(--downloads-column-drag-x, 0px\), 0, 0\);[^}]*transition:\s*transform 220ms/s);
expect(css).toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*transition:\s*none;/s);
expect(css).not.toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*background:/s);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-table\.is-column-drag-active \[data-download-column\][^{]*\{[^}]*transition-duration:\s*220ms !important;/s);
});
});
describe("rollende Downloadkennzahlen", () => {
it("moves increasing values up and decreasing values down", () => {
expect(getRollingMetricDirection(300, 600)).toBe("up");
expect(getRollingMetricDirection(600, 300)).toBe("down");
expect(getRollingMetricDirection(300, 300)).toBe("none");
});
it("animates exactly the five stable sidebar metrics", () => {
const html = renderToStaticMarkup(<DownloadsSidebarStatus model={withRuntime(createInput())} />);
expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(5);
expect(html).toContain('data-status-metric="speed"');
expect(html).toContain('data-status-metric="eta"');
});
});
function item(id: string, packageId: string, status: DownloadStatus, overrides: Partial<DownloadItem> = {}): DownloadItem {
return {
id,
@@ -57,10 +111,21 @@ function pkg(id: string, name: string, itemIds: string[]): PackageEntry {
return { id, name, itemIds, createdAt: now } as PackageEntry;
}
describe("Download-Gesamtgröße", () => {
it("summiert bekannte Dateigrößen und verwendet geladene Bytes nur als Fallback", () => {
const items = [
item("known", "package-a", "queued", { totalBytes: 4_000, downloadedBytes: 500 }),
item("fallback", "package-a", "queued", { totalBytes: null, downloadedBytes: 750 })
];
expect(getDownloadQueueTotalBytes(items)).toBe(4_750);
});
});
function createInput(overrides: Partial<DownloadsModelInput> = {}): DownloadsModelInput {
const items = [
item("active", "package-a", "downloading"),
item("queued", "package-a", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
item("queued", "package-a", "queued", { provider: "debridlink", providerLabel: "Debrid-Link", url: "https://ddownload.com/file/queued" }),
item("failed", "package-b", "failed", { provider: "alldebrid", providerLabel: "AllDebrid" }),
item("done", "package-b", "completed", { provider: "realdebrid", providerLabel: "Real-Debrid" })
];
@@ -114,7 +179,6 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
onToggleSelection: () => {},
onSelectionMouseDown: () => {},
onSelectionMouseEnter: () => {},
onTogglePackage: () => {},
onTogglePackageCollapse: () => {},
onStartPackageRename: () => {},
onPackageRenameChange: () => {},
@@ -126,11 +190,10 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
onRemoveItem: () => {},
onOpenContextMenu: () => {},
onSortColumn: () => {},
onColumnDragStart: () => {},
onColumnDragOver: () => {},
onColumnDragLeave: () => {},
onColumnDrop: () => {},
onColumnDragEnd: () => {},
onColumnPointerDown: () => {},
onColumnPointerMove: () => {},
onColumnPointerUp: () => {},
onColumnPointerCancel: () => {},
onColumnContextMenu: () => {},
...overrides
};
@@ -190,7 +253,9 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
packages: 2,
links: 4,
session: "3,00 GB",
sessionBytes: 3_000_000_000,
total: "10,00 GB",
totalBytes: 10_000_000_000,
hosters: 3,
speed: "96,00 Mbit/s",
eta: "00:05:00"
@@ -325,6 +390,39 @@ describe("downloads model", () => {
});
describe("downloads view", () => {
it("pins the clipboard toggle separately at the bottom above the status metrics", () => {
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
const actionsMarkup = html.match(/<div class="downloads-sidebar-actions">([\s\S]*?)<\/div>/)?.[1] ?? "";
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(html).toContain('class="downloads-clipboard-toggle"');
expect(actionsMarkup).not.toContain("Zwischenablage überwachen");
expect(css).toMatch(/\.downloads-sidebar\s*\{[^}]*height:\s*100%;[^}]*padding:\s*14px 12px 6px;/s);
expect(css).toMatch(/\.downloads-clipboard-toggle\s*\{[^}]*margin-top:\s*auto;[^}]*border:\s*1px solid var\(--ui-border\);[^}]*background:\s*var\(--ui-input\);/s);
});
it("shows the sidebar actions as permanently recognizable buttons", () => {
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(css).toMatch(/\.downloads-sidebar-actions button\s*\{[^}]*border:\s*1px solid var\(--ui-border\);[^}]*background:\s*var\(--ui-input\);[^}]*padding:\s*0 10px;/s);
});
it("marks the download filters for one measured vertical selection indicator", () => {
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(6);
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
});
it("shows only the package mode while the file mode remains hidden", () => {
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
expect(html).toContain("Pakete");
expect(html).not.toContain(">Dateien<");
expect(html).not.toContain("downloads-mode-switch");
});
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
const occupied = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
const empty = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false })} />);
@@ -474,15 +572,36 @@ describe("downloads view", () => {
expect(html.indexOf("downloads-table-header")).toBeGreaterThan(html.indexOf("downloads-table"));
expect(html.indexOf("data-visual-region=\"downloads-table-body\"")).toBeGreaterThan(html.indexOf("downloads-table-header"));
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;/s);
expect(html).toContain('class="downloads-package-card ');
expect(html).not.toContain('class="package-card');
expect(html).toContain('class="downloads-hoster-icon" data-hoster="rapidgator" src="data:image/x-icon;base64,');
expect(html).toContain('class="downloads-hoster-icon" data-hoster="ddownload" src="./provider-icons/ddownload.ico"');
expect(html).not.toContain('title="RapidGator">RG<');
expect(html).toContain('data-download-column="name"');
expect(html).toContain('data-download-column="hoster"');
expect(html).toMatch(/grid-template-columns:[^"]+ 60px/);
expect(html).toContain('class="downloads-package-items is-expanded"');
expect(html).toContain('class="downloads-package-items-inner"');
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*scrollbar-gutter:\s*stable;/s);
expect(css).toMatch(/\.downloads-table-header,\s*\.downloads-table-body\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*1191px;/s);
expect(css).not.toMatch(/min-width:\s*max-content;/);
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*height:\s*41px;[^}]*position:\s*sticky;/s);
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*48px;/s);
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*40px;/s);
expect(css).toMatch(/\.downloads-item-row\s*\{[^}]*height:\s*38px;/s);
expect(css).toMatch(/\.downloads-toolbar button,\s*\.downloads-footer button,[^{]+\{[^}]*height:\s*36px;/s);
expect(css).toMatch(/\.downloads-content\s*\{[^}]*height:\s*100%;/s);
expect(css).toMatch(/\.downloads-action-cell button,\s*\.downloads-collapse-button\s*\{[^}]*width:\s*30px;[^}]*height:\s*30px;/s);
expect(css).toMatch(/\.downloads-collapse-button\s*\{[^}]*box-sizing:\s*border-box;[^}]*flex:\s*0 0 30px;[^}]*width:\s*30px;[^}]*min-width:\s*30px;[^}]*max-width:\s*30px;/s);
expect(css).toMatch(/\.downloads-cell-slot\s*\{[^}]*display:\s*flex;[^}]*justify-content:\s*center;/s);
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*\.downloads-cell\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
expect(css).toMatch(/\[data-download-column="name"\][^{]*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s);
expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s);
expect(css).toMatch(/\.downloads-item-row\s+\.downloads-meter\s*>\s*b\s*\{[^}]*color:\s*var\(--ui-text\);/s);
expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/);
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid var\(--ui-border\);[^}]*padding:\s*0;/s);
expect(css).not.toMatch(/gradient|box-shadow|nth-child/i);
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s);
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*box-shadow:\s*none;/s);
expect(css).not.toMatch(/gradient|nth-child/i);
});
it("keeps visible interaction text non-selectable and only inputs plus copy values selectable", () => {
@@ -492,6 +611,17 @@ describe("downloads view", () => {
expect(css).toMatch(/\.downloads-copyable,\s*\.downloads-search-input,\s*\.downloads-rename-input\s*\{[^}]*user-select:\s*text;/s);
});
it("marks selected rows clearly, enlarges selection checkboxes and slows package disclosure", () => {
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
const source = readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8");
expect(css).toMatch(/\.downloads-item-row\.is-selected,\s*\.downloads-package-card\.is-selected\s*>\s*\.downloads-package-row\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--ui-success\) 14%, var\(--ui-canvas\)\);[^}]*box-shadow:\s*inset 3px 0 0 var\(--ui-success\);/s);
expect(css).toMatch(/\.downloads-selection-cell\s+input\[type="checkbox"\]\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;/s);
expect(css).toMatch(/\.downloads-hoster-icon\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;[^}]*object-fit:\s*contain;/s);
expect(css).toMatch(/\.downloads-hoster-icon\[data-hoster="rapidgator"\]\s*\{[^}]*transform:\s*translateY\(-4px\) scale\(2\);/s);
expect(source.match(/duration:\s*300/g)).toHaveLength(2);
});
it("keeps the 1120px layout inside the single downloads table scroll owner", () => {
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
@@ -511,6 +641,14 @@ describe("downloads view", () => {
});
describe("downloads App integration", () => {
it("updates the clipboard checkbox optimistically before IPC reconciliation", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
expect(source).toMatch(/const toggleClipboardWatcher = useCallback\(\(\): void => \{[\s\S]*setClipboardWatcherActive\(next\);[\s\S]*window\.rd\.toggleClipboard\(\)/);
expect(source).toContain("onToggleClipboardWatcher: toggleClipboardWatcher");
expect(source).not.toContain("onToggleClipboardWatcher: () => { void performQuickAction(() => window.rd.toggleClipboard()); }");
});
it("uses the extracted table only, cleans temporary drag listeners and routes the global context start through the shared callback", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
@@ -523,6 +661,63 @@ describe("downloads App integration", () => {
});
describe("download table row contracts", () => {
it("summarizes full, partial, offline and unchecked package availability", () => {
expect(getAvailabilitySummary([
item("online-a", "package-a", "queued", { onlineStatus: "online" }),
item("online-b", "package-a", "queued", { onlineStatus: "online" })
])).toEqual({ online: 2, total: 2, state: "online" });
expect(getAvailabilitySummary([
item("partial-a", "package-a", "queued", { onlineStatus: "online" }),
item("partial-b", "package-a", "queued", { onlineStatus: "offline" })
])).toEqual({ online: 1, total: 2, state: "partial" });
expect(getAvailabilitySummary([
item("offline-a", "package-a", "queued", { onlineStatus: "offline" }),
item("offline-b", "package-a", "queued", { onlineStatus: "offline" })
])).toEqual({ online: 0, total: 2, state: "offline" });
expect(getAvailabilitySummary([
item("unknown-a", "package-a", "queued", { onlineStatus: undefined })
])).toEqual({ online: 0, total: 1, state: "checking" });
});
it("renders availability for package and file rows", () => {
const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" });
const packageHtml = renderToStaticMarkup(PackageCardContent({
actions: createActions(),
columnOrder: ["availability"],
editing: false,
editingName: "",
gridTemplate: "110px",
packageSpeedBps: 0,
row: { package: pkg("package-a", "Paket", [onlineItem.id]), items: [onlineItem], collapsed: true },
selectedIds: new Set<string>(),
selectedVersion: 0
}));
const itemHtml = renderToStaticMarkup(ItemRowContent({
actions: createActions(),
columnOrder: ["availability"],
gridTemplate: "110px",
item: onlineItem,
selected: false
}));
expect(packageHtml).toContain("1/1 online");
expect(packageHtml).toContain("downloads-availability has-counts is-online");
expect(packageHtml).toContain("downloads-availability-count is-online-count");
expect(packageHtml).toContain("downloads-availability-separator");
expect(packageHtml).toContain("downloads-availability-count is-total-count");
expect(packageHtml).toContain("downloads-availability-label");
expect(itemHtml).toContain("Online");
expect(itemHtml).toContain("downloads-availability is-online");
});
it("aligns availability symbols, split counts and labels on fixed axes", () => {
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(css).toMatch(/\.downloads-availability\.has-counts\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*16px 4ch 1ch 4ch auto;[^}]*font-variant-numeric:\s*tabular-nums;/s);
expect(css).toMatch(/\.downloads-availability-count\.is-online-count\s*\{[^}]*text-align:\s*right;/s);
expect(css).toMatch(/\.downloads-availability-count\.is-total-count\s*\{[^}]*text-align:\s*left;/s);
});
it("preserves the package download and extraction phase split", () => {
const extractionPackage = {
...pkg("extracting-package", "Entpackendes Paket", ["extracting-item"]),
@@ -646,14 +841,13 @@ describe("download table row contracts", () => {
expect(commits).toEqual(["package-a:Neuer Name"]);
});
it("keeps package selection and activation as separate controls and sends context coordinates", () => {
it("removes the redundant package activation checkbox and preserves context actions", () => {
const calls: Array<unknown> = [];
const model = withRuntime(createInput());
const row = model.packageRows[0];
const component = PackageCardContent({
actions: createActions({
onToggleSelection: (id) => calls.push(["select", id]),
onTogglePackage: (id) => calls.push(["toggle", id]),
onToggleSelection: (id, ctrl, shift) => calls.push(["select", id, ctrl, shift]),
onOpenContextMenu: (id, x, y) => calls.push(["context", id, x, y])
}),
columnOrder: model.columnOrder,
@@ -666,13 +860,12 @@ describe("download table row contracts", () => {
selectedVersion: 1
});
const selection = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie auswählen");
const activation = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie aktivieren");
const packageElement = findElement(component, (element) => element.props["data-download-package-id"] === "package-a");
selection.props.onChange();
activation.props.onChange();
selection.props.onClick({ stopPropagation: () => {}, ctrlKey: false, metaKey: false, shiftKey: true });
packageElement.props.onContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: 30, clientY: 50 });
expect(calls).toEqual([["select", "package-a"], ["toggle", "package-a"], ["context", "package-a", 30, 50]]);
expect(calls).toEqual([["select", "package-a", true, true], ["context", "package-a", 30, 50]]);
expect(() => findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie aktivieren")).toThrow("Element not found");
});
});
+47 -11
View File
@@ -14,8 +14,9 @@ import {
type HistoryViewEntry
} from "../src/renderer/views/history/history-model";
import {
HistoryContent,
HistoryToolbar,
HistoryContent,
HistorySidebar,
HistoryToolbar,
HistoryView,
type HistoryViewActions
} from "../src/renderer/views/history/HistoryView";
@@ -203,8 +204,17 @@ describe("history model", () => {
});
});
describe("HistoryView", () => {
it("keeps the header and rows inside the same internal horizontal scroll context", () => {
describe("HistoryView", () => {
it("marks history filters for one measured vertical selection indicator", () => {
const model = buildHistoryViewModel(entries, "week", "", [], [], false, "", now);
const html = renderToStaticMarkup(<HistorySidebar actions={createActions()} model={model} />);
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(7);
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
});
it("keeps the header and rows inside the same internal horizontal scroll context", () => {
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
@@ -273,11 +283,25 @@ describe("HistoryView", () => {
expect(html).toContain("history-status-failed");
expect(html).toContain("Abgeschlossen");
expect(html).toContain("Gelöscht");
expect(html).toContain("Fehlgeschlagen");
expect(html).not.toMatch(/>Start<|>Pause<|>Stop<|Priorität/);
});
it("renders each visual marker once, occupied main rows separately from closed detail rows and an honest footer", () => {
expect(html).toContain("Fehlgeschlagen");
expect(html).not.toMatch(/>Start<|>Pause<|>Stop<|Priorität/);
});
it("matches the download action control and centers every header except package and file", () => {
const content = HistoryContent({
actions: createActions(),
model: buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now)
});
const actionCell = findElement(content, (element) => element.props.className === "history-row-action");
const styles = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
expect(actionCell.props.children.props.children).toBe("⋮");
expect(styles).toMatch(/\.history-row-action button\s*\{[^}]*background:\s*var\(--ui-input\);[^}]*border:\s*1px solid var\(--ui-border\);[^}]*height:\s*30px;[^}]*width:\s*30px;/s);
expect(styles).toMatch(/\.history-table-header-row > span\s*\{[^}]*text-align:\s*center;/s);
expect(styles).toMatch(/\.history-table-header-row > span:nth-child\(2\)\s*\{[^}]*text-align:\s*left;/s);
});
it("renders each visual marker once, occupied main rows separately from closed detail rows and an honest footer", () => {
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
@@ -392,8 +416,20 @@ describe("HistoryView", () => {
});
});
describe("visual history states", () => {
it("re-arms the real App mounted gate before every StrictMode lifecycle setup can start async work", () => {
describe("visual history states", () => {
it("keeps the selected category visible immediately while only its geometry glides", () => {
const styles = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
const selection = readFileSync(new URL("../src/renderer/ui/SlidingSelection.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
const selectionStyles = styles.slice(styles.indexOf(".ui-sliding-selection"), styles.indexOf("html,"));
expect(selectionStyles).toMatch(/\.ui-sliding-selection::before\s*\{[^}]*opacity:\s*1;/s);
expect(selectionStyles).not.toContain(".ui-sliding-selection.has-sliding-selection::before");
expect(selectionStyles).not.toMatch(/transition(?:-property)?:[^;]*opacity/);
expect(selection).not.toContain("classList.add(\"has-sliding-selection\")");
expect(selection).not.toContain("classList.remove(\"has-sliding-selection\")");
});
it("re-arms the real App mounted gate before every StrictMode lifecycle setup can start async work", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
const firstRequest = source.indexOf("window.rd.getVersion()");
const effectStart = source.lastIndexOf("useEffect(() => {", firstRequest);
+163
View File
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import { normalizeLanguage, translateUiText } from "../src/renderer/i18n";
describe("renderer localization", () => {
it("falls back to English", () => {
expect(normalizeLanguage(undefined)).toBe("en");
expect(normalizeLanguage("fr")).toBe("en");
expect(normalizeLanguage("de")).toBe("de");
});
it("translates exact interface labels in both directions", () => {
expect(translateUiText("Einstellungen speichern", "en")).toBe("Save settings");
expect(translateUiText("Save settings", "de")).toBe("Einstellungen speichern");
expect(translateUiText("Passwort/Zugang", "en")).toBe("Password/access");
});
it("translates dynamic update and pagination text", () => {
expect(translateUiText("v2.0.14 ist verfügbar. Installierte Version: 2.0.13.", "en"))
.toBe("v2.0.14 is available. Installed version: 2.0.13.");
expect(translateUiText("146 von 46", "en")).toBe("146 of 46");
});
it("translates the complete history surface including status values", () => {
const translations = new Map([
["Alle Einträge", "All entries"],
["Heute", "Today"],
["Letzte 7 Tage", "Last 7 days"],
["Älter", "Older"],
["Gelöscht", "Deleted"],
["Fehlgeschlagen", "Failed"],
["Verlauf leeren", "Clear history"],
["Erneut hinzufügen", "Add again"],
["Im Ordner zeigen", "Show in folder"],
["Auswahl löschen", "Clear selection"],
["Noch kein Verlauf", "No history yet"],
["Keine passenden Einträge", "No matching entries"]
]);
for (const [german, english] of translations) {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
}
});
it("translates dynamic queue and availability values", () => {
expect(translateUiText("26/26 online", "en")).toBe("26/26 online");
expect(translateUiText("3 abgebrochen", "en")).toBe("3 cancelled");
expect(translateUiText("Geplant: 18:30", "en")).toBe("Scheduled: 18:30");
expect(translateUiText("Einträge: 2", "en")).toBe("Entries: 2");
expect(translateUiText("Sichtbar: 2", "en")).toBe("Visible: 2");
expect(translateUiText("Ausgewählt: 0", "en")).toBe("Selected: 0");
expect(translateUiText("2 pro Seite", "en")).toBe("2 per page");
expect(translateUiText("Sichtbar: ", "en")).toBe("Visible: ");
expect(translateUiText(" pro Seite", "en")).toBe(" per page");
});
it.each([
["Verfügbarkeit", "Availability"],
["Hinzugefügt am", "Added on"],
["Ungeprüft", "Unchecked"],
["Paket gestoppt", "Package stopped"],
["Noch keine Downloads", "No downloads yet"],
["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."],
["Neue Sammlung", "New collection"],
["Links erfassen", "Capture links"],
["Datei importieren", "Import file"],
["Sammlung verarbeiten", "Process collection"],
["An Downloads übergeben", "Send to downloads"],
["Keine passenden Links", "No matching links"],
["Noch keine Links", "No links yet"],
["Eine URL oder Rohzeile pro Zeile", "One URL or raw line per line"],
["Prüfen und speichern", "Check and save"],
["Accounts durchsuchen", "Search accounts"],
["Account-Typ filtern", "Filter account type"],
["Keine passenden Account-Typen.", "No matching account types."],
["Geschützter Zugang", "Protected access"],
["Immer erste Tonspur", "Always first audio track"],
["Keine Archive löschen", "Do not delete archives"],
["Sieben Tage", "Seven days"],
["Zeitraum", "Period"],
["Nicht verfügbar", "Unavailable"],
["Datenmenge", "Data volume"],
["Erfolgsquote", "Success rate"],
["Durchschnitt", "Average"],
["Live aus der aktuellen Renderer-Sitzung", "Live from the current renderer session"],
["Kontextmenü", "Context menu"],
["Die Oberfläche hat einen Fehler ausgelöst", "The interface encountered an error"],
["Oberfläche neu laden", "Reload interface"],
["Unbekannter Fehler", "Unknown error"],
["Details anzeigen", "Show details"],
["Details ausblenden", "Hide details"]
])("translates renderer text %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Film.mkv auswählen", "Select Film.mkv"],
["Aktionen für Film.mkv", "Actions for Film.mkv"],
["RapidGator Zuordnung entfernen", "Remove RapidGator assignment"],
["RapidGator nach oben", "Move RapidGator up"],
["Geplant: Heute 22:15", "Scheduled: Today 22:15"],
["Tonspur: 2 OK · 1 ohne DE-Tag · ffmpeg fehlt · 3 Fehler", "Audio track: 2 OK · 1 without DE tag · ffmpeg missing · 3 errors"],
["4/8 fertig · 2 Fehler", "4/8 completed · 2 errors"],
["Entpacken 52%", "Extracting 52%"],
["Fehlgeschlagen nach 3 Versuchen: HTTP 503 von https://host.test/a", "Failed after 3 attempts: HTTP 503 von https://host.test/a"],
["Update-Check fehlgeschlagen: ECONNRESET https://api.test/v1", "Update check failed: ECONNRESET https://api.test/v1"],
["Account geprüft — Premium bis 2027-01-01", "Account checked — Premium until 2027-01-01"],
["7 Link(s) zur Queue hinzugefügt", "7 link(s) added to the queue"],
["2 Paket(e), 5 Link(s) importiert", "2 package(s), 5 link(s) imported"],
["3 ausgewählt", "3 selected"],
["vor 4 Std", "4 hr ago"],
["Zeitregel 4", "Schedule rule 4"],
["1.5 GB von 10 GB übrig", "1.5 GB of 10 GB remaining"]
])("translates composed renderer text %s without changing its payload", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
"https://rapidgator.net/file/abc?token=secret",
"C:\\Downloads\\Film [1080p]\\video.mkv",
"video.GERMAN.DL.1080p.mkv",
"ECONNRESET at api.example.test:443",
"RapidGator API: quota=0"
])("keeps free technical content unchanged: %s", (value) => {
expect(translateUiText(value, "en")).toBe(value);
expect(translateUiText(value, "de")).toBe(value);
});
it.each([
["nicht gesetzt", "not set"],
["Schätzwert", "Estimate"],
["ist verfügbar. Installierte Version:", "is available. Installed version:"],
["Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster.", "Daily limit reached. New links will switch to the next hoster."],
["Nur lokal", "Local only"],
["Name kopiert", "Name copied"],
["Link kopiert", "Link copied"],
["Bei \"überspringen\" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.", "With \"skip\", only repeated extraction is skipped - open downloads remain in the queue."]
])("translates remaining renderer inventory text %s", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Debug-Server aktiv: 127.0.0.1:9345", "Debug server active: 127.0.0.1:9345"],
["Unbekannter Account-Typ: custom-provider", "Unknown account type: custom-provider"],
["RapidGator: Bitte Passwort eintragen.", "RapidGator: Enter a password."],
["Update-Download: 42% (42 MB / 100 MB)", "Update download: 42% (42 MB / 100 MB)"],
["Zwischenablage: 3 Link(s) erkannt", "Clipboard: 3 link(s) detected"],
["2/4 API-Keys deaktiviert.", "2/4 API keys disabled."],
["Account-Check: 3/4 Login gültig, 2 mit Premium.", "Account check: 3/4 logins valid, 2 with premium."],
["Soll RapidGator wirklich aus der Accountliste entfernt werden?", "Remove RapidGator from the account list?"],
["Konflikte gelöst: 2 überschrieben, 3 übersprungen", "Conflicts resolved: 2 overwritten, 3 skipped"],
["DLC importiert: 2 Paket(e), 5 Link(s)", "DLC imported: 2 package(s), 5 link(s)"],
["3 Fehler, 2 Warnungen (letzte 5)", "3 errors, 2 warnings (latest 5)"],
["Links für Sammlung 1 lokal erfassen.", "Capture links locally for Sammlung 1."],
["Film.mkv Klicken zum Kopieren", "Click to copy Film.mkv"]
])("translates additional composed renderer text %s", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
});
+401 -385
View File
@@ -1,401 +1,417 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
import { afterEach, describe, expect, it } from "vitest";
type ReleaseVerification = {
publish: {
provider: string;
owner: string;
repo: string;
};
latestArtifact: string;
missingArtifacts: string[];
};
type CommandResult = {
status: number | null;
stdout?: string;
stderr?: string;
error?: Error;
};
type ArchiveVerification = {
verifiedArchives: string[];
};
const verifierPath = path.resolve("scripts", "verify_public_release.mjs");
const verifierUrl = "../scripts/verify_public_release.mjs";
const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl) as {
verifyPublicRelease: (rootDir: string) => ReleaseVerification;
verifyReleaseArchives: (
rootDir: string,
options: {
sevenZipPath: string;
runCommand: (command: string, args: string[]) => CommandResult;
}
) => ArchiveVerification;
};
const fixtureRoots: string[] = [];
const redistributionFiles = [
"LICENSE",
"THIRD_PARTY_NOTICES.md",
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
"resources/extractor-jvm/licenses/7-Zip-license.txt",
"resources/extractor-jvm/licenses/Apache-2.0.txt",
"resources/extractor-jvm/THIRD_PARTY_NOTICES.txt"
] as const;
function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void {
const filePath = path.join(rootDir, ...relativePath.split("/"));
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
function writeRedistributionFiles(rootDir: string, packaged = false): void {
for (const relativePath of redistributionFiles) {
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
let targetPath: string = relativePath;
if (packaged && relativePath === "LICENSE") {
targetPath = "win-unpacked/resources/LICENSE";
} else if (packaged && relativePath === "THIRD_PARTY_NOTICES.md") {
targetPath = "win-unpacked/resources/THIRD_PARTY_NOTICES.md";
} else if (packaged) {
targetPath = `win-unpacked/resources/app.asar.unpacked/${relativePath}`;
}
writeFile(rootDir, targetPath, content);
}
}
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
import { afterEach, describe, expect, it } from "vitest";
type ReleaseVerification = {
publish: {
provider: string;
owner: string;
repo: string;
};
latestArtifact: string;
missingArtifacts: string[];
};
type CommandResult = {
status: number | null;
stdout?: string;
stderr?: string;
error?: Error;
};
type ArchiveVerification = {
verifiedArchives: string[];
};
const verifierPath = path.resolve("scripts", "verify_public_release.mjs");
const verifierUrl = "../scripts/verify_public_release.mjs";
const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl) as {
verifyPublicRelease: (rootDir: string) => ReleaseVerification;
verifyReleaseArchives: (
rootDir: string,
options: {
sevenZipPath: string;
runCommand: (command: string, args: string[]) => CommandResult;
}
) => ArchiveVerification;
};
const fixtureRoots: string[] = [];
const redistributionFiles = [
"LICENSE",
"THIRD_PARTY_NOTICES.md",
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
"resources/extractor-jvm/licenses/7-Zip-license.txt",
"resources/extractor-jvm/licenses/Apache-2.0.txt",
"resources/extractor-jvm/THIRD_PARTY_NOTICES.txt"
] as const;
function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void {
const filePath = path.join(rootDir, ...relativePath.split("/"));
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
function writeRedistributionFiles(rootDir: string, packaged = false): void {
for (const relativePath of redistributionFiles) {
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
let targetPath: string = relativePath;
if (packaged && relativePath === "LICENSE") {
targetPath = "win-unpacked/resources/LICENSE";
} else if (packaged && relativePath === "THIRD_PARTY_NOTICES.md") {
targetPath = "win-unpacked/resources/THIRD_PARTY_NOTICES.md";
} else if (packaged) {
targetPath = `win-unpacked/resources/app.asar.unpacked/${relativePath}`;
}
writeFile(rootDir, targetPath, content);
}
}
function writeArchivePayload(outputDir: string, omittedName = ""): void {
for (const relativePath of redistributionFiles) {
if (path.basename(relativePath) === omittedName) {
continue;
}
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
const targetPath = relativePath === "LICENSE"
? "resources/LICENSE"
: relativePath === "THIRD_PARTY_NOTICES.md"
? "resources/THIRD_PARTY_NOTICES.md"
: `resources/app.asar.unpacked/${relativePath}`;
writeFile(outputDir, targetPath, content);
for (const relativePath of redistributionFiles) {
if (path.basename(relativePath) === omittedName) {
continue;
}
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
const targetPath = relativePath === "LICENSE"
? "resources/LICENSE"
: relativePath === "THIRD_PARTY_NOTICES.md"
? "resources/THIRD_PARTY_NOTICES.md"
: `resources/app.asar.unpacked/${relativePath}`;
writeFile(outputDir, targetPath, content);
}
if (omittedName !== "app_icon.ico") {
writeFile(outputDir, "resources/assets/app_icon.ico", "application-icon");
}
}
function createArchiveCommandRunner(omittedName = "") {
return (command: string, args: string[]): CommandResult => {
const archivePath = args[1] || "";
const outputArg = args.find((arg) => arg.startsWith("-o"));
if (!outputArg) {
return { status: 2, stderr: "missing output directory" };
}
const outputDir = outputArg.slice(2);
if (archivePath.toLowerCase().endsWith(".exe")) {
writeFile(outputDir, "payload/app-64.7z", "nested archive");
} else if (archivePath.toLowerCase().endsWith(".7z")) {
writeArchivePayload(outputDir, omittedName);
}
return { status: command ? 0 : 2, stdout: "ok", stderr: "" };
};
}
function createReleaseFixture(): string {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-metadata-"));
fixtureRoots.push(rootDir);
const setupPayload = Buffer.from("setup");
const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64");
writeFile(rootDir, "package.json", `${JSON.stringify({
name: "real-debrid-downloader",
version: "1.7.233",
build: {
productName: "Real-Debrid-Downloader",
publish: {
provider: "github",
owner: "Sucukdeluxe",
repo: "multi-debrid-downloader"
},
files: [
"build/main/**/*",
"build/renderer/**/*",
"resources/extractor-jvm/**/*",
"LICENSE",
"THIRD_PARTY_NOTICES.md",
"package.json"
],
extraResources: [
{
from: "LICENSE",
to: "LICENSE"
},
function createArchiveCommandRunner(omittedName = "") {
return (command: string, args: string[]): CommandResult => {
const archivePath = args[1] || "";
const outputArg = args.find((arg) => arg.startsWith("-o"));
if (!outputArg) {
return { status: 2, stderr: "missing output directory" };
}
const outputDir = outputArg.slice(2);
if (archivePath.toLowerCase().endsWith(".exe")) {
writeFile(outputDir, "payload/app-64.7z", "nested archive");
} else if (archivePath.toLowerCase().endsWith(".7z")) {
writeArchivePayload(outputDir, omittedName);
}
return { status: command ? 0 : 2, stdout: "ok", stderr: "" };
};
}
function createReleaseFixture(): string {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-metadata-"));
fixtureRoots.push(rootDir);
const setupPayload = Buffer.from("setup");
const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64");
writeFile(rootDir, "package.json", `${JSON.stringify({
name: "real-debrid-downloader",
version: "1.7.233",
build: {
productName: "Real-Debrid-Downloader",
publish: {
provider: "github",
owner: "Sucukdeluxe",
repo: "multi-debrid-downloader"
},
files: [
"build/main/**/*",
"build/renderer/**/*",
"resources/extractor-jvm/**/*",
"LICENSE",
"THIRD_PARTY_NOTICES.md",
"package.json"
],
extraResources: [
{
from: "LICENSE",
to: "LICENSE"
},
{
from: "THIRD_PARTY_NOTICES.md",
to: "THIRD_PARTY_NOTICES.md"
},
{
from: "assets/app_icon.ico",
to: "assets/app_icon.ico"
}
],
nsis: {
artifactName: "${productName}-Setup-${version}.${ext}",
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true
},
portable: {
artifactName: "${productName}-${version}-portable.${ext}"
}
}
}, null, 2)}\n`);
writeFile(
rootDir,
"latest.yml",
`version: 1.7.233\nfiles:\n - url: Real-Debrid-Downloader-Setup-1.7.233.exe\n sha512: ${setupSha512}\n size: ${setupPayload.length}\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\nsha512: ${setupSha512}\n`
);
writeFile(
rootDir,
"win-unpacked/resources/app-update.yml",
"provider: github\nowner: Sucukdeluxe\nrepo: multi-debrid-downloader\n"
);
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe", setupPayload);
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap");
writeFile(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe", "portable");
],
nsis: {
artifactName: "${productName}-Setup-${version}.${ext}",
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true
},
portable: {
artifactName: "${productName}-${version}-portable.${ext}"
}
}
}, null, 2)}\n`);
writeFile(
rootDir,
"latest.yml",
`version: 1.7.233\nfiles:\n - url: Real-Debrid-Downloader-Setup-1.7.233.exe\n sha512: ${setupSha512}\n size: ${setupPayload.length}\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\nsha512: ${setupSha512}\n`
);
writeFile(
rootDir,
"win-unpacked/resources/app-update.yml",
"provider: github\nowner: Sucukdeluxe\nrepo: multi-debrid-downloader\n"
);
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe", setupPayload);
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap");
writeFile(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe", "portable");
writeRedistributionFiles(rootDir);
writeRedistributionFiles(rootDir, true);
return rootDir;
}
afterEach(() => {
for (const rootDir of fixtureRoots.splice(0)) {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
describe("public release metadata", () => {
it("accepts the canonical GitHub release metadata and artifacts", () => {
const rootDir = createReleaseFixture();
const result = verifyPublicRelease(rootDir);
expect(result.publish).toEqual({
provider: "github",
owner: "Sucukdeluxe",
repo: "multi-debrid-downloader"
});
expect(result.latestArtifact).toBe("Real-Debrid-Downloader-Setup-1.7.233.exe");
expect(result.missingArtifacts).toEqual([]);
});
it("rejects a package configured for a different GitHub owner", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
packageJson.build.publish.owner = "DifferentOwner";
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
expect(() => verifyPublicRelease(rootDir)).toThrow(/owner/i);
});
it("rejects a latest.yml path whose artifact does not exist", () => {
const rootDir = createReleaseFixture();
fs.rmSync(path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"));
expect(() => verifyPublicRelease(rootDir)).toThrow(/Real-Debrid-Downloader-Setup-1\.7\.233\.exe/);
});
it("rejects syntactically invalid latest.yml", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(
path.join(rootDir, "latest.yml"),
"version: 1.7.233\nfiles: [\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\n"
);
expect(() => verifyPublicRelease(rootDir)).toThrow(/latest\.yml|yaml/i);
});
it("rejects a noncanonical files entry in latest.yml", () => {
const rootDir = createReleaseFixture();
const latestPath = path.join(rootDir, "latest.yml");
const latest = fs.readFileSync(latestPath, "utf8").replace(
"url: Real-Debrid-Downloader-Setup-1.7.233.exe",
"url: Different-Setup-1.7.233.exe"
);
fs.writeFileSync(latestPath, latest);
expect(() => verifyPublicRelease(rootDir)).toThrow(/files|url|canonical/i);
});
it("rejects a latest.yml SHA512 digest that does not match the installer", () => {
const rootDir = createReleaseFixture();
const latestPath = path.join(rootDir, "latest.yml");
const latest = fs.readFileSync(latestPath, "utf8");
const wrongDigest = Buffer.alloc(64, 0x23).toString("base64");
fs.writeFileSync(latestPath, latest.replace(/sha512: [^\n]+/g, `sha512: ${wrongDigest}`));
expect(() => verifyPublicRelease(rootDir)).toThrow(/sha512|digest|integrity/i);
});
it("rejects a directory in place of an artifact file", () => {
const rootDir = createReleaseFixture();
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
fs.rmSync(setupPath);
fs.mkdirSync(setupPath);
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|file/i);
});
it("rejects an empty artifact file", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(path.join(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe"), "");
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|empty|file/i);
});
it("rejects a release missing a declared redistribution license", () => {
const rootDir = createReleaseFixture();
fs.rmSync(path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt"));
expect(() => verifyPublicRelease(rootDir)).toThrow(/Apache-2\.0\.txt/);
});
it("rejects a modified official license text", () => {
const rootDir = createReleaseFixture();
fs.appendFileSync(
path.join(rootDir, "resources", "extractor-jvm", "licenses", "LGPL-2.1.txt"),
"modified"
);
expect(() => verifyPublicRelease(rootDir)).toThrow(/LGPL-2\.1\.txt|digest|content/i);
});
it("rejects swapped third-party license assignments", () => {
const rootDir = createReleaseFixture();
const noticePath = path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt");
const notice = fs.readFileSync(noticePath, "utf8")
.replace("GNU Lesser General Public License 2.1 or later", "Apache License 2.0")
.replace("licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt", "licenses/Apache-2.0.txt");
fs.writeFileSync(noticePath, notice);
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|digest|mapping/i);
});
it("rejects a release whose unpacked application omits a license", () => {
const rootDir = createReleaseFixture();
fs.rmSync(path.join(
rootDir,
"win-unpacked",
"resources",
"app.asar.unpacked",
"resources",
"extractor-jvm",
"licenses",
"Apache-2.0.txt"
));
expect(() => verifyPublicRelease(rootDir)).toThrow(/win-unpacked|Apache-2\.0\.txt|packaged/i);
});
it("rejects a symlink in place of a release artifact", () => {
const rootDir = createReleaseFixture();
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
const targetPath = path.join(rootDir, "setup-target.exe");
fs.renameSync(setupPath, targetPath);
fs.symlinkSync(targetPath, setupPath, "file");
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
});
it("rejects a symlink in place of an official license", () => {
const rootDir = createReleaseFixture();
const licensePath = path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt");
const targetPath = path.join(rootDir, "Apache-target.txt");
fs.renameSync(licensePath, targetPath);
fs.symlinkSync(targetPath, licensePath, "file");
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
});
it("rejects build metadata that omits the project license", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE");
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/);
});
writeFile(rootDir, "assets/app_icon.ico", "application-icon");
writeFile(rootDir, "win-unpacked/resources/assets/app_icon.ico", "application-icon");
return rootDir;
}
afterEach(() => {
for (const rootDir of fixtureRoots.splice(0)) {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
describe("public release metadata", () => {
it("accepts the canonical GitHub release metadata and artifacts", () => {
const rootDir = createReleaseFixture();
const result = verifyPublicRelease(rootDir);
expect(result.publish).toEqual({
provider: "github",
owner: "Sucukdeluxe",
repo: "multi-debrid-downloader"
});
expect(result.latestArtifact).toBe("Real-Debrid-Downloader-Setup-1.7.233.exe");
expect(result.missingArtifacts).toEqual([]);
});
it("rejects a package configured for a different GitHub owner", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
packageJson.build.publish.owner = "DifferentOwner";
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
expect(() => verifyPublicRelease(rootDir)).toThrow(/owner/i);
});
it("rejects a latest.yml path whose artifact does not exist", () => {
const rootDir = createReleaseFixture();
fs.rmSync(path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"));
expect(() => verifyPublicRelease(rootDir)).toThrow(/Real-Debrid-Downloader-Setup-1\.7\.233\.exe/);
});
it("rejects syntactically invalid latest.yml", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(
path.join(rootDir, "latest.yml"),
"version: 1.7.233\nfiles: [\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\n"
);
expect(() => verifyPublicRelease(rootDir)).toThrow(/latest\.yml|yaml/i);
});
it("rejects a noncanonical files entry in latest.yml", () => {
const rootDir = createReleaseFixture();
const latestPath = path.join(rootDir, "latest.yml");
const latest = fs.readFileSync(latestPath, "utf8").replace(
"url: Real-Debrid-Downloader-Setup-1.7.233.exe",
"url: Different-Setup-1.7.233.exe"
);
fs.writeFileSync(latestPath, latest);
expect(() => verifyPublicRelease(rootDir)).toThrow(/files|url|canonical/i);
});
it("rejects a latest.yml SHA512 digest that does not match the installer", () => {
const rootDir = createReleaseFixture();
const latestPath = path.join(rootDir, "latest.yml");
const latest = fs.readFileSync(latestPath, "utf8");
const wrongDigest = Buffer.alloc(64, 0x23).toString("base64");
fs.writeFileSync(latestPath, latest.replace(/sha512: [^\n]+/g, `sha512: ${wrongDigest}`));
expect(() => verifyPublicRelease(rootDir)).toThrow(/sha512|digest|integrity/i);
});
it("rejects a directory in place of an artifact file", () => {
const rootDir = createReleaseFixture();
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
fs.rmSync(setupPath);
fs.mkdirSync(setupPath);
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|file/i);
});
it("rejects an empty artifact file", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(path.join(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe"), "");
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|empty|file/i);
});
it("rejects a release missing a declared redistribution license", () => {
const rootDir = createReleaseFixture();
fs.rmSync(path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt"));
expect(() => verifyPublicRelease(rootDir)).toThrow(/Apache-2\.0\.txt/);
});
it("rejects a modified official license text", () => {
const rootDir = createReleaseFixture();
fs.appendFileSync(
path.join(rootDir, "resources", "extractor-jvm", "licenses", "LGPL-2.1.txt"),
"modified"
);
expect(() => verifyPublicRelease(rootDir)).toThrow(/LGPL-2\.1\.txt|digest|content/i);
});
it("rejects swapped third-party license assignments", () => {
const rootDir = createReleaseFixture();
const noticePath = path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt");
const notice = fs.readFileSync(noticePath, "utf8")
.replace("GNU Lesser General Public License 2.1 or later", "Apache License 2.0")
.replace("licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt", "licenses/Apache-2.0.txt");
fs.writeFileSync(noticePath, notice);
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|digest|mapping/i);
});
it("rejects a release whose unpacked application omits a license", () => {
const rootDir = createReleaseFixture();
fs.rmSync(path.join(
rootDir,
"win-unpacked",
"resources",
"app.asar.unpacked",
"resources",
"extractor-jvm",
"licenses",
"Apache-2.0.txt"
));
expect(() => verifyPublicRelease(rootDir)).toThrow(/win-unpacked|Apache-2\.0\.txt|packaged/i);
});
it("rejects a symlink in place of a release artifact", () => {
const rootDir = createReleaseFixture();
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
const targetPath = path.join(rootDir, "setup-target.exe");
fs.renameSync(setupPath, targetPath);
fs.symlinkSync(targetPath, setupPath, "file");
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
});
it("rejects a symlink in place of an official license", () => {
const rootDir = createReleaseFixture();
const licensePath = path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt");
const targetPath = path.join(rootDir, "Apache-target.txt");
fs.renameSync(licensePath, targetPath);
fs.symlinkSync(targetPath, licensePath, "file");
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
});
it("rejects build metadata that omits the project license", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE");
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/);
});
it("rejects build metadata that does not copy the project license into resources", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
delete packageJson.build.extraResources;
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
delete packageJson.build.extraResources;
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
expect(() => verifyPublicRelease(rootDir)).toThrow(/extraResources|LICENSE/);
});
it("rejects incomplete third-party redistribution notices", () => {
it("rejects a packaged application without its window and tray icon", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(
path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt"),
"net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 LGPL-2.1.txt\n"
);
fs.rmSync(path.join(rootDir, "win-unpacked", "resources", "assets", "app_icon.ico"));
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|content/i);
expect(() => verifyPublicRelease(rootDir)).toThrow(/app_icon|icon/i);
});
it("returns a nonzero CLI status for invalid release metadata", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
packageJson.build.publish.owner = "DifferentOwner";
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
const result = spawnSync(process.execPath, [verifierPath, rootDir], {
encoding: "utf8"
});
expect(result.status).not.toBe(0);
expect(result.stderr).toMatch(/owner/i);
});
it("recursively verifies redistribution files inside setup and portable archives", () => {
const rootDir = createReleaseFixture();
const result = verifyReleaseArchives(rootDir, {
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
runCommand: createArchiveCommandRunner()
});
expect(result.verifiedArchives).toEqual([
"Real-Debrid-Downloader-Setup-1.7.233.exe",
"Real-Debrid-Downloader-1.7.233-portable.exe"
]);
});
it("rejects an archive whose nested application payload omits a license", () => {
const rootDir = createReleaseFixture();
expect(() => verifyReleaseArchives(rootDir, {
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
runCommand: createArchiveCommandRunner("Apache-2.0.txt")
})).toThrow(/Apache-2\.0\.txt|missing redistribution file/i);
});
it("exposes archive verification as a nonzero CLI gate", () => {
const rootDir = createReleaseFixture();
const result = spawnSync(process.execPath, [
verifierPath,
rootDir,
"--verify-archives",
"--seven-zip",
path.join(rootDir, "missing-7z.exe")
], {
encoding: "utf8"
});
expect(result.status).not.toBe(0);
expect(result.stderr).toMatch(/7-Zip|command|spawn/i);
});
});
it("rejects incomplete third-party redistribution notices", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(
path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt"),
"net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 LGPL-2.1.txt\n"
);
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|content/i);
});
it("returns a nonzero CLI status for invalid release metadata", () => {
const rootDir = createReleaseFixture();
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
packageJson.build.publish.owner = "DifferentOwner";
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
const result = spawnSync(process.execPath, [verifierPath, rootDir], {
encoding: "utf8"
});
expect(result.status).not.toBe(0);
expect(result.stderr).toMatch(/owner/i);
});
it("recursively verifies redistribution files inside setup and portable archives", () => {
const rootDir = createReleaseFixture();
const result = verifyReleaseArchives(rootDir, {
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
runCommand: createArchiveCommandRunner()
});
expect(result.verifiedArchives).toEqual([
"Real-Debrid-Downloader-Setup-1.7.233.exe",
"Real-Debrid-Downloader-1.7.233-portable.exe"
]);
});
it("rejects an archive whose nested application payload omits a license", () => {
const rootDir = createReleaseFixture();
expect(() => verifyReleaseArchives(rootDir, {
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
runCommand: createArchiveCommandRunner("Apache-2.0.txt")
})).toThrow(/Apache-2\.0\.txt|missing redistribution file/i);
});
it("exposes archive verification as a nonzero CLI gate", () => {
const rootDir = createReleaseFixture();
const result = spawnSync(process.execPath, [
verifierPath,
rootDir,
"--verify-archives",
"--seven-zip",
path.join(rootDir, "missing-7z.exe")
], {
encoding: "utf8"
});
expect(result.status).not.toBe(0);
expect(result.stderr).toMatch(/7-Zip|command|spawn/i);
});
});
+23 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { pruneSelection } from "../src/renderer/selection";
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "../src/renderer/selection";
import type { SessionState } from "../src/shared/types";
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
@@ -10,7 +10,7 @@ function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "p
return { packages, items };
}
describe("pruneSelection", () => {
describe("pruneSelection", () => {
it("drops ids whose package/item no longer exists", () => {
const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]);
const next = pruneSelection(sel, session(["p1"], ["i1"]));
@@ -41,4 +41,24 @@ describe("pruneSelection", () => {
expect([...next].sort()).toEqual(["i1", "p1", "p2"]);
expect(next).toBe(sel); // unchanged → same instance
});
});
});
describe("download selection clearing", () => {
const target = (...classes: string[]): Pick<Element, "closest"> => ({
closest: (selector: string) => selector.split(",").some((entry: string) => classes.includes(entry.trim().slice(1))) ? {} as Element : null
});
it("preserves selection while header, package and file selection controls handle their own click", () => {
expect(shouldClearDownloadSelection(target("downloads-selection-cell"))).toBe(false);
expect(shouldClearDownloadSelection(target("downloads-package-card"))).toBe(false);
expect(shouldClearDownloadSelection(target("downloads-item-row"))).toBe(false);
expect(shouldClearDownloadSelection(target("unrelated-surface"))).toBe(true);
});
it("clears checkbox-focused selection on Escape but preserves text editing", () => {
expect(shouldClearDownloadSelectionOnEscape("INPUT", "checkbox")).toBe(true);
expect(shouldClearDownloadSelectionOnEscape("DIV")).toBe(true);
expect(shouldClearDownloadSelectionOnEscape("INPUT", "text")).toBe(false);
expect(shouldClearDownloadSelectionOnEscape("TEXTAREA")).toBe(false);
});
});
+39
View File
@@ -17,6 +17,7 @@ import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import {
ACCOUNT_COLUMNS,
SETTINGS_SECTIONS,
buildSettingsFormViewModel,
buildAccountRowId,
buildTargetedAccountCheck,
filterAccountAddOptions,
@@ -411,6 +412,35 @@ describe("settings model", () => {
});
describe("settings views", () => {
it("marks settings sections for one measured vertical selection indicator", () => {
const html = renderToStaticMarkup(<SettingsSidebar actions={viewActions()} model={viewModel()} />);
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(SETTINGS_SECTIONS.length);
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
});
it("offers English and German as a live language setting", () => {
const form = buildSettingsFormViewModel({
settings: defaultSettings(),
section: "allgemein",
speedLimitInput: "0",
scheduleSpeedInputs: {}
});
const language = form.groups.flatMap((group) => group.fields).find((field) => field.id === "language");
expect(language).toEqual({
id: "language",
kind: "select",
label: "Sprache",
value: "en",
options: [
{ value: "en", label: "English" },
{ value: "de", label: "Deutsch" }
]
});
});
it("renders one real sidebar marker and all sections", () => {
const html = renderToStaticMarkup(<SettingsSidebar actions={viewActions()} model={viewModel()} />);
expect(count(html, "data-visual-region=\"settings-sidebar\"")).toBe(1);
@@ -459,6 +489,14 @@ describe("settings views", () => {
});
describe("account workspace", () => {
it("marks account panels for one measured horizontal selection indicator", () => {
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
expect(html).toContain("ui-sliding-selection ui-sliding-selection-horizontal");
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(2);
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
});
it("renders the exact columns, one table marker, full usernames and no raw credentials", () => {
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
const positions = ACCOUNT_COLUMNS.map((column) => html.indexOf(column));
@@ -621,6 +659,7 @@ describe("settings geometry", () => {
expect(css).toMatch(/\.settings-switch\s*{[^}]*width:\s*40px;[^}]*height:\s*20px;/s);
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*height:\s*41px;/s);
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*overflow:\s*hidden;/s);
expect(css).toMatch(/\.settings-account-table-grid\s*{[^}]*color:\s*var\(--ui-text\);/s);
expect(css).toMatch(/\.settings-account-row\s*{[^}]*height:\s*48px;/s);
expect(css).toMatch(/\.settings-account-table-body\s*{[^}]*overflow:\s*auto;/s);
expect(accountWorkspaceSource).toContain("onScroll={syncAccountTableScroll}");
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
describe("sliding selection scheduling", () => {
it("applies the first mounted selection before scheduling later movements", async () => {
const selectionModule = await import("../src/renderer/ui/SlidingSelection");
const schedule = Reflect.get(selectionModule, "scheduleSelectionLayout");
expect(schedule).toBeTypeOf("function");
const events: string[] = [];
const pending: { callback?: FrameRequestCallback } = {};
const requestFrame = (callback: FrameRequestCallback): number => {
events.push("request");
pending.callback = callback;
return 9;
};
const cancelFrame = (frame: number): void => {
events.push(`cancel:${frame}`);
};
const apply = (): void => {
events.push("apply");
};
const enableTransitions = (): void => {
events.push("enable");
};
expect(schedule(false, 0, apply, requestFrame, cancelFrame, enableTransitions)).toBe(9);
expect(events).toEqual(["apply", "request"]);
pending.callback?.(0);
expect(events).toEqual(["apply", "request", "enable"]);
events.length = 0;
expect(schedule(true, 4, apply, requestFrame, cancelFrame)).toBe(9);
expect(events).toEqual(["cancel:4", "request"]);
pending.callback?.(0);
expect(events).toEqual(["cancel:4", "request", "apply"]);
});
});
+406 -397
View File
@@ -1,399 +1,408 @@
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
import { readBandwidthChartPalette } from "../src/renderer/App";
import {
buildStatisticsViewModel,
type StatisticsMetric,
type StatisticsRange
} from "../src/renderer/views/statistics/statistics-model";
import {
StatisticsContent,
StatisticsSidebar,
StatisticsView,
type StatisticsViewActions
} from "../src/renderer/views/statistics/StatisticsView";
import { createVisualFixture } from "./visual/fixtures";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
function createSnapshot(): UiSnapshot {
return structuredClone(createVisualFixture("empty").snapshot);
}
function item(
id: string,
status: DownloadStatus,
overrides: Partial<DownloadItem> = {}
): DownloadItem {
return {
id,
packageId: "statistics-package",
url: `https://url-host-${id}.example/file`,
provider: "realdebrid",
providerLabel: "Real-Debrid",
status,
retries: 0,
speedBps: 0,
downloadedBytes: 100,
totalBytes: 100,
progressPercent: status === "completed" ? 100 : 50,
fileName: `${id}.bin`,
targetPath: `C:\\Downloads\\${id}.bin`,
resumable: true,
attempts: 1,
lastError: status === "failed" ? "Fehlgeschlagen" : "",
fullStatus: status,
createdAt: now - 1000,
updatedAt: now,
...overrides
};
}
function setItems(snapshot: UiSnapshot, items: DownloadItem[]): void {
snapshot.session.items = Object.fromEntries(items.map((entry) => [entry.id, entry]));
}
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);
}
function findButton(node: ReactNode, label: string): ReactElement {
let result: ReactElement | null = null;
visitElements(node, (element) => {
if (!result && element.type === "button" && element.props.children === label) {
result = element;
}
});
if (!result) {
throw new Error(`Button not found: ${label}`);
}
return result;
}
function createActions(overrides: Partial<StatisticsViewActions> = {}): StatisticsViewActions {
return {
onRangeChange: () => {},
onResetSession: () => {},
onResetAll: () => {},
onResetErrors: () => {},
...overrides
};
}
function expectUnavailable(metric: StatisticsMetric): void {
expect(metric).toMatchObject({ value: null, available: false });
expect(metric.sourceLabel.trim()).not.toBe("");
}
describe("statistics model", () => {
it("uses real snapshot session fields and excludes active or waiting downloads from the success denominator", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 600;
snapshot.stats.totalFilesSession = 3;
snapshot.session.running = true;
setItems(snapshot, [
item("complete-a", "completed"),
item("complete-b", "completed"),
item("complete-c", "completed"),
item("failed", "failed"),
item("active", "downloading"),
item("waiting", "queued")
]);
const model = buildStatisticsViewModel(snapshot, "session", now);
expect(model.metrics.downloadedBytes.value).toBe(600);
expect(model.metrics.files.value).toBe(3);
expect(model.metrics.successRate.value).toBe(75);
expect(model.metrics.errors.value).toBe(1);
});
it("reports no success rate when the current queue has no completed or failed result", () => {
const snapshot = createSnapshot();
snapshot.session.running = true;
setItems(snapshot, [item("active", "downloading"), item("waiting", "queued")]);
const model = buildStatisticsViewModel(snapshot, "session", now);
expectUnavailable(model.metrics.successRate);
expect(model.metrics.errors).toMatchObject({ value: 0, available: true });
});
it("sorts current-queue providers by bytes and then id without deriving them from URL hostnames", () => {
const snapshot = createSnapshot();
snapshot.session.running = true;
setItems(snapshot, [
item("real", "completed", {
url: "https://alldebrid.invalid/wrong-source",
provider: "realdebrid",
providerLabel: "Real-Debrid Konto",
downloadedBytes: 200
}),
item("all", "failed", {
url: "https://realdebrid.invalid/wrong-source",
provider: "alldebrid",
providerLabel: "AllDebrid",
downloadedBytes: 200
}),
item("link", "completed", {
url: "https://realdebrid.invalid/also-wrong",
provider: "debridlink",
providerLabel: "Debrid-Link",
downloadedBytes: 400
}),
item("unknown", "completed", {
url: "https://hoster-only.invalid/not-a-provider",
provider: null,
providerLabel: undefined,
downloadedBytes: 900
})
]);
const model = buildStatisticsViewModel(snapshot, "session", now);
expect(model.providers.map((row) => row.id)).toEqual(["debridlink", "alldebrid", "realdebrid"]);
expect(model.providers.map((row) => row.label)).toEqual(["Debrid-Link", "AllDebrid", "Real-Debrid Konto"]);
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[1, 0], [0, 1], [1, 0]]);
expect(model.providers.some((row) => row.id.includes("host"))).toBe(false);
});
it("uses daily provider usage only for the matching local day", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 999_999;
snapshot.settings.providerDailyUsageDay = "2026-08-10";
snapshot.settings.providerDailyUsageBytes = { realdebrid: 500, alldebrid: 1_500 };
const model = buildStatisticsViewModel(snapshot, "today", now);
expect(model.metrics.downloadedBytes).toMatchObject({ value: 2_000, available: true });
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
["alldebrid", 1_500],
["realdebrid", 500]
]);
expectUnavailable(model.metrics.files);
expectUnavailable(model.metrics.successRate);
expectUnavailable(model.metrics.errors);
expectUnavailable(model.metrics.averageSpeedBps);
});
it("treats a stale daily key as a genuine zero today without stale provider rows", () => {
const snapshot = createSnapshot();
snapshot.settings.providerDailyUsageDay = "2026-08-09";
snapshot.settings.providerDailyUsageBytes = { realdebrid: 900 };
const model = buildStatisticsViewModel(snapshot, "today", now);
expect(model.metrics.downloadedBytes).toMatchObject({ value: 0, available: true });
expect(model.providers).toEqual([]);
});
it.each(["week", "month"] satisfies StatisticsRange[])("keeps %s unavailable without inventing historical buckets", (range) => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 5_000;
snapshot.stats.totalDownloadedAllTime = 50_000;
snapshot.settings.providerDailyUsageDay = "2026-08-10";
snapshot.settings.providerDailyUsageBytes = { realdebrid: 4_000 };
snapshot.settings.providerTotalUsageBytes = { realdebrid: 40_000 };
const model = buildStatisticsViewModel(snapshot, range, now);
expect(model.coverage).toBe("unavailable");
expect(model.message).toBe("Für diesen Zeitraum werden noch keine historischen Daten gespeichert.");
expect(model.providers).toEqual([]);
expect(model.providerScope).toBeNull();
Object.values(model.metrics).forEach(expectUnavailable);
});
it("uses all-time counters and provider totals without inventing historical outcomes", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloadedAllTime = 25_000;
snapshot.stats.totalFilesAllTime = 42;
snapshot.settings.providerTotalUsageBytes = { realdebrid: 5_000, debridlink: 20_000 };
snapshot.summary = {
total: 10,
success: 9,
failed: 1,
cancelled: 0,
extracted: 9,
durationSeconds: 10,
averageSpeedBps: 2_500
};
const model = buildStatisticsViewModel(snapshot, "all", now);
expect(model.metrics.downloadedBytes.value).toBe(25_000);
expect(model.metrics.files.value).toBe(42);
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
["debridlink", 20_000],
["realdebrid", 5_000]
]);
expect(model.providers.every((row) => row.completed === null && row.failed === null)).toBe(true);
expectUnavailable(model.metrics.successRate);
expectUnavailable(model.metrics.errors);
expectUnavailable(model.metrics.averageSpeedBps);
});
it("prefers live queue outcomes over an old summary and uses the summary only after the run ends", () => {
const snapshot = createSnapshot();
setItems(snapshot, [
item("complete-a", "completed"),
item("complete-b", "completed"),
item("complete-c", "completed"),
item("failed", "failed")
]);
snapshot.summary = {
total: 4,
success: 1,
failed: 3,
cancelled: 0,
extracted: 1,
durationSeconds: 100,
averageSpeedBps: 500
};
snapshot.session.running = true;
const active = buildStatisticsViewModel(snapshot, "session", now);
snapshot.session.running = false;
const ended = buildStatisticsViewModel(snapshot, "session", now);
expect(active.metrics.successRate.value).toBe(75);
expect(active.metrics.errors.value).toBe(1);
expectUnavailable(active.metrics.averageSpeedBps);
expect(ended.metrics.successRate.value).toBe(25);
expect(ended.metrics.errors.value).toBe(3);
expect(ended.metrics.averageSpeedBps).toMatchObject({ value: 500, available: true });
});
it("models empty, idle, active and paused session states separately", () => {
const empty = createSnapshot();
const idle = createSnapshot();
setItems(idle, [item("idle", "completed")]);
const active = createSnapshot();
active.session.running = true;
setItems(active, [item("active", "downloading")]);
const paused = createSnapshot();
paused.session.running = true;
paused.session.paused = true;
setItems(paused, [item("paused", "paused")]);
expect(buildStatisticsViewModel(empty, "session", now).sessionState).toBe("empty");
expect(buildStatisticsViewModel(idle, "session", now).sessionState).toBe("idle");
expect(buildStatisticsViewModel(active, "session", now).sessionState).toBe("active");
expect(buildStatisticsViewModel(paused, "session", now).sessionState).toBe("paused");
});
});
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
import { readBandwidthChartPalette } from "../src/renderer/App";
import {
buildStatisticsViewModel,
type StatisticsMetric,
type StatisticsRange
} from "../src/renderer/views/statistics/statistics-model";
import {
StatisticsContent,
StatisticsSidebar,
StatisticsView,
type StatisticsViewActions
} from "../src/renderer/views/statistics/StatisticsView";
import { createVisualFixture } from "./visual/fixtures";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
function createSnapshot(): UiSnapshot {
return structuredClone(createVisualFixture("empty").snapshot);
}
function item(
id: string,
status: DownloadStatus,
overrides: Partial<DownloadItem> = {}
): DownloadItem {
return {
id,
packageId: "statistics-package",
url: `https://url-host-${id}.example/file`,
provider: "realdebrid",
providerLabel: "Real-Debrid",
status,
retries: 0,
speedBps: 0,
downloadedBytes: 100,
totalBytes: 100,
progressPercent: status === "completed" ? 100 : 50,
fileName: `${id}.bin`,
targetPath: `C:\\Downloads\\${id}.bin`,
resumable: true,
attempts: 1,
lastError: status === "failed" ? "Fehlgeschlagen" : "",
fullStatus: status,
createdAt: now - 1000,
updatedAt: now,
...overrides
};
}
function setItems(snapshot: UiSnapshot, items: DownloadItem[]): void {
snapshot.session.items = Object.fromEntries(items.map((entry) => [entry.id, entry]));
}
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);
}
function findButton(node: ReactNode, label: string): ReactElement {
let result: ReactElement | null = null;
visitElements(node, (element) => {
if (!result && element.type === "button" && element.props.children === label) {
result = element;
}
});
if (!result) {
throw new Error(`Button not found: ${label}`);
}
return result;
}
function createActions(overrides: Partial<StatisticsViewActions> = {}): StatisticsViewActions {
return {
onRangeChange: () => {},
onResetSession: () => {},
onResetAll: () => {},
onResetErrors: () => {},
...overrides
};
}
function expectUnavailable(metric: StatisticsMetric): void {
expect(metric).toMatchObject({ value: null, available: false });
expect(metric.sourceLabel.trim()).not.toBe("");
}
describe("statistics model", () => {
it("uses real snapshot session fields and excludes active or waiting downloads from the success denominator", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 600;
snapshot.stats.totalFilesSession = 3;
snapshot.session.running = true;
setItems(snapshot, [
item("complete-a", "completed"),
item("complete-b", "completed"),
item("complete-c", "completed"),
item("failed", "failed"),
item("active", "downloading"),
item("waiting", "queued")
]);
const model = buildStatisticsViewModel(snapshot, "session", now);
expect(model.metrics.downloadedBytes.value).toBe(600);
expect(model.metrics.files.value).toBe(3);
expect(model.metrics.successRate.value).toBe(75);
expect(model.metrics.errors.value).toBe(1);
});
it("reports no success rate when the current queue has no completed or failed result", () => {
const snapshot = createSnapshot();
snapshot.session.running = true;
setItems(snapshot, [item("active", "downloading"), item("waiting", "queued")]);
const model = buildStatisticsViewModel(snapshot, "session", now);
expectUnavailable(model.metrics.successRate);
expect(model.metrics.errors).toMatchObject({ value: 0, available: true });
});
it("sorts current-queue providers by bytes and then id without deriving them from URL hostnames", () => {
const snapshot = createSnapshot();
snapshot.session.running = true;
setItems(snapshot, [
item("real", "completed", {
url: "https://alldebrid.invalid/wrong-source",
provider: "realdebrid",
providerLabel: "Real-Debrid Konto",
downloadedBytes: 200
}),
item("all", "failed", {
url: "https://realdebrid.invalid/wrong-source",
provider: "alldebrid",
providerLabel: "AllDebrid",
downloadedBytes: 200
}),
item("link", "completed", {
url: "https://realdebrid.invalid/also-wrong",
provider: "debridlink",
providerLabel: "Debrid-Link",
downloadedBytes: 400
}),
item("unknown", "completed", {
url: "https://hoster-only.invalid/not-a-provider",
provider: null,
providerLabel: undefined,
downloadedBytes: 900
})
]);
const model = buildStatisticsViewModel(snapshot, "session", now);
expect(model.providers.map((row) => row.id)).toEqual(["debridlink", "alldebrid", "realdebrid"]);
expect(model.providers.map((row) => row.label)).toEqual(["Debrid-Link", "AllDebrid", "Real-Debrid Konto"]);
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[1, 0], [0, 1], [1, 0]]);
expect(model.providers.some((row) => row.id.includes("host"))).toBe(false);
});
it("uses daily provider usage only for the matching local day", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 999_999;
snapshot.settings.providerDailyUsageDay = "2026-08-10";
snapshot.settings.providerDailyUsageBytes = { realdebrid: 500, alldebrid: 1_500 };
const model = buildStatisticsViewModel(snapshot, "today", now);
expect(model.metrics.downloadedBytes).toMatchObject({ value: 2_000, available: true });
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
["alldebrid", 1_500],
["realdebrid", 500]
]);
expectUnavailable(model.metrics.files);
expectUnavailable(model.metrics.successRate);
expectUnavailable(model.metrics.errors);
expectUnavailable(model.metrics.averageSpeedBps);
});
it("treats a stale daily key as a genuine zero today without stale provider rows", () => {
const snapshot = createSnapshot();
snapshot.settings.providerDailyUsageDay = "2026-08-09";
snapshot.settings.providerDailyUsageBytes = { realdebrid: 900 };
const model = buildStatisticsViewModel(snapshot, "today", now);
expect(model.metrics.downloadedBytes).toMatchObject({ value: 0, available: true });
expect(model.providers).toEqual([]);
});
it.each(["week", "month"] satisfies StatisticsRange[])("keeps %s unavailable without inventing historical buckets", (range) => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 5_000;
snapshot.stats.totalDownloadedAllTime = 50_000;
snapshot.settings.providerDailyUsageDay = "2026-08-10";
snapshot.settings.providerDailyUsageBytes = { realdebrid: 4_000 };
snapshot.settings.providerTotalUsageBytes = { realdebrid: 40_000 };
const model = buildStatisticsViewModel(snapshot, range, now);
expect(model.coverage).toBe("unavailable");
expect(model.message).toBe("Für diesen Zeitraum werden noch keine historischen Daten gespeichert.");
expect(model.providers).toEqual([]);
expect(model.providerScope).toBeNull();
Object.values(model.metrics).forEach(expectUnavailable);
});
it("uses all-time counters and provider totals without inventing historical outcomes", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloadedAllTime = 25_000;
snapshot.stats.totalFilesAllTime = 42;
snapshot.settings.providerTotalUsageBytes = { realdebrid: 5_000, debridlink: 20_000 };
snapshot.summary = {
total: 10,
success: 9,
failed: 1,
cancelled: 0,
extracted: 9,
durationSeconds: 10,
averageSpeedBps: 2_500
};
const model = buildStatisticsViewModel(snapshot, "all", now);
expect(model.metrics.downloadedBytes.value).toBe(25_000);
expect(model.metrics.files.value).toBe(42);
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
["debridlink", 20_000],
["realdebrid", 5_000]
]);
expect(model.providers.every((row) => row.completed === null && row.failed === null)).toBe(true);
expectUnavailable(model.metrics.successRate);
expectUnavailable(model.metrics.errors);
expectUnavailable(model.metrics.averageSpeedBps);
});
it("prefers live queue outcomes over an old summary and uses the summary only after the run ends", () => {
const snapshot = createSnapshot();
setItems(snapshot, [
item("complete-a", "completed"),
item("complete-b", "completed"),
item("complete-c", "completed"),
item("failed", "failed")
]);
snapshot.summary = {
total: 4,
success: 1,
failed: 3,
cancelled: 0,
extracted: 1,
durationSeconds: 100,
averageSpeedBps: 500
};
snapshot.session.running = true;
const active = buildStatisticsViewModel(snapshot, "session", now);
snapshot.session.running = false;
const ended = buildStatisticsViewModel(snapshot, "session", now);
expect(active.metrics.successRate.value).toBe(75);
expect(active.metrics.errors.value).toBe(1);
expectUnavailable(active.metrics.averageSpeedBps);
expect(ended.metrics.successRate.value).toBe(25);
expect(ended.metrics.errors.value).toBe(3);
expect(ended.metrics.averageSpeedBps).toMatchObject({ value: 500, available: true });
});
it("models empty, idle, active and paused session states separately", () => {
const empty = createSnapshot();
const idle = createSnapshot();
setItems(idle, [item("idle", "completed")]);
const active = createSnapshot();
active.session.running = true;
setItems(active, [item("active", "downloading")]);
const paused = createSnapshot();
paused.session.running = true;
paused.session.paused = true;
setItems(paused, [item("paused", "paused")]);
expect(buildStatisticsViewModel(empty, "session", now).sessionState).toBe("empty");
expect(buildStatisticsViewModel(idle, "session", now).sessionState).toBe("idle");
expect(buildStatisticsViewModel(active, "session", now).sessionState).toBe("active");
expect(buildStatisticsViewModel(paused, "session", now).sessionState).toBe("paused");
});
});
describe("statistics view", () => {
it("marks statistic ranges for one measured vertical selection indicator", () => {
const model = buildStatisticsViewModel(createSnapshot(), "today", now);
const html = renderToStaticMarkup(<StatisticsSidebar actions={createActions()} model={model} />);
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(5);
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
});
it("renders each statistics marker exactly once, all ranges and no download toolbar or pagination", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 2_048;
snapshot.stats.totalFilesSession = 1;
setItems(snapshot, [item("complete", "completed")]);
const html = renderToStaticMarkup(
<StatisticsView
actions={createActions()}
chart={<div>Bestehender Bandbreitenverlauf</div>}
model={buildStatisticsViewModel(snapshot, "session", now)}
/>
);
for (const marker of ["statistics-sidebar", "statistics-kpis", "statistics-chart"]) {
expect(html.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
}
for (const label of ["Sitzung", "Heute", "Sieben Tage", "30 Tage", "Gesamt"]) {
expect(html).toContain(`>${label}<`);
}
expect(html).toContain("Bestehender Bandbreitenverlauf");
expect(html).not.toContain("downloads-toolbar");
expect(html.toLocaleLowerCase("de-DE")).not.toContain("pagination");
});
it("dispatches range and reset controls only through the supplied callbacks", () => {
const snapshot = createSnapshot();
setItems(snapshot, [item("failed", "failed")]);
const model = buildStatisticsViewModel(snapshot, "session", now);
const calls: string[] = [];
const actions = createActions({
onRangeChange: (range) => calls.push(`range:${range}`),
onResetSession: () => calls.push("reset:session"),
onResetAll: () => calls.push("reset:all"),
onResetErrors: () => calls.push("reset:errors")
});
const sidebar = StatisticsSidebar({ actions, model });
const content = StatisticsContent({ actions, chart: <div />, model });
findButton(sidebar, "Heute").props.onClick();
findButton(content, "Sitzung zurücksetzen").props.onClick();
findButton(content, "Gesamt zurücksetzen").props.onClick();
findButton(content, "Fehler zurücksetzen").props.onClick();
expect(calls).toEqual(["range:today", "reset:session", "reset:all", "reset:errors"]);
});
it("enables error reset only for a positive genuine error metric", () => {
const clean = createSnapshot();
const failed = createSnapshot();
setItems(failed, [item("failed", "failed")]);
const cleanContent = StatisticsContent({
actions: createActions(),
chart: <div />,
model: buildStatisticsViewModel(clean, "session", now)
});
const failedContent = StatisticsContent({
actions: createActions(),
chart: <div />,
model: buildStatisticsViewModel(failed, "session", now)
});
expect(findButton(cleanContent, "Fehler zurücksetzen").props.disabled).toBe(true);
expect(findButton(failedContent, "Fehler zurücksetzen").props.disabled).toBe(false);
});
it("keeps the empty provider state inside the ARIA table as a row and spanning cell", () => {
const html = renderToStaticMarkup(
<StatisticsContent
actions={createActions()}
chart={<div />}
model={buildStatisticsViewModel(createSnapshot(), "session", now)}
/>
);
expect(html).toContain('class="statistics-provider-empty" role="row"');
expect(html).toContain('aria-colspan="3" role="cell"');
});
});
describe("bandwidth chart palette", () => {
it("requests only the semantic UI color properties and keeps the computed font family", () => {
const requested: string[] = [];
const values: Record<string, string> = {
"--ui-border": " rgb(61, 61, 61) ",
"--ui-text-muted": " rgb(145, 145, 145) ",
"--ui-accent": " rgb(56, 134, 255) "
};
const palette = readBandwidthChartPalette((property) => {
requested.push(property);
return values[property];
}, "Inter, Segoe UI, sans-serif");
expect(requested).toEqual(["--ui-border", "--ui-text-muted", "--ui-accent"]);
expect(palette).toEqual({
grid: "rgb(61, 61, 61)",
text: "rgb(145, 145, 145)",
accent: "rgb(56, 134, 255)",
fontFamily: "Inter, Segoe UI, sans-serif"
});
});
});
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 2_048;
snapshot.stats.totalFilesSession = 1;
setItems(snapshot, [item("complete", "completed")]);
const html = renderToStaticMarkup(
<StatisticsView
actions={createActions()}
chart={<div>Bestehender Bandbreitenverlauf</div>}
model={buildStatisticsViewModel(snapshot, "session", now)}
/>
);
for (const marker of ["statistics-sidebar", "statistics-kpis", "statistics-chart"]) {
expect(html.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
}
for (const label of ["Sitzung", "Heute", "Sieben Tage", "30 Tage", "Gesamt"]) {
expect(html).toContain(`>${label}<`);
}
expect(html).toContain("Bestehender Bandbreitenverlauf");
expect(html).not.toContain("downloads-toolbar");
expect(html.toLocaleLowerCase("de-DE")).not.toContain("pagination");
});
it("dispatches range and reset controls only through the supplied callbacks", () => {
const snapshot = createSnapshot();
setItems(snapshot, [item("failed", "failed")]);
const model = buildStatisticsViewModel(snapshot, "session", now);
const calls: string[] = [];
const actions = createActions({
onRangeChange: (range) => calls.push(`range:${range}`),
onResetSession: () => calls.push("reset:session"),
onResetAll: () => calls.push("reset:all"),
onResetErrors: () => calls.push("reset:errors")
});
const sidebar = StatisticsSidebar({ actions, model });
const content = StatisticsContent({ actions, chart: <div />, model });
findButton(sidebar, "Heute").props.onClick();
findButton(content, "Sitzung zurücksetzen").props.onClick();
findButton(content, "Gesamt zurücksetzen").props.onClick();
findButton(content, "Fehler zurücksetzen").props.onClick();
expect(calls).toEqual(["range:today", "reset:session", "reset:all", "reset:errors"]);
});
it("enables error reset only for a positive genuine error metric", () => {
const clean = createSnapshot();
const failed = createSnapshot();
setItems(failed, [item("failed", "failed")]);
const cleanContent = StatisticsContent({
actions: createActions(),
chart: <div />,
model: buildStatisticsViewModel(clean, "session", now)
});
const failedContent = StatisticsContent({
actions: createActions(),
chart: <div />,
model: buildStatisticsViewModel(failed, "session", now)
});
expect(findButton(cleanContent, "Fehler zurücksetzen").props.disabled).toBe(true);
expect(findButton(failedContent, "Fehler zurücksetzen").props.disabled).toBe(false);
});
it("keeps the empty provider state inside the ARIA table as a row and spanning cell", () => {
const html = renderToStaticMarkup(
<StatisticsContent
actions={createActions()}
chart={<div />}
model={buildStatisticsViewModel(createSnapshot(), "session", now)}
/>
);
expect(html).toContain('class="statistics-provider-empty" role="row"');
expect(html).toContain('aria-colspan="3" role="cell"');
});
});
describe("bandwidth chart palette", () => {
it("requests only the semantic UI color properties and keeps the computed font family", () => {
const requested: string[] = [];
const values: Record<string, string> = {
"--ui-border": " rgb(61, 61, 61) ",
"--ui-text-muted": " rgb(145, 145, 145) ",
"--ui-accent": " rgb(56, 134, 255) "
};
const palette = readBandwidthChartPalette((property) => {
requested.push(property);
return values[property];
}, "Inter, Segoe UI, sans-serif");
expect(requested).toEqual(["--ui-border", "--ui-text-muted", "--ui-accent"]);
expect(palette).toEqual({
grid: "rgb(61, 61, 61)",
text: "rgb(145, 145, 145)",
accent: "rgb(56, 134, 255)",
fontFamily: "Inter, Segoe UI, sans-serif"
});
});
});
+46 -1
View File
@@ -16,7 +16,52 @@ afterEach(() => {
}
});
describe("settings storage", () => {
describe("settings storage", () => {
it("repairs a persisted version-2 column order that lost availability during default merging", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.configFile, JSON.stringify({
...defaultSettings(),
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
columnOrderVersion: 2
}), "utf8");
const loaded = loadSettings(paths);
expect(loaded.columnOrder).toEqual(["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"]);
expect(loaded.columnOrderVersion).toBe(3);
});
it("adds availability beside speed once for legacy column settings", () => {
const legacy = { ...defaultSettings(), columnOrder: ["name", "status", "speed"] } as Partial<AppSettings>;
delete legacy.columnOrderVersion;
const normalized = normalizeSettings(legacy as AppSettings);
expect(normalized.columnOrder).toEqual(["name", "status", "speed", "availability"]);
expect(normalized.columnOrderVersion).toBe(3);
expect(normalizeSettings({ ...normalized, columnOrder: ["name", "speed"] }).columnOrder).toEqual(["name", "speed"]);
});
it("uses English for new installations and preserves only supported languages", () => {
expect(defaultSettings().language).toBe("en");
expect(normalizeSettings({ ...defaultSettings(), language: "de" }).language).toBe("de");
expect(normalizeSettings({ ...defaultSettings(), language: "fr" as "en" }).language).toBe("en");
});
it("keeps German for existing settings files created before language selection existed", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const legacy = { ...defaultSettings() } as Partial<AppSettings>;
delete legacy.language;
fs.mkdirSync(path.dirname(paths.configFile), { recursive: true });
fs.writeFileSync(paths.configFile, JSON.stringify(legacy), "utf8");
expect(loadSettings(paths).language).toBe("de");
});
it("defaults download directories to the desktop project folder", () => {
const baseDir = path.join(os.homedir(), "Desktop", "Multi-Debrid-Downloader");
const defaults = defaultSettings();
+586 -584
View File
File diff suppressed because it is too large Load Diff