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:
Sucukdeluxe
2026-08-11 20:08:06 +02:00
parent a6e6d2d439
commit 2861fd95f7
14 changed files with 485 additions and 264 deletions
+15 -113
View File
@@ -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"]);
});
});