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:
+108
-95
@@ -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
@@ -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
@@ -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
|
||||
}));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user