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
+12
View File
@@ -4,6 +4,18 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
## [Unreleased]
### Availability sorting
- Make the Availability column header sortable: the first click orders packages from fully online through partially available and unchecked down to fully offline, the second click reverses the order.
- Rank packages by their online share first and by their offline share second, so `25/26 online` sorts above `6/7 online` and unchecked packages sit between partial and fully offline ones.
- Show a package in the warning color as soon as at least one link is online and at least one is offline, even while other links are still unchecked; fully online stays green and fully offline stays red.
- Switch the package order instantly when a column sort changes it instead of sliding rows up and down; manual moves keep their animation.
- Keep offline links out of the package status error count, because the Availability column already shows them; genuine download failures still appear as errors.
### Sidebar totals
- Show remaining and total queue volume of one terabyte or more as grouped gigabytes with one decimal, such as `1.449,5 GB` in German and `1,449.5 GB` in English, instead of a five-decimal terabyte value.
## [2.0.86] - 2026-09-02
### Background availability refresh
+17 -7
View File
@@ -8,14 +8,13 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
## Zuletzt verifizierter Stand
- Verifiziert am: 2. September 2026, Europe/Berlin
- Verifiziert am: 3. September 2026, Europe/Berlin
- Lokaler Pfad: `C:\Users\Sascha\Desktop\Claude & ChatGPT Projekte\Multi-Debrid-Downloader`
- Arbeitsbranch: `release/v2.0.86`
- Quellbasis: `release/v2.0.85`
- Release-Tag: `v2.0.86`
- Baseline-Commit: `fd71caa341979fbc43307040b662131a8de5f9b1`
- Hotfix-Basis: `fd71caa341979fbc43307040b662131a8de5f9b1`
- Paketversion: `2.0.86`
- Arbeitsbranch: `release/v2.0.87` (in Arbeit, unveröffentlicht)
- Quellbasis: `release/v2.0.86`
- Letztes Release-Tag: `v2.0.86`
- Baseline-Commit: `65084bd6d2a5add6a1552316f63cf0b0b2c27080`
- Paketversion: `2.0.86` (Versionsanhebung auf `2.0.87` erfolgt erst mit dem Release-Vorbereitungscommit)
- Letztes Release: `Multi-Debrid-Downloader v2.0.86`, veröffentlicht am 2. September 2026 auf GitHub und Forgejo
- Runtime-Voraussetzung: Node.js `>=20`; lokal verifiziert mit Node.js `24.19.0` und npm `11.17.0`
@@ -220,6 +219,17 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
- Die Verfügbarkeit eines Oberpakets wird kompakt als `0/8 online` dargestellt. Das zusätzliche Statussymbol vor dem Zähler und der dadurch entstandene Abstand entfallen; die Verfügbarkeitsanzeige einzelner Dateien bleibt unverändert.
- Entpack-, Passwort- und Nachbearbeitungslogik wurden nicht verändert.
## Änderungen in v2.0.87 (in Arbeit, unveröffentlicht)
- Die Spalte „Verfügbarkeit“ in der Download-Ansicht ist sortierbar. Der erste Klick ordnet Pakete von vollständig online über teilweise verfügbar und ungeprüft bis vollständig offline; der zweite Klick kehrt die Reihenfolge um. Sortiert wird primär nach Online-Anteil, sekundär nach Offline-Anteil, sodass `25/26 online` vor `6/7 online` steht und ungeprüfte Pakete zwischen teilweise verfügbaren und vollständig offline liegenden Paketen einsortiert werden.
- Ein Paket gilt als teilweise verfügbar (orange), sobald mindestens ein Link online und mindestens ein Link offline ist, auch wenn weitere Links noch ungeprüft sind. Vollständig online bleibt grün, vollständig offline bleibt rot; Pakete ohne Offline-Ergebnis und mit ungeprüften Links bleiben grau „Ungeprüft“/„Prüfung“.
- Ein Spaltensortierung wechselt die Paketreihenfolge sofort statt mit der 3-Sekunden-Gleitanimation; manuelle Verschiebungen bleiben animiert. Dazu zählt `App.tsx` pro Sortierung `packageOrderSortRevision` hoch, und `VirtualizedDownloadsBody` überspringt die Reihenfolge-Animation, solange die zuletzt angewendete Revision abweicht (`shouldAnimateDownloadOrderChange`).
- Offline-Links zählen im Paketstatus nicht mehr als „N Fehler“, weil die Spalte „Verfügbarkeit“ das bereits zeigt. `getPackageProgress` liefert `offline` getrennt und zieht diese Einträge vom angezeigten `failed` ab; echte Download-Fehler erscheinen weiterhin. Für Fortschritt und Entpack-Aufteilung zählen Offline-Einträge unverändert als abgeschlossen.
- Seitenleiste „Verbleibend“/„Gesamt“: Ab 1 TiB wird das Volumen als gruppierte Gigabyte mit einer Nachkommastelle nach Sprache angezeigt (`1.449,5 GB` deutsch, `1,449.5 GB` englisch) statt als `1.32922 TB`. Der Wert ändert sich damit alle 100 MB sichtbar; unter 1 TiB bleibt `humanSize` unverändert. `formatRemainingDownloadBytes` nimmt dafür die Sprache als zweiten Parameter.
- Umsetzung: `DownloadSortColumn` um `availability` erweitert, `getAvailabilitySummary` liefert zusätzlich `offline`, `compareAvailabilitySummaries` in `DownloadsTable.tsx`, `sortPackageOrderByAvailability` in `package-order.ts`, Verdrahtung in `sortDownloadsByColumn` in `App.tsx`. Entpack-, Passwort- und Nachbearbeitungslogik unverändert.
- Verifiziert am 4. September 2026: TypeScript fehlerfrei; vollständiger Clientlauf mit 143 erfolgreichen Testdateien, 1 optional übersprungenen JVM-Testdatei, 2.752 erfolgreichen und 4 übersprungenen Tests. Nur die zwei bekannten Windows-Symlink-Fixtures in `public-release-metadata.test.ts` endeten vor ihrer Produktassertion mit `EPERM`. Main-Build, Renderer-Produktionsbuild (bekannte Vite-Warnung, rund 583 KiB Chunk), Node-Self-Check und 16 von 16 Backup-API-Tests erfolgreich. Release-Build und Veröffentlichung stehen noch aus und brauchen eine neue ausdrückliche Freigabe.
- Dev-Start im Pfad mit `&`: `npm run dev` scheitert, weil der `cross-env`/`tsx`-Shim in `dev:electron` den Pfad an `&` zerlegt. Funktionierender Weg sind drei direkte Node-Aufrufe: `node node_modules/tsup/dist/cli-default.js src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch`, `node node_modules/vite/bin/vite.js --port 5180 --strictPort` und danach `NODE_ENV=development DEV_SERVER_PORT=5180 node node_modules/tsx/dist/cli.mjs scripts/run-dev-electron.ts`.
## Start-, Build- und Testbefehle
```powershell
+9 -4
View File
@@ -39,7 +39,7 @@ import {
getProviderDailyUsageBytes,
getProviderUsageDayKey
} from "../shared/provider-daily-limits";
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
import { preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order";
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, resolveSelectAllSelectionScope, shouldClearDownloadSelection } from "./selection";
import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, formatAccountOperationError, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountToggleIntentEnabled, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui";
import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
@@ -1904,6 +1904,7 @@ export function App(): ReactElement {
const [downloadFilter, setDownloadFilter] = useState<DownloadSidebarFilter>("all");
const [downloadProviderFilter, setDownloadProviderFilter] = useState("all");
const [downloadsSortColumn, setDownloadsSortColumn] = useState<DownloadSortColumn>("name");
const [downloadsSortRevision, setDownloadsSortRevision] = useState(0);
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
const [showAllPackages, setShowAllPackages] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
@@ -5292,6 +5293,7 @@ export function App(): ReactElement {
const nextDescending = downloadsSortColumn === column ? !downloadsSortDescending : false;
setDownloadsSortColumn(column);
setDownloadsSortDescending(nextDescending);
setDownloadsSortRevision((revision) => revision + 1);
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
const sorted = column === "progress"
? sortPackageOrderByProgress(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
@@ -5307,7 +5309,9 @@ export function App(): ReactElement {
nextDescending,
Object.fromEntries(downloadsViewCore.packageRows.map((row) => [row.package.id, row.items]))
)
: sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
: column === "availability"
? sortPackageOrderByAvailability(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
: sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
pendingPackageOrderRef.current = [...sorted];
pendingPackageOrderAtRef.current = Date.now();
packageOrderRef.current = sorted;
@@ -5450,6 +5454,7 @@ export function App(): ReactElement {
gridTemplate,
sortColumn: downloadsSortColumn,
sortDirection: downloadsSortDescending ? "desc" : "asc",
packageOrderSortRevision: downloadsSortRevision,
disclosureRevision: downloadDisclosureRevision,
animationsEnabled: snapshot.settings.animatePackageDisclosure,
status: {
@@ -5457,9 +5462,9 @@ export function App(): ReactElement {
links: downloadQueueMetrics.pendingItemCount,
session: humanSize(snapshot.stats.totalDownloaded),
sessionBytes: snapshot.stats.totalDownloaded,
total: formatRemainingDownloadBytes(downloadQueueTotal),
total: formatRemainingDownloadBytes(downloadQueueTotal, normalizeLanguage(settingsDraft.language)),
totalBytes: downloadQueueTotal.bytes,
remaining: formatRemainingDownloadBytes(downloadRemaining),
remaining: formatRemainingDownloadBytes(downloadRemaining, normalizeLanguage(settingsDraft.language)),
remainingBytes: downloadRemaining.bytes,
remainingTooltip: formatRemainingDownloadTooltip(downloadRemaining),
hosters: downloadQueueMetrics.hosterCount,
+20 -1
View File
@@ -1,4 +1,5 @@
import type { PackageEntry } from "../shared/types";
import type { DownloadItem, PackageEntry } from "../shared/types";
import { compareAvailabilitySummaries, getAvailabilitySummary } from "./views/downloads/DownloadsTable";
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
const fromIndex = order.indexOf(draggedPackageId);
@@ -24,6 +25,24 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
return sorted;
}
export function sortPackageOrderByAvailability(
order: string[],
packages: Record<string, PackageEntry>,
items: Record<string, DownloadItem>,
descending: boolean
): string[] {
const summaries = new Map(order.map((packageId) => {
const packageItems = (packages[packageId]?.itemIds ?? []).map((id) => items[id]).filter((item): item is DownloadItem => Boolean(item));
return [packageId, getAvailabilitySummary(packageItems)] as const;
}));
const sorted = [...order];
sorted.sort((a, b) => {
const cmp = compareAvailabilitySummaries(summaries.get(a)!, summaries.get(b)!);
return descending ? -cmp : cmp;
});
return sorted;
}
export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
return packages;
}
+31 -12
View File
@@ -14,7 +14,7 @@ import {
} from "../../download-format";
import type { DownloadPackageRow } from "./downloads-model";
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress" | "service";
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress" | "service" | "availability";
const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px";
const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px";
@@ -68,7 +68,7 @@ export const downloadColumnDefinitions: Record<string, { label: string; width: s
prio: { label: "Priorität", width: "minmax(var(--downloads-priority-min, 85px), 0.8fr)" },
status: { label: "Status", width: "minmax(var(--downloads-status-min, 210px), 1.2fr)" },
speed: { label: "Geschwindigkeit", width: "minmax(var(--downloads-speed-min, 120px), 1fr)" },
availability: { label: "Verfügbarkeit", width: "minmax(var(--downloads-availability-min, 110px), 1fr)" },
availability: { label: "Verfügbarkeit", width: "minmax(var(--downloads-availability-min, 110px), 1fr)", sortable: "availability" },
added: { label: "Hinzugefügt am", width: "minmax(var(--downloads-added-min, 135px), 1fr)" }
};
@@ -79,15 +79,31 @@ function effectiveItemOnlineStatus(item: DownloadItem): DownloadItem["onlineStat
?? (item.status === "downloading" || item.status === "integrity_check" || item.status === "completed" ? "online" : undefined);
}
export function getAvailabilitySummary(items: DownloadItem[]): { online: number; total: number; state: AvailabilityState } {
export interface AvailabilitySummary {
online: number;
offline: number;
total: number;
state: AvailabilityState;
}
export function getAvailabilitySummary(items: readonly DownloadItem[]): AvailabilitySummary {
const total = items.length;
const availability = items.map(effectiveItemOnlineStatus);
const online = availability.filter((status) => status === "online").length;
const offline = availability.filter((status) => status === "offline").length;
if (total > 0 && online === total) return { online, total, state: "online" };
if (total > 0 && offline === total) return { online, total, state: "offline" };
if (total > 0 && online + offline === total) return { online, total, state: "partial" };
return { online, total, state: "checking" };
if (total > 0 && online === total) return { online, offline, total, state: "online" };
if (total > 0 && offline === total) return { online, offline, total, state: "offline" };
if (online > 0 && offline > 0) return { online, offline, total, state: "partial" };
return { online, offline, total, state: "checking" };
}
export function compareAvailabilitySummaries(a: AvailabilitySummary, b: AvailabilitySummary): number {
const onlineShareA = a.total > 0 ? a.online / a.total : 0;
const onlineShareB = b.total > 0 ? b.online / b.total : 0;
if (onlineShareA !== onlineShareB) return onlineShareB - onlineShareA;
const offlineShareA = a.total > 0 ? a.offline / a.total : 0;
const offlineShareB = b.total > 0 ? b.offline / b.total : 0;
return offlineShareA - offlineShareB;
}
function Availability({ online, total, state, text }: { online: number; total: number; state: AvailabilityState; text?: string }): ReactElement {
@@ -333,9 +349,10 @@ export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps)
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; offline: number; cancelled: number; total: number; value: number } {
let done = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0));
let failed = 0;
let offline = 0;
let cancelled = 0;
let extracted = Math.max(0, Number(row.package.cleanedExtractedItemCount || 0));
let extracting = false;
@@ -343,8 +360,10 @@ export function getPackageProgress(row: DownloadPackageRow): { done: number; fai
let extractingProgress = 0;
for (const item of row.allItems) {
if (item.status === "completed") done += 1;
else if (item.status === "failed") failed += 1;
else if (item.status === "cancelled") cancelled += 1;
else if (item.status === "failed") {
failed += 1;
if (item.onlineStatus === "offline") offline += 1;
} else if (item.status === "cancelled") cancelled += 1;
const fullStatus = item.fullStatus || "";
if (fullStatus.startsWith("Entpackt")) {
extracted += 1;
@@ -368,7 +387,7 @@ export function getPackageProgress(row: DownloadPackageRow): { done: number; fai
const downloadProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(downloadRatio * (useExtractSplit ? 50 : 100)));
const extractionProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
const value = Math.min(100, useExtractSplit ? downloadProgress + extractionProgress : downloadProgress);
return { done, failed, cancelled, total, value };
return { done, failed: failed - offline, offline, cancelled, total, value };
}
export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: number; total: number; value: number } {
@@ -442,7 +461,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
const text = availability.state === "checking"
? row.allItems.some((item) => item.onlineStatus === "checking") ? "Prüfung" : "Ungeprüft"
: undefined;
return <Availability {...availability} text={text} />;
return <Availability online={availability.online} state={availability.state} text={text} total={availability.total} />;
}
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
return null;
@@ -52,6 +52,7 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
gridTemplate: string;
sortColumn?: DownloadSortColumn;
sortDirection?: "asc" | "desc";
packageOrderSortRevision?: number;
disclosureRevision: number;
animationsEnabled: boolean;
status: DownloadsStatusModel;
@@ -16,6 +16,7 @@ import {
animateDownloadOrderRows,
captureDownloadOrderRowTops,
getDownloadOrderTransitionPinnedIds,
shouldAnimateDownloadOrderChange,
getDownloadPackageOrder,
isDownloadPackageOrderChange
} from "./download-order-transition";
@@ -137,8 +138,15 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
const orderRowTopsRef = useRef<Map<string, number>>(new Map());
const [, setOrderTransitionRevision] = useState(0);
const packageOrderChanged = isDownloadPackageOrderChange(previousPackageOrderRef.current, packageOrder);
const packageOrderSortRevision = model.packageOrderSortRevision ?? 0;
const appliedSortRevisionRef = useRef(packageOrderSortRevision);
const orderAnimationsEnabled = shouldAnimateDownloadOrderChange({
animationsEnabled: model.animationsEnabled,
sortRevision: packageOrderSortRevision,
appliedSortRevision: appliedSortRevisionRef.current
});
const orderTransitionPinnedIds = getDownloadOrderTransitionPinnedIds({
enabled: model.animationsEnabled,
enabled: orderAnimationsEnabled,
previousOrder: previousPackageOrderRef.current,
nextOrder: packageOrder,
previousVisibleIds: previousVisibleIdsRef.current,
@@ -198,9 +206,10 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
useRendererLayoutEffect(() => {
previousPackageOrderRef.current = packageOrder;
previousVisibleIdsRef.current = virtualWindow.rows.map((entry) => entry.id);
appliedSortRevisionRef.current = packageOrderSortRevision;
const body = bodyRef.current;
if (!body) return;
if (!model.animationsEnabled) {
if (!orderAnimationsEnabled) {
for (const animation of orderAnimationsRef.current) animation.cancel();
orderAnimationsRef.current = [];
orderRowTopsRef.current = captureDownloadOrderRowTops(body);
@@ -228,7 +237,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
orderTransitionTimerRef.current = 0;
setOrderTransitionRevision((revision) => revision + 1);
}, DOWNLOAD_ORDER_TRANSITION_DURATION_MS);
}, [model.animationsEnabled, orderTransitionPinnedIds, packageOrder, packageOrderChanged, virtualWindow.rows]);
}, [orderAnimationsEnabled, orderTransitionPinnedIds, packageOrder, packageOrderChanged, packageOrderSortRevision, virtualWindow.rows]);
useEffect(() => () => {
if (orderTransitionTimerRef.current) window.clearTimeout(orderTransitionTimerRef.current);
@@ -20,6 +20,10 @@ export function isDownloadPackageOrderChange(previous: readonly string[], next:
return next.some((id, index) => previous[index] !== id);
}
export function shouldAnimateDownloadOrderChange(input: { animationsEnabled: boolean; sortRevision: number; appliedSortRevision: number }): boolean {
return input.animationsEnabled && input.sortRevision === input.appliedSortRevision;
}
export function getDownloadOrderTransitionPinnedIds(input: {
enabled: boolean;
previousOrder: readonly string[];
@@ -1,4 +1,4 @@
import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
import type { AppLanguage, DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
import { extractHoster, humanSize } from "../../download-format";
import { DOWNLOAD_FILE_ROW_HEIGHT, DOWNLOAD_PACKAGE_ROW_HEIGHT, type DownloadVirtualRowInput } from "./download-virtualizer";
@@ -147,9 +147,14 @@ export function getDownloadQueueStatusMetrics(items: readonly DownloadItem[]): {
};
}
export function formatRemainingDownloadBytes(summary: { bytes: number; unknownItems: number }): string {
const largeQueueGigabyteFormatters: Record<AppLanguage, Intl.NumberFormat> = {
de: new Intl.NumberFormat("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
en: new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 })
};
export function formatRemainingDownloadBytes(summary: { bytes: number; unknownItems: number }, language: AppLanguage = "de"): string {
const value = summary.bytes >= 1024 ** 4
? `${(summary.bytes / 1024 ** 4).toFixed(5)} TB`
? `${largeQueueGigabyteFormatters[language].format(summary.bytes / 1024 ** 3)} GB`
: humanSize(summary.bytes);
if (summary.unknownItems <= 0) return value;
return summary.bytes > 0 ? `${value}` : "Unbekannt";
+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", () => {