release: prepare v2.0.18 interface and reset reliability update

Preserve package progress and history across immediate cleanup, make extraction resets wait for all post-processing tasks, and keep archive diagnostics out of compact status cells. Rework account creation and settings selectors, improve context-menu placement, remove accidental row dragging, and expand regression coverage for the corrected workflows.
This commit is contained in:
Sucukdeluxe
2026-08-11 00:46:46 +02:00
parent 9b1eeada2e
commit ef5c20c8df
25 changed files with 1953 additions and 952 deletions
+108 -95
View File
@@ -1,102 +1,115 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import {
clampContextMenuPosition,
ContextMenu,
getContextMenuKeyboardAction,
getContextMenuSubmenuKeyboardAction,
getContextSubmenuPosition
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import {
clampContextMenuPosition,
ContextMenu,
getContextMenuKeyboardAction,
getContextMenuSubmenuKeyboardAction,
getContextSubmenuPosition
} from "../src/renderer/ui/ContextMenu";
describe("ContextMenu", () => {
it("renders menu semantics and marks buttons as menu items", () => {
const html = renderToStaticMarkup(
<ContextMenu ariaLabel="Aktionen" onClose={() => {}} open x={40} y={60}>
<button>Öffnen</button>
<button disabled>Gesperrt</button>
</ContextMenu>
);
expect(html).toContain("role=\"menu\"");
expect(html).toContain("aria-label=\"Aktionen\"");
expect(html.match(/role=\"menuitem\"/g)).toHaveLength(2);
expect(html).toContain("tabindex=\"-1\"");
});
it("server-renders without layout-effect warnings", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
renderToStaticMarkup(
<ContextMenu onClose={() => {}} open x={0} y={0}>
<button>Öffnen</button>
</ContextMenu>
);
expect(error).not.toHaveBeenCalled();
error.mockRestore();
});
it("clamps every edge to the visible viewport", () => {
expect(clampContextMenuPosition(790, 590, 220, 180, 800, 600)).toEqual({ x: 580, y: 420 });
expect(clampContextMenuPosition(-12, -8, 220, 180, 800, 600)).toEqual({ x: 0, y: 0 });
expect(clampContextMenuPosition(40, 60, 220, 180, 800, 600)).toEqual({ x: 40, y: 60 });
});
it("navigates enabled items, activates Enter and closes only the menu on Escape", () => {
const enabled = [true, false, true, true];
expect(getContextMenuKeyboardAction("ArrowDown", 0, enabled)).toEqual({ type: "focus", index: 2 });
expect(getContextMenuKeyboardAction("ArrowDown", 3, enabled)).toEqual({ type: "focus", index: 0 });
expect(getContextMenuKeyboardAction("ArrowUp", 0, enabled)).toEqual({ type: "focus", index: 3 });
expect(getContextMenuKeyboardAction("Home", 3, enabled)).toEqual({ type: "focus", index: 0 });
expect(getContextMenuKeyboardAction("End", 0, enabled)).toEqual({ type: "focus", index: 3 });
expect(getContextMenuKeyboardAction("Enter", 2, enabled)).toEqual({ type: "activate", index: 2 });
expect(getContextMenuKeyboardAction("Escape", 2, enabled)).toEqual({ type: "close" });
expect(getContextMenuKeyboardAction("ArrowDown", -1, [false, false])).toBeNull();
});
it("opens and leaves submenus with standard keyboard commands", () => {
expect(getContextMenuSubmenuKeyboardAction("Enter", true, false)).toBe("open");
expect(getContextMenuSubmenuKeyboardAction("ArrowRight", true, false)).toBe("open");
expect(getContextMenuSubmenuKeyboardAction("ArrowLeft", false, true)).toBe("close");
expect(getContextMenuSubmenuKeyboardAction("Escape", false, true)).toBe("close");
expect(getContextMenuSubmenuKeyboardAction("ArrowDown", true, false)).toBeNull();
});
it("renders nested priority choices as an announced submenu", () => {
const html = renderToStaticMarkup(
<ContextMenu onClose={() => {}} open x={0} y={0}>
<div className="ctx-menu-sub">
<button aria-haspopup="menu">Priorität</button>
<div className="ctx-menu-sub-items" role="menu">
<button>Hoch</button>
<button>Standard</button>
<button>Niedrig</button>
</div>
</div>
</ContextMenu>
);
expect(html).toContain("aria-haspopup=\"menu\"");
expect(html.match(/role=\"menu\"/g)).toHaveLength(2);
expect(html.match(/role=\"menuitem\"/g)).toHaveLength(4);
});
const contextMenuSource = readFileSync(new URL("../src/renderer/ui/ContextMenu.tsx", import.meta.url), "utf8");
const stylesSource = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
describe("ContextMenu", () => {
it("renders menu semantics and marks buttons as menu items", () => {
const html = renderToStaticMarkup(
<ContextMenu ariaLabel="Aktionen" onClose={() => {}} open x={40} y={60}>
<button>Öffnen</button>
<button disabled>Gesperrt</button>
</ContextMenu>
);
expect(html).toContain("role=\"menu\"");
expect(html).toContain("aria-label=\"Aktionen\"");
expect(html.match(/role=\"menuitem\"/g)).toHaveLength(2);
expect(html).toContain("tabindex=\"-1\"");
});
it("server-renders without layout-effect warnings", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
renderToStaticMarkup(
<ContextMenu onClose={() => {}} open x={0} y={0}>
<button>Öffnen</button>
</ContextMenu>
);
expect(error).not.toHaveBeenCalled();
error.mockRestore();
});
it("clamps every edge to the visible viewport", () => {
expect(clampContextMenuPosition(790, 590, 220, 180, 800, 600)).toEqual({ x: 580, y: 420 });
expect(clampContextMenuPosition(-12, -8, 220, 180, 800, 600)).toEqual({ x: 0, y: 0 });
expect(clampContextMenuPosition(40, 60, 220, 180, 800, 600)).toEqual({ x: 40, y: 60 });
});
it("navigates enabled items, activates Enter and closes only the menu on Escape", () => {
const enabled = [true, false, true, true];
expect(getContextMenuKeyboardAction("ArrowDown", 0, enabled)).toEqual({ type: "focus", index: 2 });
expect(getContextMenuKeyboardAction("ArrowDown", 3, enabled)).toEqual({ type: "focus", index: 0 });
expect(getContextMenuKeyboardAction("ArrowUp", 0, enabled)).toEqual({ type: "focus", index: 3 });
expect(getContextMenuKeyboardAction("Home", 3, enabled)).toEqual({ type: "focus", index: 0 });
expect(getContextMenuKeyboardAction("End", 0, enabled)).toEqual({ type: "focus", index: 3 });
expect(getContextMenuKeyboardAction("Enter", 2, enabled)).toEqual({ type: "activate", index: 2 });
expect(getContextMenuKeyboardAction("Escape", 2, enabled)).toEqual({ type: "close" });
expect(getContextMenuKeyboardAction("ArrowDown", -1, [false, false])).toBeNull();
});
it("opens and leaves submenus with standard keyboard commands", () => {
expect(getContextMenuSubmenuKeyboardAction("Enter", true, false)).toBe("open");
expect(getContextMenuSubmenuKeyboardAction("ArrowRight", true, false)).toBe("open");
expect(getContextMenuSubmenuKeyboardAction("ArrowLeft", false, true)).toBe("close");
expect(getContextMenuSubmenuKeyboardAction("Escape", false, true)).toBe("close");
expect(getContextMenuSubmenuKeyboardAction("ArrowDown", true, false)).toBeNull();
});
it("renders nested priority choices as an announced submenu", () => {
const html = renderToStaticMarkup(
<ContextMenu onClose={() => {}} open x={0} y={0}>
<div className="ctx-menu-sub">
<button aria-haspopup="menu">Priorität</button>
<div className="ctx-menu-sub-items" role="menu">
<button>Hoch</button>
<button>Standard</button>
<button>Niedrig</button>
</div>
</div>
</ContextMenu>
);
expect(html).toContain("aria-haspopup=\"menu\"");
expect(html.match(/role=\"menu\"/g)).toHaveLength(2);
expect(html.match(/role=\"menuitem\"/g)).toHaveLength(4);
});
it("places submenus inside the viewport on every edge", () => {
expect(getContextSubmenuPosition(
{ left: 700, right: 790, top: 40 },
{ width: 180, height: 150 },
{ width: 800, height: 600 }
)).toEqual({ x: 520, y: 40 });
expect(getContextSubmenuPosition(
{ left: 8, right: 98, top: 40 },
{ width: 180, height: 150 },
{ width: 800, height: 600 }
)).toEqual({ x: 98, y: 40 });
expect(getContextSubmenuPosition(
{ left: 500, right: 590, top: 560 },
{ width: 180, height: 150 },
{ width: 800, height: 600 }
expect(getContextSubmenuPosition(
{ left: 700, right: 790, top: 40 },
{ width: 180, height: 150 },
{ width: 800, height: 600 }
)).toEqual({ x: 520, y: 40 });
expect(getContextSubmenuPosition(
{ left: 8, right: 98, top: 40 },
{ width: 180, height: 150 },
{ width: 800, height: 600 }
)).toEqual({ x: 98, y: 40 });
expect(getContextSubmenuPosition(
{ left: 500, right: 590, top: 560 },
{ width: 180, height: 150 },
{ width: 800, height: 600 }
)).toEqual({ x: 590, y: 450 });
});
it("keeps submenus hidden until their viewport-safe position is ready", () => {
expect(contextMenuSource).toContain('position.ready && position.sourceX === x && position.sourceY === y ? "is-positioned" : ""');
expect(contextMenuSource).toContain('parts.items.classList.add("is-positioned")');
expect(stylesSource).toMatch(/\.ctx-menu:not\(\.is-positioned\)\s*\{[^}]*visibility:\s*hidden/s);
expect(stylesSource).not.toMatch(/\.ctx-menu-sub:hover\s+\.ctx-menu-sub-items\s*\{\s*display:\s*block/s);
expect(stylesSource).toMatch(/\.ctx-menu-sub:hover\s*>\s*\.ctx-menu-sub-items\.is-positioned/s);
expect(contextMenuSource).toContain('window.addEventListener("pointerdown", onOutside, true)');
});
});
+363 -33
View File
@@ -17,7 +17,8 @@ import { createStoragePaths, emptySession } from "../src/main/storage";
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
import { UnrestrictedLink } from "../src/main/realdebrid";
import { UnrestrictedLink } from "../src/main/realdebrid";
import type { HistoryEntry } from "../src/shared/types";
const tempDirs: string[] = [];
const originalFetch = globalThis.fetch;
@@ -1134,7 +1135,7 @@ describe("download manager", () => {
};
try {
const manager = new DownloadManager(
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
@@ -1144,10 +1145,9 @@ describe("download manager", () => {
autoReconnect: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "retry", links: ["https://dummy/retry"] }]);
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "retry", links: ["https://dummy/retry"] }]);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 25000);
@@ -7487,7 +7487,84 @@ describe("download manager", () => {
expect(snap.settings.providerDailyUsageBytes || {}).toEqual({});
});
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
it("resets extraction state atomically for selected package items", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "reset-extraction-package";
const createdAt = Date.now();
const itemIds = ["reset-a", "reset-b", "reset-c"];
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "reset-extraction",
outputDir: path.join(root, "downloads", "reset-extraction"),
extractDir: path.join(root, "extract", "reset-extraction"),
status: "extracting",
itemIds,
cancelled: false,
enabled: true,
postProcessLabel: "release.part1.rar",
downloadCompletedAt: createdAt,
createdAt,
updatedAt: createdAt
};
for (const itemId of itemIds) {
session.items[itemId] = {
id: itemId,
packageId,
url: `https://dummy/${itemId}`,
provider: "megadebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 1_000,
totalBytes: 1_000,
progressPercent: 100,
fileName: `${itemId}.part1.rar`,
targetPath: path.join(root, "downloads", "reset-extraction", `${itemId}.part1.rar`),
resumable: true,
attempts: 1,
lastError: "Unerwartetes Dateiende",
fullStatus: `Entpack-Fehler [${itemId}.part1.rar]: Unerwartetes Dateiende`,
onlineStatus: "online",
createdAt,
updatedAt: createdAt
};
}
const manager = new DownloadManager(
{
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true
},
session,
createStoragePaths(path.join(root, "state"))
);
manager.resetItems(itemIds);
const snapshot = manager.getSnapshot().session;
expect(snapshot.packages[packageId]).toEqual(expect.objectContaining({
status: "queued",
postProcessLabel: undefined,
downloadCompletedAt: 0
}));
for (const itemId of itemIds) {
expect(snapshot.items[itemId]).toEqual(expect.objectContaining({
status: "queued",
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
lastError: "",
fullStatus: "Wartet",
onlineStatus: undefined
}));
}
});
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
@@ -8004,7 +8081,7 @@ describe("download manager", () => {
}
}, 25000);
it("creates extract directory only at extraction and marks items as Entpackt", async () => {
it("creates extract directory only at extraction and marks items as Entpackt", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
@@ -8069,9 +8146,14 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "zip-pack", links: ["https://dummy/archive"] }]);
const pkgId = manager.getSnapshot().session.packageOrder[0];
const extractDir = manager.getSnapshot().session.packages[pkgId]?.extractDir || "";
manager.addPackages([{ name: "zip-pack", links: ["https://dummy/archive"] }]);
const pkgId = manager.getSnapshot().session.packageOrder[0];
const completedPostProcessLabels: Array<string | undefined> = [];
manager.on("state", (state) => {
const emittedPackage = state.session.packages[pkgId];
if (emittedPackage?.status === "completed") completedPostProcessLabels.push(emittedPackage.postProcessLabel);
});
const extractDir = manager.getSnapshot().session.packages[pkgId]?.extractDir || "";
expect(extractDir).toBeTruthy();
expect(fs.existsSync(extractDir)).toBe(false);
@@ -8079,12 +8161,18 @@ describe("download manager", () => {
await new Promise((resolve) => setTimeout(resolve, 140));
expect(fs.existsSync(extractDir)).toBe(false);
await waitFor(() => fs.existsSync(path.join(extractDir, "inside.txt")), 30000);
const snapshot = manager.getSnapshot();
const item = Object.values(snapshot.session.items)[0];
expect(item?.status).toBe("completed");
expect(item?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
await waitFor(() => fs.existsSync(path.join(extractDir, "inside.txt")), 30000);
await waitFor(() => {
const current = manager.getSnapshot().session.packages[pkgId];
return current?.status === "completed" && current.postProcessLabel === undefined;
}, 30000);
const snapshot = manager.getSnapshot();
const item = Object.values(snapshot.session.items)[0];
expect(item?.status).toBe("completed");
expect(item?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
expect(snapshot.session.packages[pkgId]?.postProcessLabel).toBeUndefined();
expect(completedPostProcessLabels.every((label) => label === undefined)).toBe(true);
expect(fs.existsSync(extractDir)).toBe(true);
expect(fs.existsSync(path.join(extractDir, "inside.txt"))).toBe(true);
} finally {
@@ -8093,7 +8181,7 @@ describe("download manager", () => {
}
}, 35000);
it("keeps accurate summary when completed items are cleaned immediately", async () => {
it("keeps accurate summary when completed items are cleaned immediately", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const binary = Buffer.alloc(128 * 1024, 3);
@@ -8167,7 +8255,244 @@ describe("download manager", () => {
server.close();
await once(server, "close");
}
});
});
it("preserves completed package progress when immediate cleanup removes a finished item", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "cleanup-progress-package";
const completedItemId = "cleanup-progress-completed";
const queuedItemId = "cleanup-progress-queued";
const createdAt = Date.now();
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "cleanup-progress",
outputDir: path.join(root, "downloads", "cleanup-progress"),
extractDir: path.join(root, "extract", "cleanup-progress"),
status: "downloading",
itemIds: [completedItemId, queuedItemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[completedItemId] = {
id: completedItemId,
packageId,
url: "https://dummy/completed",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 1_000,
totalBytes: 1_000,
progressPercent: 100,
fileName: "completed.rar",
targetPath: path.join(root, "downloads", "cleanup-progress", "completed.rar"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Entpackt - Fertig",
createdAt,
updatedAt: createdAt
};
session.items[queuedItemId] = {
...session.items[completedItemId],
id: queuedItemId,
url: "https://dummy/queued",
status: "queued",
downloadedBytes: 0,
progressPercent: 0,
fileName: "queued.rar",
targetPath: path.join(root, "downloads", "cleanup-progress", "queued.rar"),
fullStatus: "Wartet"
};
const manager = new DownloadManager(
{
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true,
completedCleanupPolicy: "immediate"
},
session,
createStoragePaths(path.join(root, "state"))
);
(manager as any).applyCompletedCleanupPolicy(packageId, completedItemId);
(manager as any).applyCompletedCleanupPolicy(packageId, completedItemId);
const packageEntry = manager.getSnapshot().session.packages[packageId];
expect(packageEntry.itemIds).toEqual([queuedItemId]);
expect(packageEntry.cleanedCompletedItemCount).toBe(1);
expect(packageEntry.cleanedExtractedItemCount).toBe(1);
expect(packageEntry.cleanedDownloadedBytes).toBe(1_000);
expect(packageEntry.cleanedTotalBytes).toBe(1_000);
});
it("includes immediately cleaned items in the final package history entry", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "cleanup-history-package";
const firstItemId = "cleanup-history-first";
const secondItemId = "cleanup-history-second";
const createdAt = Date.now() - 5_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "cleanup-history",
outputDir: path.join(root, "downloads", "cleanup-history"),
extractDir: path.join(root, "extract", "cleanup-history"),
status: "downloading",
itemIds: [firstItemId, secondItemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[firstItemId] = {
id: firstItemId,
packageId,
url: "https://dummy/first",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 1_000,
totalBytes: 1_000,
progressPercent: 100,
fileName: "first.rar",
targetPath: path.join(root, "downloads", "cleanup-history", "first.rar"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Entpackt - Fertig",
createdAt,
updatedAt: createdAt
};
session.items[secondItemId] = {
...session.items[firstItemId],
id: secondItemId,
url: "https://dummy/second",
provider: "megadebrid-api",
status: "queued",
downloadedBytes: 2_000,
totalBytes: 2_000,
fileName: "second.rar",
targetPath: path.join(root, "downloads", "cleanup-history", "second.rar"),
fullStatus: "Wartet"
};
const history: HistoryEntry[] = [];
const manager = new DownloadManager(
{
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true,
completedCleanupPolicy: "immediate"
},
session,
createStoragePaths(path.join(root, "state")),
{ onHistoryEntry: (entry) => history.push(entry) }
);
(manager as any).applyCompletedCleanupPolicy(packageId, firstItemId);
const pkg = (manager as any).session.packages[packageId];
(manager as any).session.items[secondItemId].status = "completed";
(manager as any).session.items[secondItemId].fullStatus = "Entpackt - Fertig";
pkg.status = "completed";
(manager as any).recordPackageHistory(packageId, pkg, [(manager as any).session.items[secondItemId]]);
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({
totalBytes: 3_000,
downloadedBytes: 3_000,
fileCount: 2,
provider: null,
urls: ["https://dummy/first", "https://dummy/second"]
});
history.length = 0;
(manager as any).historyRecordedPackages.delete(packageId);
(manager as any).removePackageFromSession(packageId, [secondItemId], "deleted");
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({
totalBytes: 3_000,
downloadedBytes: 3_000,
fileCount: 2,
provider: null,
status: "deleted",
urls: ["https://dummy/first", "https://dummy/second"]
});
});
it("waits for aborted package post-processing before restarting reset items", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "reset-race-package";
const itemId = "reset-race-item";
const createdAt = Date.now() - 5_000;
session.running = true;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "reset-race",
outputDir: path.join(root, "downloads", "reset-race"),
extractDir: path.join(root, "extract", "reset-race"),
status: "failed",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/reset-race",
provider: "realdebrid",
status: "failed",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: 1_000,
progressPercent: 0,
fileName: "reset-race.rar",
targetPath: "",
resumable: true,
attempts: 1,
lastError: "extract failed",
fullStatus: "Entpack-Fehler",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
let releaseTask = (): void => {};
const task = new Promise<void>((resolve) => { releaseTask = resolve; });
let releaseHybridTask = (): void => {};
const hybridTask = new Promise<void>((resolve) => { releaseHybridTask = resolve; });
const internal = manager as any;
internal.session.running = true;
internal.packagePostProcessTasks.set(packageId, task);
internal.packagePostProcessAbortControllers.set(packageId, new AbortController());
internal.packageHybridPostProcessTasks.set(packageId, new Set([hybridTask]));
internal.packageHybridPostProcessControllers.set(packageId, new Set([new AbortController()]));
internal.ensureScheduler = vi.fn(async () => {});
const resetPromise = Promise.resolve(manager.resetItems([itemId]));
await Promise.resolve();
expect(internal.ensureScheduler).not.toHaveBeenCalled();
releaseTask();
await Promise.resolve();
expect(internal.ensureScheduler).not.toHaveBeenCalled();
releaseHybridTask();
await resetPromise;
expect(internal.ensureScheduler).toHaveBeenCalledTimes(1);
});
it("removes finished package when package_done cleanup policy is enabled", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
@@ -9772,15 +10097,17 @@ describe("download manager", () => {
const createdAt = Date.now() - 20_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Deferred Reset",
outputDir: sharedDir,
extractDir: sharedDir,
status: "completed",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
id: packageId,
name: "Deferred Reset",
outputDir: sharedDir,
extractDir: sharedDir,
status: "completed",
itemIds: [itemId],
cancelled: false,
enabled: true,
downloadStartedAt: createdAt,
downloadCompletedAt: createdAt + 10_000,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
@@ -9841,14 +10168,17 @@ describe("download manager", () => {
);
await waitFor(() => renameStarted, 4000);
manager.resetPackage(packageId);
releaseRename();
await deferredPromise;
const resetPromise = manager.resetPackage(packageId);
releaseRename();
await deferredPromise;
await resetPromise;
expect(cleanupRemainingArchiveArtifacts).not.toHaveBeenCalled();
const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
expect(snapshot.session.items[itemId]?.status).toBe("queued");
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
expect(snapshot.session.packages[packageId]?.downloadStartedAt).toBe(0);
expect(snapshot.session.packages[packageId]?.downloadCompletedAt).toBe(0);
expect(snapshot.session.items[itemId]?.status).toBe("queued");
});
it("does not let cancelled cleanup delete archives for a re-added package in the same folder", async () => {
+121 -34
View File
@@ -3,7 +3,7 @@ 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";
import { describe, expect, it, vi } from "vitest";
import type { DownloadItem, DownloadStatus, PackageEntry } from "../src/shared/types";
import {
buildDownloadSidebarCounts,
@@ -32,7 +32,9 @@ import {
arePackageCardPropsEqual,
compactDownloadStatus,
downloadColumnDefinitions,
getAvailabilitySummary
getAvailabilitySummary,
getPackageProgress,
getPackageSizeProgress
} from "../src/renderer/views/downloads/DownloadsTable";
import { compactDownloadServiceLabel, normalizeDownloadServiceLabel } from "../src/renderer/download-format";
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
@@ -124,6 +126,23 @@ describe("Download-Gesamtgröße", () => {
expect(getDownloadQueueTotalBytes(items)).toBe(4_750);
});
it("preserves completed package bytes and progress after immediate cleanup", () => {
const active = item("active", "package-a", "downloading", {
downloadedBytes: 500,
totalBytes: 1_000,
progressPercent: 50
});
const packageEntry = pkg("package-a", "Serie", ["active"]);
packageEntry.cleanedCompletedItemCount = 2;
packageEntry.cleanedExtractedItemCount = 2;
packageEntry.cleanedDownloadedBytes = 2_000;
packageEntry.cleanedTotalBytes = 2_000;
const row = { package: packageEntry, items: [active], allItems: [active], collapsed: true };
expect(getPackageSizeProgress(row)).toEqual({ downloaded: 2_500, total: 3_000, value: 83 });
expect(getPackageProgress(row)).toEqual(expect.objectContaining({ done: 2, total: 3, value: 83 }));
});
});
describe("laufender Queue-Linkzähler", () => {
@@ -156,15 +175,19 @@ describe("responsive Downloadstatus und Servicebezeichnungen", () => {
expect(compactDownloadStatus("Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken - 1%");
expect(compactDownloadStatus("0/11 · Entpacken 53% (1/1) · scn2-httpv7-S01E102.rar")).toBe("Entpacken - 53%");
expect(compactDownloadStatus("Extracting 53% (1/1) · archive.rar")).toBe("Extracting - 53%");
expect(compactDownloadStatus("Passwort gefunden · archive.part1.rar")).toBe("Passwort gefunden");
expect(compactDownloadStatus("Entpacken - Ausstehend · archive.part1.rar")).toBe("Entpacken - Ausstehend");
expect(compactDownloadStatus("Entpack-Fehler [archive.part1.rar]: Unerwartetes Dateiende")).toBe("Entpack-Fehler");
expect(compactDownloadStatus("Extraction error [archive.part1.rar]: Unexpected end of file")).toBe("Extraction error");
});
it("removes duplicated access-mode wording from service labels", () => {
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid Web");
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid API");
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Access)")).toBe("Mega-Debrid API");
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid (Web)");
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid (API)");
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Access)")).toBe("Mega-Debrid (API)");
expect(normalizeDownloadServiceLabel("Real-Debrid (Web Account)")).toBe("Real-Debrid (Web Account)");
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid Web, Mega-Debrid API");
expect(compactDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid");
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid (Web), Mega-Debrid (API)");
expect(compactDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid (Web), Mega-Debrid (API)");
});
});
@@ -218,9 +241,6 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
onClearAll: () => {},
onToggleAllPackages: () => {},
onShowAllPackages: () => {},
onPackageDragStart: () => {},
onPackageDrop: () => {},
onPackageDragEnd: () => {},
onSetVisibleSelection: () => {},
onToggleSelection: () => {},
onSelectionMouseDown: () => {},
@@ -468,10 +488,12 @@ describe("downloads view", () => {
it("shows only the package mode while the file mode remains hidden", () => {
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(html).toContain("Pakete");
expect(html).not.toContain(">Dateien<");
expect(html).not.toContain("downloads-mode-switch");
expect(css).toMatch(/\.downloads-mode-title\s*\{[^}]*justify-content:\s*center;[^}]*color:\s*#0a0f1a;[^}]*background:\s*#90cdf4;[^}]*text-align:\s*center;/s);
});
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
@@ -518,24 +540,24 @@ describe("downloads view", () => {
expect(toolbar).not.toContain("downloads-search-input");
});
it("forwards package drag lifecycle callbacks through the extracted downloads content", () => {
const calls: string[] = [];
const actions = createActions() as DownloadsViewActions & {
onPackageDragStart: (packageId: string) => void;
onPackageDrop: (packageId: string) => void;
onPackageDragEnd: () => void;
};
actions.onPackageDragStart = (packageId) => calls.push(`start:${packageId}`);
actions.onPackageDrop = (packageId) => calls.push(`drop:${packageId}`);
actions.onPackageDragEnd = () => calls.push("end");
const content = DownloadsContent({ actions, model: withRuntime(createInput()) });
const packageElement = findElement(content, (element) => element.props.row?.package.id === "package-a");
it("blocks native package dragging while preserving explicit reorder actions", () => {
const model = withRuntime(createInput());
const component = PackageCardContent({
actions: createActions(),
columnOrder: model.columnOrder,
editing: false,
editingName: "",
gridTemplate: model.gridTemplate,
packageSpeedBps: 0,
row: model.packageRows[0],
selectedIds: new Set<string>(),
selectedVersion: 0
});
const preventDefault = vi.fn();
packageElement.props.onDragStart("package-a");
packageElement.props.onDrop("package-b");
packageElement.props.onDragEnd();
expect(calls).toEqual(["start:package-a", "drop:package-b", "end"]);
expect(component.props.draggable).toBeUndefined();
component.props.onDragStart({ preventDefault });
expect(preventDefault).toHaveBeenCalledOnce();
});
it("starts with the local Start action and dispatches toolbar actions separately", () => {
@@ -670,6 +692,7 @@ describe("downloads view", () => {
expect(css).toMatch(/\.downloads-sidebar,\s*\.downloads-sidebar-status,\s*\.downloads-toolbar,\s*\.downloads-content,\s*\.downloads-footer\s*\{[^}]*user-select:\s*none;/s);
expect(css).toMatch(/\.downloads-copyable,\s*\.downloads-search-input,\s*\.downloads-rename-input\s*\{[^}]*user-select:\s*text;/s);
expect(css).toMatch(/\.downloads-name-cell\s+\.downloads-rename-input\s*\{[^}]*flex:\s*1 1 auto;[^}]*width:\s*100%;[^}]*min-width:\s*0;/s);
});
it("marks selected rows clearly, enlarges selection checkboxes and slows package disclosure", () => {
@@ -740,6 +763,30 @@ describe("download table row contracts", () => {
])).toEqual({ online: 0, total: 1, state: "checking" });
});
it("shows reset package availability as one compact unchecked label", () => {
const resetItems = [
item("reset-a", "package-a", "queued", { onlineStatus: undefined }),
item("reset-b", "package-a", "queued", { onlineStatus: undefined }),
item("reset-c", "package-a", "queued", { onlineStatus: undefined })
];
const html = renderToStaticMarkup(PackageCardContent({
actions: createActions(),
columnOrder: ["availability"],
editing: false,
editingName: "",
gridTemplate: "150px",
packageSpeedBps: 0,
row: { package: pkg("package-a", "Reset", resetItems.map((entry) => entry.id)), items: resetItems, allItems: resetItems, collapsed: true },
selectedIds: new Set<string>(),
selectedVersion: 0
}));
expect(html).toContain(">Ungeprüft</span>");
expect(html).not.toContain(">0</span>");
expect(html).not.toContain(">3</span>");
expect(html).not.toContain(">online</span>");
});
it("renders availability for package and file rows", () => {
const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" });
const packageHtml = renderToStaticMarkup(PackageCardContent({
@@ -749,7 +796,7 @@ describe("download table row contracts", () => {
editingName: "",
gridTemplate: "110px",
packageSpeedBps: 0,
row: { package: pkg("package-a", "Paket", [onlineItem.id]), items: [onlineItem], collapsed: true },
row: { package: pkg("package-a", "Paket", [onlineItem.id]), items: [onlineItem], allItems: [onlineItem], collapsed: true },
selectedIds: new Set<string>(),
selectedVersion: 0
}));
@@ -795,7 +842,7 @@ describe("download table row contracts", () => {
editingName: "",
gridTemplate: "80px",
packageSpeedBps: 0,
row: { package: extractionPackage, items: [extractionItem], collapsed: true },
row: { package: extractionPackage, items: [extractionItem], allItems: [extractionItem], collapsed: true },
selectedIds: new Set<string>(),
selectedVersion: 0
}));
@@ -803,6 +850,29 @@ describe("download table row contracts", () => {
expect(html).toContain(">70%</b>");
});
it("never exposes archive filenames as the visible package status", () => {
const extractionItem = item("archive-item", "archive-package", "completed", { fullStatus: "Entpacken - Ausstehend" });
const extractionPackage = {
...pkg("archive-package", "Archiv", [extractionItem.id]),
status: "extracting",
postProcessLabel: "release.part1.rar"
} as PackageEntry;
const html = renderToStaticMarkup(PackageCardContent({
actions: createActions(),
columnOrder: ["status"],
editing: false,
editingName: "",
gridTemplate: "220px",
packageSpeedBps: 0,
row: { package: extractionPackage, items: [extractionItem], allItems: [extractionItem], collapsed: true },
selectedIds: new Set<string>(),
selectedVersion: 0
}));
expect(html).toContain(">Entpacken - Ausstehend</span>");
expect(html).not.toContain(">release.part1.rar</span>");
});
it("renders meter text in clipped track and fill layers", () => {
const html = renderToStaticMarkup(ItemRowContent({
actions: createActions(),
@@ -836,8 +906,8 @@ describe("download table row contracts", () => {
expect(html.match(/>Download läuft<\/span>/g)).toHaveLength(2);
expect(html).toContain('title="Download läuft (Mega-Debrid)"');
expect(html).toContain('title="Mega-Debrid Web (Web Account)"');
expect(html).toContain('class="downloads-service-full">Mega-Debrid Web</span>');
expect(html).toContain('class="downloads-service-compact">Mega-Debrid</span>');
expect(html).toContain('class="downloads-service-full">Mega-Debrid (Web)</span>');
expect(html).toContain('class="downloads-service-compact">Mega-Debrid (Web)</span>');
});
it("sets the whole visible selection atomically from the header checkbox", () => {
@@ -964,12 +1034,29 @@ describe("download table row contracts", () => {
editingName: "",
gridTemplate: "220px",
packageSpeedBps: 0,
row: { package: audioPackage, items: [item("audio-item", audioPackage.id, "queued")], collapsed: true },
row: { package: audioPackage, items: [item("audio-item", audioPackage.id, "queued")], allItems: [item("audio-item", audioPackage.id, "queued")], collapsed: true },
selectedIds: new Set<string>(),
selectedVersion: 0
}));
expect(html).toMatch(/title="0\/1 · Entpacken 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
expect(html).toMatch(/title="0\/1 · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
});
it("shows only a compact extraction error while retaining diagnostics in the tooltip", () => {
const html = renderToStaticMarkup(ItemRowContent({
actions: createActions(),
columnOrder: ["status"],
gridTemplate: "220px",
item: item("extract-error", "package-a", "failed", {
fullStatus: "Entpack-Fehler [release.part1.rar]: Unerwartetes Dateiende",
lastError: "Mega-Debrid API: Kein Server verfügbar"
}),
selected: false
}));
expect(html.match(/>Entpack-Fehler<\/span>/g)).toHaveLength(2);
expect(html).toContain('title="Entpack-Fehler [release.part1.rar]: Unerwartetes Dateiende');
expect(html).toContain('Mega-Debrid API: Kein Server verfügbar');
});
it("shows only the operation in an actively downloading package status", () => {
@@ -981,7 +1068,7 @@ describe("download table row contracts", () => {
editingName: "",
gridTemplate: "220px",
packageSpeedBps: 1_000,
row: { package: activePackage, items: [item("active-item", activePackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid API)" })], collapsed: true },
row: { package: activePackage, items: [item("active-item", activePackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid API)" })], allItems: [item("active-item", activePackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid API)" })], collapsed: true },
selectedIds: new Set<string>(),
selectedVersion: 0
}));
+85 -16
View File
@@ -22,9 +22,11 @@ import {
buildTargetedAccountCheck,
filterAccountAddOptions,
getSettingsSaveLabel,
getSettingsSelectNavigationIndex,
projectAccountRows,
pruneAccountSelection,
reconcileAccountAddDraft,
resolveHistoryRetentionSelection,
sortAccountRows,
type AccountAddOption,
type AccountRowSource,
@@ -53,6 +55,10 @@ const accountWorkspaceSource = readFileSync(
new URL("../src/renderer/views/settings/AccountWorkspace.tsx", import.meta.url),
"utf8"
);
const settingsCss = readFileSync(
new URL("../src/renderer/views/settings/settings.css", import.meta.url),
"utf8"
);
function sourceBlock(source: string, start: string, end: string): string {
return source.slice(source.indexOf(start), source.indexOf(end, source.indexOf(start)));
@@ -105,7 +111,7 @@ function accountSources(): AccountRowSource[] {
},
dailyLimitBytes: 10 * GIB,
dailyUsageBytes: 4 * GIB,
username: "stored@example.test",
username: "stored-user",
credentialKind: "password",
canCheck: true
},
@@ -310,7 +316,8 @@ describe("settings model", () => {
const rows = projectAccountRows(accountSources(), [], NOW);
expect(rows.map((row) => row.id)).toEqual(accountSources().map((source) => buildAccountRowId(source.service, source.mode, source.identityId)));
expect(rows[0].username).toBe("verified@example.test");
expect(rows[0].username).toBe("stored-user");
expect(rows[0].email).toBe("verified@example.test");
expect(rows[0].credential).toBe("••••••");
expect(rows[1].credential).toBe("API-Key");
expect(rows.map((row) => row.status.tone)).toEqual(["ok", "free", "invalid", "unknown", "disabled"]);
@@ -420,7 +427,7 @@ describe("settings views", () => {
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
});
it("offers English and German as a live language setting", () => {
it("offers animated language and bounded history retention choices", () => {
const form = buildSettingsFormViewModel({
settings: defaultSettings(),
section: "allgemein",
@@ -439,6 +446,53 @@ describe("settings views", () => {
{ value: "de", label: "Deutsch" }
]
});
const historyRetention = form.groups.flatMap((group) => group.fields).find((field) => field.id === "historyRetentionMode");
expect(historyRetention).toEqual({
id: "historyRetentionMode",
kind: "select",
label: "Verlauf speichern",
value: "permanent",
options: [
{ value: "never", label: "Nie" },
{ value: "session", label: "Nur aktuelle Session" },
{ value: "permanent-100", label: "Nur letzte 100 Einträge" },
{ value: "permanent-250", label: "Nur letzte 250 Einträge" },
{ value: "permanent", label: "Dauerhaft" }
]
});
const html = renderToStaticMarkup(<SettingsForm actions={{ onAction: () => {}, onChange: () => {} }} model={form} />);
expect(html).toContain("class=\"settings-select\"");
expect(html).toContain("role=\"combobox\"");
expect(html).toContain("role=\"listbox\"");
expect(settingsCss).toMatch(/\.settings-select-options\s*\{[^}]*opacity:\s*0[^}]*transform:\s*translateY\(-6px\)[^}]*transition:/s);
expect(settingsCss).toMatch(/\.settings-select\.is-open\s+\.settings-select-options\s*\{[^}]*opacity:\s*1[^}]*transform:\s*translateY\(0\)/s);
});
it("supports keyboard navigation in animated settings selects", () => {
expect(getSettingsSelectNavigationIndex(1, 3, "ArrowDown")).toBe(2);
expect(getSettingsSelectNavigationIndex(2, 3, "ArrowDown")).toBe(0);
expect(getSettingsSelectNavigationIndex(0, 3, "ArrowUp")).toBe(2);
expect(getSettingsSelectNavigationIndex(1, 3, "Home")).toBe(0);
expect(getSettingsSelectNavigationIndex(1, 3, "End")).toBe(2);
const source = readFileSync(new URL("../src/renderer/views/settings/SettingsForm.tsx", import.meta.url), "utf8");
expect(source).toContain("optionRefs.current[nextIndex]?.focus()");
expect(source).toContain('event.key === "Home"');
expect(source).toContain('event.key === "End"');
expect(source).toContain("onBlur={onBlur}");
});
it("clears a bounded history preset when permanent retention is selected", () => {
expect(resolveHistoryRetentionSelection("permanent", 100, "permanent")).toEqual({
historyRetentionMode: "permanent",
historyMaxEntries: 500
});
expect(resolveHistoryRetentionSelection("permanent", 250, "permanent-100")).toEqual({
historyRetentionMode: "permanent",
historyMaxEntries: 100
});
});
it("renders one real sidebar marker and all sections", () => {
@@ -610,19 +664,22 @@ describe("account workspace", () => {
expect(addHtml).toContain("Account hinzufügen");
expect(addHtml).toContain("Prüfen und speichern");
expect(count(addHtml, "<select")).toBe(1);
expect(addHtml).toContain('aria-label="Dienst / Zugangstyp"');
expect(count(addHtml, "<option")).toBe(options.length);
options.forEach((option) => expect(addHtml).toContain(`value="${option.id}"`));
expect(addHtml).toContain('<option value="megadebrid-api" selected="">Mega-Debrid · API</option>');
expect(count(addHtml, "<select")).toBe(0);
expect(addHtml).toContain('aria-label="Dienst oder Zugangstyp suchen"');
expect(addHtml).toContain('role="listbox"');
expect(addHtml).toContain('class="settings-account-picker-header"');
expect(addHtml).toContain("Dienst");
expect(addHtml).toContain("Typ/Funktion");
options.forEach((option) => expect(addHtml).toContain(`data-account-option-id="${option.id}"`));
expect(addHtml).toContain('data-account-option-id="megadebrid-api"');
expect(addHtml).toContain('aria-selected="true"');
expect(addHtml).toContain("Weiteren Account hinzufügen");
expect(addHtml).toContain("Login:Passwort");
expect(addHtml).not.toContain('type="search"');
expect(addHtml).not.toContain("Account-Typ filtern");
expect(addHtml).not.toContain("settings-account-picker-row");
expect(addHtml).toContain('type="search"');
expect(addHtml).toContain("settings-account-picker-row");
expect(count(addHtml, 'class="settings-account-dialog-fields"')).toBe(1);
expect(addHtml.indexOf('aria-label="Dienst / Zugangstyp"')).toBeLessThan(addHtml.indexOf("settings-account-option-meta"));
expect(addHtml.indexOf("settings-account-option-meta")).toBeLessThan(addHtml.indexOf("settings-account-dialog-fields"));
expect(addHtml.indexOf('aria-label="Dienst oder Zugangstyp suchen"')).toBeLessThan(addHtml.indexOf("settings-account-picker-table"));
expect(addHtml.indexOf("settings-account-picker-table")).toBeLessThan(addHtml.indexOf("settings-account-dialog-fields"));
expect(editHtml).toContain("Account bearbeiten");
expect(editHtml).toContain("member@example.test");
expect(editHtml).toContain("Entfernen");
@@ -631,7 +688,7 @@ describe("account workspace", () => {
expect(count(editHtml, "type=\"password\"")).toBe(2);
});
it("selects the account option through the single service selector", () => {
it("selects the account option through the compact service table", () => {
const selected: string[] = [];
const tree = AccountAddDialog({
actions: {
@@ -653,12 +710,24 @@ describe("account workspace", () => {
busy: false
}
});
const selector = findElement(tree, (element) => element.type === "select" && element.props["aria-label"] === "Dienst / Zugangstyp");
const selector = findElement(tree, (element) => element.props["data-account-option-id"] === "debridlink-api");
selector.props.onChange({ target: { value: "debridlink-api" } });
selector.props.onClick();
expect(selected).toEqual(["debridlink-api"]);
});
it("keeps stored usernames separate from provider email addresses", () => {
const rows = projectAccountRows(accountSources(), [], NOW);
expect(rows[0].username).toBe("stored-user");
expect(rows[0].email).toBe("verified@example.test");
expect(ACCOUNT_COLUMNS).toContain("E-Mail");
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
expect(html).toContain("stored-user");
expect(html).toContain("verified@example.test");
});
});
describe("settings App integration", () => {
+32 -2
View File
@@ -6,7 +6,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { AppSettings } from "../src/shared/types";
import { defaultSettings } from "../src/main/constants";
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
const tempDirs: string[] = [];
@@ -672,7 +672,37 @@ describe("settings storage", () => {
expect(loaded.packages["pkg1"].name).toBe("Test Package");
});
it("returns empty session when session file contains invalid JSON", () => {
it("preserves cleaned package progress aggregates while normalizing a session", () => {
const session = emptySession();
session.packageOrder = ["pkg-progress"];
session.packages["pkg-progress"] = {
id: "pkg-progress",
name: "Progress",
outputDir: "C:\\Downloads\\Progress",
extractDir: "C:\\Downloads\\Progress\\Extracted",
status: "downloading",
itemIds: [],
cancelled: false,
enabled: true,
cleanedCompletedItemCount: 3,
cleanedExtractedItemCount: 2,
cleanedDownloadedBytes: 3_000,
cleanedTotalBytes: 4_000,
createdAt: Date.now(),
updatedAt: Date.now()
};
const normalized = normalizeLoadedSession(session);
expect(normalized.packages["pkg-progress"]).toEqual(expect.objectContaining({
cleanedCompletedItemCount: 3,
cleanedExtractedItemCount: 2,
cleanedDownloadedBytes: 3_000,
cleanedTotalBytes: 4_000
}));
});
it("returns empty session when session file contains invalid JSON", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);