Sort packages by availability and tidy availability-related displays

- Make the Verfügbarkeit column sortable from fully online through partial and unchecked down to fully offline, reversing on the second click
- Show partially available packages in the warning color as soon as one link is online and one is offline
- Switch the package order instantly on column sorts instead of sliding rows
- Keep offline links out of the package status error count
- Show queue volumes of one terabyte or more as grouped gigabytes with one decimal per language

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MxAH7rSv8MgfKEPwMkR8N2
This commit is contained in:
Sucukdeluxe
2026-09-04 00:19:37 +02:00
co-authored by Claude Fable 5.1
parent 65084bd6d2
commit 558d6ca4f4
11 changed files with 264 additions and 41 deletions
+31
View File
@@ -1,7 +1,38 @@
import { describe, expect, it } from "vitest";
import { sortPackageOrderByService } from "../src/renderer/App";
import { sortPackageOrderByAvailability } from "../src/renderer/package-order";
describe("download package sorting", () => {
it("sorts the Verfügbarkeit column from fully online through partial and unchecked down to fully offline", () => {
const packages = {
offline: { id: "offline", itemIds: ["offline-1", "offline-2"] },
partialLow: { id: "partialLow", itemIds: ["low-1", "low-2", "low-3", "low-4"] },
unchecked: { id: "unchecked", itemIds: ["unchecked-1"] },
online: { id: "online", itemIds: ["online-1", "online-2"] },
partialHigh: { id: "partialHigh", itemIds: ["high-1", "high-2", "high-3", "high-4"] },
empty: { id: "empty", itemIds: [] }
} as any;
const items = {
"offline-1": { status: "queued", onlineStatus: "offline" },
"offline-2": { status: "queued", onlineStatus: "offline" },
"low-1": { status: "queued", onlineStatus: "online" },
"low-2": { status: "queued", onlineStatus: "offline" },
"low-3": { status: "queued", onlineStatus: "offline" },
"low-4": { status: "queued", onlineStatus: "offline" },
"unchecked-1": { status: "queued", onlineStatus: undefined },
"online-1": { status: "queued", onlineStatus: "online" },
"online-2": { status: "completed", onlineStatus: undefined },
"high-1": { status: "queued", onlineStatus: "online" },
"high-2": { status: "queued", onlineStatus: "online" },
"high-3": { status: "queued", onlineStatus: "online" },
"high-4": { status: "queued", onlineStatus: "offline" }
} as any;
const order = ["offline", "partialLow", "unchecked", "online", "partialHigh", "empty"];
expect(sortPackageOrderByAvailability(order, packages, items, false)).toEqual(["online", "partialHigh", "partialLow", "unchecked", "empty", "offline"]);
expect(sortPackageOrderByAvailability(order, packages, items, true)).toEqual(["offline", "unchecked", "empty", "partialLow", "partialHigh", "online"]);
});
it("sorts the Service column by its visible provider labels", () => {
const packages = {
a: { id: "a", itemIds: ["item-a"] },
+119 -11
View File
@@ -37,7 +37,8 @@ import {
DOWNLOAD_ORDER_TRANSITION_DURATION_MS,
getDownloadOrderTransitionPinnedIds,
getDownloadOrderTransformKeyframes,
isDownloadPackageOrderChange
isDownloadPackageOrderChange,
shouldAnimateDownloadOrderChange
} from "../src/renderer/views/downloads/download-order-transition";
import {
DownloadsContent,
@@ -58,6 +59,7 @@ import {
arePackageCardPropsEqual,
compactDownloadStatus,
downloadColumnDefinitions,
compareAvailabilitySummaries,
getAvailabilitySummary,
getPackageProgress,
getPackageSizeProgress
@@ -281,11 +283,14 @@ describe("Download-ETA", () => {
});
describe("verbleibendes Downloadvolumen", () => {
it("uses TB precision that changes on every normal large-queue live tick", () => {
it("shows queues of one terabyte or more as grouped gigabytes with one decimal per language", () => {
const before = Math.round(1.125 * 1024 ** 4);
const after = before - 40 * 1024 ** 2;
expect(formatRemainingDownloadBytes({ bytes: before, unknownItems: 0 })).toBe("1.12500 TB");
expect(formatRemainingDownloadBytes({ bytes: after, unknownItems: 0 })).toBe("1.12496 TB");
const after = before - 120 * 1024 ** 2;
expect(formatRemainingDownloadBytes({ bytes: before, unknownItems: 0 }, "de")).toBe("1.152,0 GB");
expect(formatRemainingDownloadBytes({ bytes: after, unknownItems: 0 }, "de")).toBe("1.151,9 GB");
expect(formatRemainingDownloadBytes({ bytes: before, unknownItems: 0 }, "en")).toBe("1,152.0 GB");
expect(formatRemainingDownloadBytes({ bytes: Math.round(1449.5 * 1024 ** 3), unknownItems: 2 }, "de")).toBe("≥ 1.449,5 GB");
expect(formatRemainingDownloadBytes({ bytes: 998.5 * 1024 ** 3, unknownItems: 0 }, "de")).toBe("998.50 GB");
});
it("summiert nur offene bekannte Restbytes und klemmt überladene Fortschritte bei null", () => {
@@ -375,6 +380,12 @@ describe("virtualisierte Paketanimation", () => {
expect(css).toMatch(/\.downloads-table-body\.is-download-motion-disabled \.downloads-virtual-spacer,\s*\.downloads-table-body\.is-download-motion-disabled \.downloads-virtual-row\s*\{[^}]*transition:\s*none !important;/s);
});
it("switches instantly instead of animating when a column sort changed the package order", () => {
expect(shouldAnimateDownloadOrderChange({ animationsEnabled: true, sortRevision: 3, appliedSortRevision: 3 })).toBe(true);
expect(shouldAnimateDownloadOrderChange({ animationsEnabled: true, sortRevision: 4, appliedSortRevision: 3 })).toBe(false);
expect(shouldAnimateDownloadOrderChange({ animationsEnabled: false, sortRevision: 3, appliedSortRevision: 3 })).toBe(false);
});
it("pins only previously visible rows for a real priority reorder", () => {
expect(DOWNLOAD_ORDER_TRANSITION_DURATION_MS).toBe(3000);
expect(isDownloadPackageOrderChange(["a", "b", "c"], ["b", "c", "a"])).toBe(true);
@@ -1088,7 +1099,7 @@ describe("downloads model", () => {
expect(row.items.map((entry) => entry.id)).toEqual(["queued-item"]);
expect(row.allItems.map((entry) => entry.id)).toEqual(["extracted-item", "queued-item"]);
expect(getPackageProgress(row)).toEqual({ done: 1, failed: 0, cancelled: 0, total: 2, value: 50 });
expect(getPackageProgress(row)).toEqual({ done: 1, failed: 0, offline: 0, cancelled: 0, total: 2, value: 50 });
expect(getPackageSizeProgress(row)).toEqual({ downloaded: 100, total: 200, value: 50 });
});
@@ -1985,21 +1996,118 @@ describe("download table row contracts", () => {
expect(getAvailabilitySummary([
item("online-a", "package-a", "queued", { onlineStatus: "online" }),
item("online-b", "package-a", "queued", { onlineStatus: "online" })
])).toEqual({ online: 2, total: 2, state: "online" });
])).toEqual({ online: 2, offline: 0, total: 2, state: "online" });
expect(getAvailabilitySummary([
item("partial-a", "package-a", "queued", { onlineStatus: "online" }),
item("partial-b", "package-a", "queued", { onlineStatus: "offline" })
])).toEqual({ online: 1, total: 2, state: "partial" });
])).toEqual({ online: 1, offline: 1, total: 2, state: "partial" });
expect(getAvailabilitySummary([
item("offline-a", "package-a", "queued", { onlineStatus: "offline" }),
item("offline-b", "package-a", "queued", { onlineStatus: "offline" })
])).toEqual({ online: 0, total: 2, state: "offline" });
])).toEqual({ online: 0, offline: 2, total: 2, state: "offline" });
expect(getAvailabilitySummary([
item("unknown-a", "package-a", "queued", { onlineStatus: undefined })
])).toEqual({ online: 0, total: 1, state: "checking" });
])).toEqual({ online: 0, offline: 0, total: 1, state: "checking" });
expect(getAvailabilitySummary([
item("active-a", "package-a", "downloading", { onlineStatus: undefined })
])).toEqual({ online: 1, total: 1, state: "online" });
])).toEqual({ online: 1, offline: 0, total: 1, state: "online" });
});
it("treats a package with online and offline links as partial even while other links are still unchecked", () => {
expect(getAvailabilitySummary([
item("mixed-online", "package-a", "queued", { onlineStatus: "online" }),
item("mixed-offline", "package-a", "queued", { onlineStatus: "offline" }),
item("mixed-checking", "package-a", "queued", { onlineStatus: "checking" })
])).toEqual({ online: 1, offline: 1, total: 3, state: "partial" });
expect(getAvailabilitySummary([
item("pending-online", "package-a", "queued", { onlineStatus: "online" }),
item("pending-unknown", "package-a", "queued", { onlineStatus: undefined })
])).toEqual({ online: 1, offline: 0, total: 2, state: "checking" });
});
it("renders partially available packages with the warning tone", () => {
const items = [
item("partial-online-a", "package-a", "queued", { onlineStatus: "online" }),
item("partial-online-b", "package-a", "queued", { onlineStatus: "online" }),
item("partial-offline", "package-a", "queued", { onlineStatus: "offline" })
];
const html = renderToStaticMarkup(PackageCardContent({
actions: createActions(),
columnOrder: ["availability"],
editing: false,
editingName: "",
gridTemplate: "110px",
packageSpeedBps: 0,
row: { package: pkg("package-a", "Paket", items.map((entry) => entry.id)), items, allItems: items, collapsed: true },
selectedIds: new Set<string>(),
selectedVersion: 0
}));
expect(html).toContain("downloads-availability has-counts is-partial");
expect(html).toContain("2/3 online");
});
it("keeps offline links out of the package status error count", () => {
const renderStatus = (items: DownloadItem[]): string => renderToStaticMarkup(PackageCardContent({
actions: createActions(),
columnOrder: ["status"],
editing: false,
editingName: "",
gridTemplate: "210px",
packageSpeedBps: 0,
row: { package: pkg("package-a", "Paket", items.map((entry) => entry.id)), items, allItems: items, collapsed: true },
selectedIds: new Set<string>(),
selectedVersion: 0
}));
const offlineItems = [
item("offline-a", "package-a", "failed", { onlineStatus: "offline", fullStatus: "Offline" }),
item("offline-b", "package-a", "failed", { onlineStatus: "offline", fullStatus: "Offline" }),
item("queued-c", "package-a", "queued", { onlineStatus: "online" })
];
const failedItems = [
item("failed-a", "package-a", "failed", { fullStatus: "Fehler: Provider" }),
item("offline-b", "package-a", "failed", { onlineStatus: "offline", fullStatus: "Offline" })
];
expect(getPackageProgress({ package: pkg("package-a", "Paket", offlineItems.map((entry) => entry.id)), items: offlineItems, allItems: offlineItems, collapsed: true }))
.toEqual(expect.objectContaining({ done: 0, failed: 0, offline: 2, total: 3 }));
expect(renderStatus(offlineItems)).not.toContain("Fehler");
expect(renderStatus(offlineItems)).toContain("0/3");
expect(renderStatus(failedItems)).toContain("1 Fehler");
});
it("orders package availability from fully online through partial and unchecked down to fully offline", () => {
const summary = (online: number, offline: number, total: number) => ({ online, offline, total, state: "checking" as const });
const ordered = [
summary(0, 5, 5),
summary(6, 1, 7),
summary(0, 0, 4),
summary(25, 1, 26),
summary(0, 2, 6),
summary(3, 3, 3)
].sort(compareAvailabilitySummaries);
expect(ordered.map((entry) => `${entry.online}/${entry.total}:${entry.offline}`)).toEqual(["3/3:3", "25/26:1", "6/7:1", "0/4:0", "0/6:2", "0/5:5"]);
});
it("exposes Verfügbarkeit as a sortable column header", () => {
const sorted: string[] = [];
const header = DownloadsTableHeader({
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
columnOrder: ["availability"],
gridTemplate: "100px",
selectedCount: 0,
sortColumn: "availability",
sortDirection: "asc",
visibleIds: []
});
const availabilityHeader = findElement(header, (element) => element.props["data-download-column"] === "availability");
const sortButton = findElement(availabilityHeader, (element) => element.type === "button" && String(element.props.children).startsWith("Verfügbarkeit"));
sortButton.props.onClick({ detail: 0 });
expect(availabilityHeader.props["aria-sort"]).toBe("ascending");
expect(sorted).toEqual(["availability"]);
});
it("shows an actively downloading item as online even without a stored availability result", () => {