fix(downloads): stabilize queue and disk recovery
Keep package rows in their existing visible order across runtime state changes and remove obsolete automatic progress grouping. Require an active usable provider before starting or resuming downloads, recover temporary disk-write blocks without consuming normal retries, and surface disk waits and extraction failures at package level. Add focused regression coverage and prepare the v2.0.24 release metadata and changelog.
This commit is contained in:
+249
-22
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
@@ -41,6 +41,235 @@ describe("runWithLimitedConcurrency", () => {
|
||||
expect(completed.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disk write recovery", () => {
|
||||
it("classifies retryable disk write stalls without treating permission errors as temporary", () => {
|
||||
expect(getDiskWriteWaitReason(Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" }))).toMatch(/Festplatte voll/);
|
||||
expect(getDiskWriteWaitReason(new Error("write_drain_timeout"))).toMatch(/Festplatte/);
|
||||
expect(getDiskWriteWaitReason(Object.assign(new Error("write EACCES"), { code: "EACCES" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("parks a disk-full download for automatic retry without consuming its retry budget", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-recovery-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const packageId = "disk-recovery-package";
|
||||
const itemId = "disk-recovery-item";
|
||||
const outputDir = path.join(root, "downloads", "disk-recovery");
|
||||
const extractDir = path.join(root, "extract", "disk-recovery");
|
||||
const createdAt = Date.now();
|
||||
session.running = true;
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "disk-recovery",
|
||||
outputDir,
|
||||
extractDir,
|
||||
status: "downloading",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://rapidgator.net/file/disk-recovery",
|
||||
provider: "realdebrid",
|
||||
status: "downloading",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 1024,
|
||||
progressPercent: 0,
|
||||
fileName: "disk-recovery.bin",
|
||||
targetPath: "",
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "Download läuft",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), autoExtract: false },
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
|
||||
(manager as any).activeTasks.set(itemId, active);
|
||||
(manager as any).debridService.unrestrictLink = async () => ({
|
||||
fileName: "disk-recovery.bin",
|
||||
directUrl: "https://dummy/disk-recovery",
|
||||
fileSize: 1024,
|
||||
retriesUsed: 0,
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Real-Debrid"
|
||||
});
|
||||
(manager as any).downloadToFile = async () => {
|
||||
throw Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" });
|
||||
};
|
||||
|
||||
const before = Date.now();
|
||||
await (manager as any).processItem(active);
|
||||
|
||||
expect(session.items[itemId]).toEqual(expect.objectContaining({
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
fullStatus: "Warte auf Festplatte"
|
||||
}));
|
||||
expect((manager as any).retryAfterByItem.get(itemId)).toBeGreaterThan(before);
|
||||
expect(session.packages[packageId].status).toBe("queued");
|
||||
});
|
||||
|
||||
it("marks a fully downloaded package as failed when post-processing failed", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-status-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const packageId = "extract-status-package";
|
||||
const itemId = "extract-status-item";
|
||||
const createdAt = Date.now();
|
||||
const outputDir = path.join(root, "downloads");
|
||||
const targetPath = path.join(outputDir, "extract-status.part1.rar");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
fs.writeFileSync(targetPath, Buffer.alloc(1024, 1));
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "extract-status",
|
||||
outputDir,
|
||||
extractDir: path.join(root, "extract"),
|
||||
status: "completed",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://rapidgator.net/file/extract-status",
|
||||
provider: "realdebrid",
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 1024,
|
||||
totalBytes: 1024,
|
||||
progressPercent: 100,
|
||||
fileName: "extract-status.part1.rar",
|
||||
targetPath,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "Kein Speicherplatz",
|
||||
fullStatus: "Entpack-Fehler [extract-status.part1.rar]: Kein Speicherplatz",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
|
||||
|
||||
(manager as any).refreshPackageStatus(session.packages[packageId]);
|
||||
|
||||
expect(session.packages[packageId].status).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("download start account gate", () => {
|
||||
it("disables every start path when no usable account is active", async () => {
|
||||
const createManager = () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-start-account-gate-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
manager.addPackages([{ name: "account-gate", links: ["https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] }]);
|
||||
const snapshot = manager.getSnapshot();
|
||||
const packageId = snapshot.session.packageOrder[0];
|
||||
const itemId = snapshot.session.packages[packageId].itemIds[0];
|
||||
return { manager, packageId, itemId };
|
||||
};
|
||||
|
||||
const starts = [
|
||||
({ manager }: ReturnType<typeof createManager>) => manager.start(),
|
||||
({ manager, packageId }: ReturnType<typeof createManager>) => manager.startPackages([packageId]),
|
||||
({ manager, itemId }: ReturnType<typeof createManager>) => manager.startItems([itemId])
|
||||
];
|
||||
|
||||
for (const start of starts) {
|
||||
const context = createManager();
|
||||
expect(context.manager.getSnapshot().canStart).toBe(false);
|
||||
try {
|
||||
await expect(start(context)).rejects.toThrow("Kein aktiver Download-Account verfügbar");
|
||||
expect(context.manager.getSnapshot().session.running).toBe(false);
|
||||
} finally {
|
||||
context.manager.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("treats disabled providers and disabled Mega-Debrid accounts as unavailable", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-account-gate-"));
|
||||
tempDirs.push(root);
|
||||
const megaLogin = "disabled@example.test";
|
||||
const disabledMegaId = getMegaDebridAccountId(megaLogin);
|
||||
const disabledProviderManager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", disabledProviders: ["realdebrid"] },
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "provider"))
|
||||
);
|
||||
const disabledMegaManager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
megaDebridApiCredentials: `${megaLogin}:secret`,
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridApiDisabledAccountIds: [disabledMegaId],
|
||||
providerOrder: ["megadebrid-api"]
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "mega"))
|
||||
);
|
||||
|
||||
expect(disabledProviderManager.getSnapshot().canStart).toBe(false);
|
||||
expect(disabledMegaManager.getSnapshot().canStart).toBe(false);
|
||||
});
|
||||
|
||||
it("allows start when an active account is available", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-active-account-gate-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token" },
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
expect(manager.getSnapshot().canStart).toBe(true);
|
||||
|
||||
manager.setSettings({ ...defaultSettings(), outputDir: manager.getSettings().outputDir });
|
||||
|
||||
expect(manager.getSnapshot().canStart).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks resume when the last active account is unavailable", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-account-gate-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
defaultSettings(),
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
const session = (manager as any).session;
|
||||
session.running = true;
|
||||
session.paused = true;
|
||||
|
||||
expect(manager.getSnapshot().canStart).toBe(false);
|
||||
expect(() => manager.togglePause()).toThrow("Kein aktiver Download-Account verfügbar");
|
||||
expect(manager.getSnapshot().session.paused).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractArchiveNameFromExtractorLogMessage", () => {
|
||||
it("detects archive names from extractor log variants", () => {
|
||||
@@ -2762,7 +2991,7 @@ describe("download manager", () => {
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
it("fails fast when Debrid-Link has no active api key left", async () => {
|
||||
it("blocks start when Debrid-Link has no active api key left", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
||||
@@ -2793,21 +3022,16 @@ describe("download manager", () => {
|
||||
settings,
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
manager.addPackages([{ name: "debridlink-no-key", links: ["https://rapidgator.net/file/no-active-key.part1.rar.html"] }]);
|
||||
await manager.start();
|
||||
await waitFor(() => {
|
||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||
return Boolean(item && item.status === "failed");
|
||||
}, 12000);
|
||||
|
||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||
expect(item?.status).toBe("failed");
|
||||
expect(item?.fullStatus || "").toContain("Debrid-Link");
|
||||
expect(item?.retries).toBe(0);
|
||||
|
||||
await manager.stop();
|
||||
);
|
||||
|
||||
manager.addPackages([{ name: "debridlink-no-key", links: ["https://rapidgator.net/file/no-active-key.part1.rar.html"] }]);
|
||||
expect(manager.getSnapshot().canStart).toBe(false);
|
||||
await expect(manager.start()).rejects.toThrow("Kein aktiver Download-Account verfügbar");
|
||||
|
||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||
expect(manager.getSnapshot().session.running).toBe(false);
|
||||
expect(item?.status).toBe("queued");
|
||||
expect(item?.retries).toBe(0);
|
||||
});
|
||||
|
||||
it("recovers from repeated resume underflow by restarting from zero", async () => {
|
||||
@@ -12582,7 +12806,7 @@ describe("download manager", () => {
|
||||
expect(internal.settings.debridLinkApiKeyTotalUsageBytes[secondKey.id]).toBe(2048);
|
||||
});
|
||||
|
||||
it("does not hang when rapid stop, disable provider, start", async () => {
|
||||
it("does not hang when rapid stop is followed by disabling the last provider", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const binary = Buffer.alloc(256 * 1024, 7);
|
||||
@@ -12665,10 +12889,13 @@ describe("download manager", () => {
|
||||
disabledProviders: ["realdebrid"]
|
||||
});
|
||||
|
||||
const startPromise = manager.start();
|
||||
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000));
|
||||
const result = await Promise.race([startPromise.then(() => "ok" as const), timeout]);
|
||||
expect(result).toBe("ok");
|
||||
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000));
|
||||
const result = await Promise.race([
|
||||
manager.start().then(() => "started" as const, (error) => String(error)),
|
||||
timeout
|
||||
]);
|
||||
expect(result).toContain("Kein aktiver Download-Account verfügbar");
|
||||
expect(manager.getSnapshot().session.running).toBe(false);
|
||||
} finally {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
|
||||
@@ -636,7 +636,7 @@ describe("downloads view", () => {
|
||||
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
|
||||
});
|
||||
|
||||
it("keeps start available for resume and pause independent from unrelated action busy state", () => {
|
||||
it("blocks resume without a usable account and keeps pause independent from unrelated action busy state", () => {
|
||||
const pausedToolbar = DownloadsToolbar({
|
||||
actions: createActions(),
|
||||
model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true })
|
||||
@@ -646,7 +646,7 @@ describe("downloads view", () => {
|
||||
model: withRuntime(createInput(), { paused: false, canPause: true, actionBusy: true })
|
||||
});
|
||||
|
||||
expect(findButton(pausedToolbar, "Start").props.disabled).toBe(false);
|
||||
expect(findButton(pausedToolbar, "Start").props.disabled).toBe(true);
|
||||
expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true);
|
||||
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(false);
|
||||
});
|
||||
@@ -1258,6 +1258,65 @@ describe("download table row contracts", () => {
|
||||
expect(html).toContain('Mega-Debrid API: Kein Server verfügbar');
|
||||
});
|
||||
|
||||
it("removes redundant service suffixes from runtime statuses", () => {
|
||||
expect(compactDownloadStatus("Starte... (Mega-Debrid Web)")).toBe("Starte...");
|
||||
expect(compactDownloadStatus("Warte auf Daten (Mega-Debrid Web)")).toBe("Warte auf Daten");
|
||||
expect(compactDownloadStatus("Warte auf Festplatte (Mega-Debrid Web)")).toBe("Warte auf Festplatte");
|
||||
});
|
||||
|
||||
it("prioritizes disk waits and extraction errors in package status", () => {
|
||||
const diskPackage = pkg("disk-package", "Disk package", ["disk-item", "active-item"]);
|
||||
const diskHtml = renderToStaticMarkup(PackageCardContent({
|
||||
actions: createActions(),
|
||||
columnOrder: ["status"],
|
||||
editing: false,
|
||||
editingName: "",
|
||||
gridTemplate: "220px",
|
||||
packageSpeedBps: 1_000,
|
||||
row: {
|
||||
package: diskPackage,
|
||||
items: [
|
||||
item("disk-item", diskPackage.id, "downloading", { fullStatus: "Warte auf Festplatte (Mega-Debrid Web)" }),
|
||||
item("active-item", diskPackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid Web)" })
|
||||
],
|
||||
allItems: [
|
||||
item("disk-item", diskPackage.id, "downloading", { fullStatus: "Warte auf Festplatte (Mega-Debrid Web)" }),
|
||||
item("active-item", diskPackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid Web)" })
|
||||
],
|
||||
collapsed: true
|
||||
},
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 0
|
||||
}));
|
||||
expect(diskHtml).toMatch(/>Warte auf Festplatte<\/span>/);
|
||||
|
||||
const errorPackage = { ...pkg("error-package", "Error package", ["error-a", "error-b"]), status: "completed" as const };
|
||||
const errorHtml = renderToStaticMarkup(PackageCardContent({
|
||||
actions: createActions(),
|
||||
columnOrder: ["status"],
|
||||
editing: false,
|
||||
editingName: "",
|
||||
gridTemplate: "220px",
|
||||
packageSpeedBps: 0,
|
||||
row: {
|
||||
package: errorPackage,
|
||||
items: [
|
||||
item("error-a", errorPackage.id, "completed", { fullStatus: "Entpack-Fehler [release.part1.rar]: Kein Speicherplatz" }),
|
||||
item("error-b", errorPackage.id, "completed", { fullStatus: "Entpackt - Done" })
|
||||
],
|
||||
allItems: [
|
||||
item("error-a", errorPackage.id, "completed", { fullStatus: "Entpack-Fehler [release.part1.rar]: Kein Speicherplatz" }),
|
||||
item("error-b", errorPackage.id, "completed", { fullStatus: "Entpackt - Done" })
|
||||
],
|
||||
collapsed: true
|
||||
},
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 0
|
||||
}));
|
||||
expect(errorHtml).toMatch(/>Entpack-Fehler<\/span>/);
|
||||
expect(errorHtml).not.toMatch(/>2\/2<\/span>/);
|
||||
});
|
||||
|
||||
it("shows only the operation in an actively downloading package status", () => {
|
||||
const activePackage = pkg("active-package", "Active package", ["active-item"]);
|
||||
const html = renderToStaticMarkup(PackageCardContent({
|
||||
|
||||
+15
-113
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DownloadItem, PackageEntry } from "../src/shared/types";
|
||||
import { sortPackagesForDisplay } from "../src/renderer/package-order";
|
||||
import type { PackageEntry } from "../src/shared/types";
|
||||
import { preservePackageOrderForDisplay } from "../src/renderer/package-order";
|
||||
|
||||
function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry {
|
||||
const now = Date.now();
|
||||
@@ -20,120 +20,22 @@ function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): Pa
|
||||
};
|
||||
}
|
||||
|
||||
function createItem(id: string, packageId: string, status: DownloadItem["status"], downloadedBytes: number): DownloadItem {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id,
|
||||
packageId,
|
||||
url: `https://hoster.example/${id}`,
|
||||
provider: null,
|
||||
status,
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes,
|
||||
totalBytes: downloadedBytes,
|
||||
progressPercent: downloadedBytes > 0 ? 50 : 0,
|
||||
fileName: `${id}.bin`,
|
||||
targetPath: "",
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
describe("sortPackagesForDisplay", () => {
|
||||
it("floats active packages to the top, keeping queue order within each group", () => {
|
||||
// pkg-a and pkg-b both have an active (downloading) item -> both float up in
|
||||
// their original queue order; pkg-c (queued only) sinks below.
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1", "a2"]),
|
||||
createPackage("pkg-c", ["c1"]),
|
||||
createPackage("pkg-b", ["b1", "b2"])
|
||||
];
|
||||
const items: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "downloading", 250),
|
||||
a2: createItem("a2", "pkg-a", "completed", 500),
|
||||
c1: createItem("c1", "pkg-c", "queued", 0),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 800),
|
||||
b2: createItem("b2", "pkg-b", "completed", 900)
|
||||
};
|
||||
|
||||
const sorted = sortPackagesForDisplay(packages, items, true, true);
|
||||
|
||||
// active group [pkg-a, pkg-b] in queue order, then rest [pkg-c]
|
||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
|
||||
});
|
||||
|
||||
it("does NOT reshuffle active packages when only their progress changes (anti-flicker)", () => {
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1"]),
|
||||
createPackage("pkg-b", ["b1"])
|
||||
];
|
||||
// Both active. pkg-b initially has more bytes than pkg-a.
|
||||
const before: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "downloading", 100),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 900)
|
||||
};
|
||||
const orderBefore = sortPackagesForDisplay(packages, before, true, true).map((p) => p.id);
|
||||
|
||||
// A progress tick: pkg-a overtakes pkg-b in bytes. Order must NOT change —
|
||||
// both are still active, so they keep queue order. (Old code swapped them.)
|
||||
const after: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "downloading", 5000),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 950)
|
||||
};
|
||||
const orderAfter = sortPackagesForDisplay(packages, after, true, true).map((p) => p.id);
|
||||
|
||||
expect(orderBefore).toEqual(["pkg-a", "pkg-b"]);
|
||||
expect(orderAfter).toEqual(orderBefore);
|
||||
});
|
||||
|
||||
it("keeps package order untouched when auto sort is disabled", () => {
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1"]),
|
||||
createPackage("pkg-b", ["b1"]),
|
||||
createPackage("pkg-c", ["c1"])
|
||||
];
|
||||
const items: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "queued", 0),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 500),
|
||||
c1: createItem("c1", "pkg-c", "queued", 0)
|
||||
};
|
||||
|
||||
const sorted = sortPackagesForDisplay(packages, items, true, false);
|
||||
|
||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
|
||||
describe("preservePackageOrderForDisplay", () => {
|
||||
it("keeps the exact queue order", () => {
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1", "a2"]),
|
||||
createPackage("pkg-c", ["c1"]),
|
||||
createPackage("pkg-b", ["b1", "b2"])
|
||||
];
|
||||
expect(preservePackageOrderForDisplay(packages).map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-c", "pkg-b"]);
|
||||
});
|
||||
|
||||
it("keeps every active package in activation order when a new package starts", () => {
|
||||
it("keeps the visible queue order stable across start and completion metadata", () => {
|
||||
const packages = [
|
||||
createPackage("pkg-new", ["new-item"], 200),
|
||||
createPackage("pkg-existing", ["existing-item"], 100)
|
||||
createPackage("pkg-first", ["first-item"], 100),
|
||||
createPackage("pkg-second", ["second-item"], 200),
|
||||
createPackage("pkg-third", ["third-item"], 300)
|
||||
];
|
||||
const items: Record<string, DownloadItem> = {
|
||||
"new-item": createItem("new-item", "pkg-new", "downloading", 100),
|
||||
"existing-item": createItem("existing-item", "pkg-existing", "downloading", 200)
|
||||
};
|
||||
const sorted = sortPackagesForDisplay(packages, items, true, true);
|
||||
|
||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-existing", "pkg-new"]);
|
||||
});
|
||||
|
||||
it("keeps queue order for active packages without a recorded start time", () => {
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1"]),
|
||||
createPackage("pkg-b", ["b1"]),
|
||||
createPackage("pkg-c", ["c1"])
|
||||
];
|
||||
const items: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "completed", 500),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 200),
|
||||
c1: createItem("c1", "pkg-c", "downloading", 100)
|
||||
};
|
||||
|
||||
expect(sortPackagesForDisplay(packages, items, true, true).map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-c", "pkg-a"]);
|
||||
expect(preservePackageOrderForDisplay(packages).map((pkg) => pkg.id)).toEqual(["pkg-first", "pkg-second", "pkg-third"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user