feat: cycle availability sorting with part-count tie breakers
This commit is contained in:
@@ -8,6 +8,7 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
||||
|
||||
## Zuletzt verifizierter Stand
|
||||
|
||||
- Availability-Sortierung nach `v2.0.88`: Klickfolge online/viele Parts zuerst → offline/wenige Parts zuerst → ursprüngliche Reihenfolge ohne aktiven Sortierpfeil. Online-/Offline-Anteile bleiben vorrangig; Part-Anzahl entscheidet nur bei identischer Verfügbarkeit. Neue Pakete bleiben beim Ausschalten hinten erhalten, gelöschte werden nicht wieder eingefügt. Wechsel auf eine andere Sortierspalte setzt den Availability-Zyklus zurück. Bestehender Pending-Snapshot-Schutz und sofortiger Sortierwechsel bleiben erhalten. TypeScript und gezielte Sortier-/Ansichtstests erfolgreich; isolierte Electron-Browserprobe mit Testdaten prüft zwei vollständige Zyklen samt verzögerten Snapshots, Sortierindikatoren und DOM-Reihenfolge ohne falsche Zwischenstände. Kein neues Release.
|
||||
- Hot-Dev-Start am 5. September 2026 geklärt und behoben: Der frühere Befund „blocked by policy“ war kein Windows- oder Sicherheitsproblem, sondern die Werkzeug-Sandbox der Agentensitzung plus ein echter Fehler im Skriptaufruf. Das `&` im Projektpfad („Claude & ChatGPT Projekte“) zerlegt die npm-`cmd`-Shims in `node_modules/.bin` (`SET dp0=%~dp0` ohne Anführungszeichen), cmd meldet „Der Befehl "ChatGPT" ist entweder falsch geschrieben“ und sucht `C:UsersSaschaDesktopwait-oninwait-on`. Fix: Die Skripte `dev`, `dev:renderer`, `dev:main:watch` und `dev:electron` rufen jetzt die Bin-Dateien direkt über `node node_modules/<paket>/...` auf; `npm run dev` startet damit aus dem Projektordner Vite (5180), tsup-Watch und das Electron-Fenster „Multi Debrid Downloader - v2.0.88“ (Prozess `Multi-Debrid-Downloader-dev-<pid>`), verifiziert am 5. September 2026. Regeln für Agenten (Codex und Claude): `npm run dev` als Hintergrundprozess starten, kein `Start-Process`, kein Aufruf von `.cmd`-Shims aus `node_modules/.bin`; wenn Port 5180 schon belegt ist, läuft bereits eine Dev-Instanz, dann nur `npm run dev:electron` starten. Übrige Skripte (`build`, `test:client`, `self-check`, `start`) nutzen weiterhin Shims und brauchen bei Bedarf denselben Umbau oder direkte `node node_modules/...`-Aufrufe.
|
||||
- Release-Vorprüfung v2.0.88: 2.765 Client-Tests erfolgreich; vier bestehende optionale Tests übersprungen, zwei Windows-Symlink-Fixtures wegen fehlender Berechtigung gezielt ausgenommen. Alle 16 Backup-API-Tests, TypeScript und Node-Self-Check erfolgreich. Main-/Renderer-Build sowie Windows-Installer und portable Anwendung gebaut; `verify_public_release.mjs --verify-archives` bestätigt beide Archive einschließlich Lizenzen, Versionsstand und Update-Metadaten. Bekannte Vite-Chunkgrößenwarnung unverändert.
|
||||
- Verifiziert am: 5. September 2026, Europe/Berlin
|
||||
|
||||
+15
-5
@@ -39,7 +39,7 @@ import {
|
||||
getProviderDailyUsageBytes,
|
||||
getProviderUsageDayKey
|
||||
} from "../shared/provider-daily-limits";
|
||||
import { preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order";
|
||||
import { createAvailabilitySortCycle, preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order";
|
||||
import { createPackageOrderState } from "./package-order-state";
|
||||
import { getPackagesWithOfflineLinks } from "../shared/offline-packages";
|
||||
import type { OfflineSkipScope } from "../shared/types";
|
||||
@@ -1894,7 +1894,8 @@ export function App(): ReactElement {
|
||||
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
|
||||
const [downloadFilter, setDownloadFilter] = useState<DownloadSidebarFilter>("all");
|
||||
const [downloadProviderFilter, setDownloadProviderFilter] = useState("all");
|
||||
const [downloadsSortColumn, setDownloadsSortColumn] = useState<DownloadSortColumn>("name");
|
||||
const [downloadsSortColumn, setDownloadsSortColumn] = useState<DownloadSortColumn | undefined>("name");
|
||||
const availabilitySortCycleRef = useRef(createAvailabilitySortCycle());
|
||||
const [downloadsSortRevision, setDownloadsSortRevision] = useState(0);
|
||||
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
|
||||
const [showAllPackages, setShowAllPackages] = useState(false);
|
||||
@@ -5271,11 +5272,22 @@ export function App(): ReactElement {
|
||||
}, [snapshot.packageSpeedBps]);
|
||||
|
||||
const sortDownloadsByColumn = useCallback((column: DownloadSortColumn): void => {
|
||||
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
|
||||
if (column === "availability") {
|
||||
const step = availabilitySortCycleRef.current.next(baseOrder);
|
||||
setDownloadsSortColumn(step.descending === null ? undefined : column);
|
||||
setDownloadsSortDescending(step.descending === false);
|
||||
setDownloadsSortRevision((revision) => revision + 1);
|
||||
commitPackageOrder(step.descending === null ? step.order : sortPackageOrderByAvailability(
|
||||
step.order, snapshot.session.packages, snapshot.session.items, step.descending
|
||||
));
|
||||
return;
|
||||
}
|
||||
availabilitySortCycleRef.current.reset();
|
||||
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)
|
||||
: column === "size"
|
||||
@@ -5290,8 +5302,6 @@ export function App(): ReactElement {
|
||||
nextDescending,
|
||||
Object.fromEntries(downloadsViewCore.packageRows.map((row) => [row.package.id, row.items]))
|
||||
)
|
||||
: column === "availability"
|
||||
? sortPackageOrderByAvailability(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
|
||||
: sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
|
||||
commitPackageOrder(sorted);
|
||||
}, [commitPackageOrder, downloadsSortColumn, downloadsSortDescending, downloadsViewCore.packageRows, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]);
|
||||
|
||||
@@ -37,7 +37,9 @@ export function sortPackageOrderByAvailability(
|
||||
}));
|
||||
const sorted = [...order];
|
||||
sorted.sort((a, b) => {
|
||||
const cmp = compareAvailabilitySummaries(summaries.get(a)!, summaries.get(b)!);
|
||||
const summaryA = summaries.get(a)!;
|
||||
const summaryB = summaries.get(b)!;
|
||||
const cmp = compareAvailabilitySummaries(summaryA, summaryB) || summaryB.total - summaryA.total;
|
||||
return descending ? -cmp : cmp;
|
||||
});
|
||||
return sorted;
|
||||
@@ -46,3 +48,31 @@ export function sortPackageOrderByAvailability(
|
||||
export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
|
||||
return packages;
|
||||
}
|
||||
|
||||
export function createAvailabilitySortCycle() {
|
||||
let phase: "off" | "online" | "offline" = "off";
|
||||
let originalOrder: string[] = [];
|
||||
return {
|
||||
reset(): void {
|
||||
phase = "off";
|
||||
originalOrder = [];
|
||||
},
|
||||
next(currentOrder: string[]): { descending: boolean | null; order: string[] } {
|
||||
if (phase === "off") {
|
||||
originalOrder = [...currentOrder];
|
||||
phase = "online";
|
||||
return { descending: false, order: currentOrder };
|
||||
}
|
||||
if (phase === "online") {
|
||||
phase = "offline";
|
||||
return { descending: true, order: currentOrder };
|
||||
}
|
||||
phase = "off";
|
||||
const available = new Set(currentOrder);
|
||||
const order = originalOrder.filter((id) => available.delete(id));
|
||||
order.push(...available);
|
||||
originalOrder = [];
|
||||
return { descending: null, order };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -551,7 +551,7 @@ export interface DownloadsTableHeaderProps {
|
||||
actions: DownloadsTableActions;
|
||||
columnOrder: readonly string[];
|
||||
gridTemplate: string;
|
||||
sortColumn: DownloadSortColumn;
|
||||
sortColumn?: DownloadSortColumn;
|
||||
sortDirection: "asc" | "desc";
|
||||
selectedCount: number;
|
||||
visibleIds: string[];
|
||||
|
||||
@@ -260,7 +260,7 @@ export function DownloadsContent({ actions, model }: { actions: DownloadsViewAct
|
||||
return (
|
||||
<main className="downloads-content">
|
||||
<div className="downloads-table" role="table" aria-label="Downloads">
|
||||
<DownloadsTableHeader actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} selectedCount={model.actionableSelectedIds.length} sortColumn={model.sortColumn ?? "name"} sortDirection={model.sortDirection ?? "asc"} visibleIds={model.visibleRowIds} />
|
||||
<DownloadsTableHeader actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} selectedCount={model.actionableSelectedIds.length} sortColumn={model.sortColumn} sortDirection={model.sortDirection ?? "asc"} visibleIds={model.visibleRowIds} />
|
||||
<VirtualizedDownloadsBody actions={actions} model={model} state={state} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sortPackageOrderByService } from "../src/renderer/App";
|
||||
import { sortPackageOrderByAvailability } from "../src/renderer/package-order";
|
||||
import { createAvailabilitySortCycle, 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", () => {
|
||||
@@ -30,7 +30,58 @@ describe("download package sorting", () => {
|
||||
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"]);
|
||||
expect(sortPackageOrderByAvailability(order, packages, items, true)).toEqual(["offline", "empty", "unchecked", "partialLow", "partialHigh", "online"]);
|
||||
});
|
||||
|
||||
it("uses part counts only for equal availability, including offline and partial packages", () => {
|
||||
const definitions = [
|
||||
["offlineSmall", 0, 2], ["onlineSmall", 2, 2], ["partialSmall", 1, 2],
|
||||
["offlineLarge", 0, 8], ["onlineLarge", 8, 8], ["partialLarge", 4, 8],
|
||||
["partialHigh", 3, 4], ["onlineEqual", 2, 2]
|
||||
] as const;
|
||||
const packages: any = {};
|
||||
const items: any = {};
|
||||
for (const [id, online, total] of definitions) {
|
||||
const itemIds = Array.from({ length: total }, (_, index) => `${id}-${index}`);
|
||||
packages[id] = { id, itemIds };
|
||||
itemIds.forEach((itemId, index) => { items[itemId] = { status: "queued", onlineStatus: index < online ? "online" : "offline" }; });
|
||||
}
|
||||
const order = definitions.map(([id]) => id);
|
||||
expect(sortPackageOrderByAvailability(order, packages, items, false)).toEqual([
|
||||
"onlineLarge", "onlineSmall", "onlineEqual", "partialHigh", "partialLarge", "partialSmall", "offlineLarge", "offlineSmall"
|
||||
]);
|
||||
expect(sortPackageOrderByAvailability(order, packages, items, true)).toEqual([
|
||||
"offlineSmall", "offlineLarge", "partialSmall", "partialLarge", "partialHigh", "onlineSmall", "onlineEqual", "onlineLarge"
|
||||
]);
|
||||
});
|
||||
|
||||
it("cycles online first, offline first, original order, then starts a new cycle", () => {
|
||||
const cycle = createAvailabilitySortCycle();
|
||||
const original = ["b", "a", "c"];
|
||||
expect(cycle.next(original).descending).toBe(false);
|
||||
expect(cycle.next(["a", "b", "c"]).descending).toBe(true);
|
||||
expect(cycle.next(["c", "b", "a"])).toEqual({ descending: null, order: original });
|
||||
expect(cycle.next(["new", "a"]).descending).toBe(false);
|
||||
expect(cycle.next(["a", "new"]).descending).toBe(true);
|
||||
expect(cycle.next(["a", "new"])).toEqual({ descending: null, order: ["new", "a"] });
|
||||
});
|
||||
|
||||
it("restores surviving packages and appends new packages without resurrecting deletions", () => {
|
||||
const cycle = createAvailabilitySortCycle();
|
||||
const original = ["b", "a", "deleted"];
|
||||
cycle.next(original);
|
||||
original.reverse();
|
||||
cycle.next(["a", "b", "new"]);
|
||||
expect(cycle.next(["new", "a", "b", "newer"])).toEqual({ descending: null, order: ["b", "a", "new", "newer"] });
|
||||
});
|
||||
|
||||
it("starts afresh after another column and handles an empty queue", () => {
|
||||
const cycle = createAvailabilitySortCycle();
|
||||
cycle.next(["old"]);
|
||||
cycle.reset();
|
||||
expect(cycle.next([])).toEqual({ descending: false, order: [] });
|
||||
cycle.next([]);
|
||||
expect(cycle.next([])).toEqual({ descending: null, order: [] });
|
||||
});
|
||||
|
||||
it("sorts the Service column by its visible provider labels", () => {
|
||||
|
||||
@@ -2119,6 +2119,24 @@ describe("download table row contracts", () => {
|
||||
expect(sorted).toEqual(["availability"]);
|
||||
});
|
||||
|
||||
it("shows no active sorting or direction arrow when sorting is switched off", () => {
|
||||
const header = DownloadsTableHeader({
|
||||
actions: createActions(),
|
||||
columnOrder: ["name", "availability"],
|
||||
gridTemplate: "100px 100px",
|
||||
selectedCount: 0,
|
||||
sortColumn: undefined,
|
||||
sortDirection: "asc",
|
||||
visibleIds: []
|
||||
});
|
||||
for (const column of ["name", "availability"]) {
|
||||
expect(findElement(header, (element) => element.props["data-download-column"] === column).props["aria-sort"]).toBe("none");
|
||||
}
|
||||
const html = renderToStaticMarkup(header);
|
||||
expect(html).not.toContain("↑");
|
||||
expect(html).not.toContain("↓");
|
||||
});
|
||||
|
||||
it("shows an actively downloading item as online even without a stored availability result", () => {
|
||||
const html = renderToStaticMarkup(ItemRowContent({
|
||||
actions: createActions(),
|
||||
|
||||
@@ -42,13 +42,15 @@ async function verify(): Promise<void> {
|
||||
const sortButton = findSortButton();
|
||||
if (!sortButton) throw new Error("Verfügbarkeitsspalte fehlt");
|
||||
const results = [];
|
||||
for (let turn = 0; turn < 4; turn++) {
|
||||
const expected = sortPackageOrderByAvailability(
|
||||
const originalOrder = [...fixture.snapshot.session.packageOrder];
|
||||
for (let turn = 0; turn < 6; turn++) {
|
||||
const phase = turn % 3;
|
||||
const expected = (phase === 2 ? originalOrder : sortPackageOrderByAvailability(
|
||||
fixture.snapshot.session.packageOrder,
|
||||
fixture.snapshot.session.packages,
|
||||
fixture.snapshot.session.items,
|
||||
turn % 2 === 1
|
||||
).map((id) => fixture.snapshot.session.packages[id].name);
|
||||
phase === 1
|
||||
)).map((id) => fixture.snapshot.session.packages[id].name);
|
||||
let wrongOrderUpdates = 0;
|
||||
const orderChanges: string[][] = [];
|
||||
const body = document.querySelector<HTMLElement>(".downloads-table-body")!;
|
||||
@@ -77,9 +79,11 @@ async function verify(): Promise<void> {
|
||||
})) movingFrames++;
|
||||
}
|
||||
observer.disconnect();
|
||||
results.push({ direction: turn % 2 === 1 ? "offline-first" : "online-first", frames, wrongOrderFrames, wrongOrderUpdates, movingFrames, orderChanges });
|
||||
const expectedAriaSort = phase === 2 ? "none" : phase === 0 ? "descending" : "ascending";
|
||||
const correctIndicator = sortButton.closest("[aria-sort]")?.getAttribute("aria-sort") === expectedAriaSort;
|
||||
results.push({ direction: ["online-first", "offline-first", "off"][phase], correctIndicator, frames, wrongOrderFrames, wrongOrderUpdates, movingFrames, orderChanges });
|
||||
}
|
||||
report.textContent = JSON.stringify({ passed: results.every((result) => result.wrongOrderFrames === 0 && result.wrongOrderUpdates === 0 && result.movingFrames === 0), results });
|
||||
report.textContent = JSON.stringify({ passed: results.every((result) => result.correctIndicator && result.wrongOrderFrames === 0 && result.wrongOrderUpdates === 0 && result.movingFrames === 0), results });
|
||||
}
|
||||
|
||||
void verify().catch((error) => { report.textContent = String(error); });
|
||||
|
||||
Reference in New Issue
Block a user