feat: cycle availability sorting with part-count tie breakers

This commit is contained in:
Sucukdeluxe
2026-09-05 13:00:54 +02:00
parent 2eb4eb5769
commit dad645a6eb
8 changed files with 131 additions and 17 deletions
+1
View File
@@ -8,6 +8,7 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
## Zuletzt verifizierter Stand ## 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. - 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. - 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 - Verifiziert am: 5. September 2026, Europe/Berlin
+16 -6
View File
@@ -39,7 +39,7 @@ import {
getProviderDailyUsageBytes, getProviderDailyUsageBytes,
getProviderUsageDayKey getProviderUsageDayKey
} from "../shared/provider-daily-limits"; } 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 { createPackageOrderState } from "./package-order-state";
import { getPackagesWithOfflineLinks } from "../shared/offline-packages"; import { getPackagesWithOfflineLinks } from "../shared/offline-packages";
import type { OfflineSkipScope } from "../shared/types"; import type { OfflineSkipScope } from "../shared/types";
@@ -1894,7 +1894,8 @@ export function App(): ReactElement {
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages"); const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
const [downloadFilter, setDownloadFilter] = useState<DownloadSidebarFilter>("all"); const [downloadFilter, setDownloadFilter] = useState<DownloadSidebarFilter>("all");
const [downloadProviderFilter, setDownloadProviderFilter] = useState("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 [downloadsSortRevision, setDownloadsSortRevision] = useState(0);
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false); const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
const [showAllPackages, setShowAllPackages] = useState(false); const [showAllPackages, setShowAllPackages] = useState(false);
@@ -5271,11 +5272,22 @@ export function App(): ReactElement {
}, [snapshot.packageSpeedBps]); }, [snapshot.packageSpeedBps]);
const sortDownloadsByColumn = useCallback((column: DownloadSortColumn): void => { 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; const nextDescending = downloadsSortColumn === column ? !downloadsSortDescending : false;
setDownloadsSortColumn(column); setDownloadsSortColumn(column);
setDownloadsSortDescending(nextDescending); setDownloadsSortDescending(nextDescending);
setDownloadsSortRevision((revision) => revision + 1); setDownloadsSortRevision((revision) => revision + 1);
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
const sorted = column === "progress" const sorted = column === "progress"
? sortPackageOrderByProgress(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending) ? sortPackageOrderByProgress(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
: column === "size" : column === "size"
@@ -5290,9 +5302,7 @@ export function App(): ReactElement {
nextDescending, nextDescending,
Object.fromEntries(downloadsViewCore.packageRows.map((row) => [row.package.id, row.items])) Object.fromEntries(downloadsViewCore.packageRows.map((row) => [row.package.id, row.items]))
) )
: column === "availability" : sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
? sortPackageOrderByAvailability(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
: sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
commitPackageOrder(sorted); commitPackageOrder(sorted);
}, [commitPackageOrder, downloadsSortColumn, downloadsSortDescending, downloadsViewCore.packageRows, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]); }, [commitPackageOrder, downloadsSortColumn, downloadsSortDescending, downloadsViewCore.packageRows, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]);
+31 -1
View File
@@ -37,7 +37,9 @@ export function sortPackageOrderByAvailability(
})); }));
const sorted = [...order]; const sorted = [...order];
sorted.sort((a, b) => { 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 descending ? -cmp : cmp;
}); });
return sorted; return sorted;
@@ -46,3 +48,31 @@ export function sortPackageOrderByAvailability(
export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] { export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
return packages; 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; actions: DownloadsTableActions;
columnOrder: readonly string[]; columnOrder: readonly string[];
gridTemplate: string; gridTemplate: string;
sortColumn: DownloadSortColumn; sortColumn?: DownloadSortColumn;
sortDirection: "asc" | "desc"; sortDirection: "asc" | "desc";
selectedCount: number; selectedCount: number;
visibleIds: string[]; visibleIds: string[];
@@ -260,7 +260,7 @@ export function DownloadsContent({ actions, model }: { actions: DownloadsViewAct
return ( return (
<main className="downloads-content"> <main className="downloads-content">
<div className="downloads-table" role="table" aria-label="Downloads"> <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} /> <VirtualizedDownloadsBody actions={actions} model={model} state={state} />
</div> </div>
</main> </main>
+53 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { sortPackageOrderByService } from "../src/renderer/App"; 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", () => { describe("download package sorting", () => {
it("sorts the Verfügbarkeit column from fully online through partial and unchecked down to fully offline", () => { 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"]; 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, 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", () => { it("sorts the Service column by its visible provider labels", () => {
+18
View File
@@ -2119,6 +2119,24 @@ describe("download table row contracts", () => {
expect(sorted).toEqual(["availability"]); 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", () => { it("shows an actively downloading item as online even without a stored availability result", () => {
const html = renderToStaticMarkup(ItemRowContent({ const html = renderToStaticMarkup(ItemRowContent({
actions: createActions(), actions: createActions(),
+10 -6
View File
@@ -42,13 +42,15 @@ async function verify(): Promise<void> {
const sortButton = findSortButton(); const sortButton = findSortButton();
if (!sortButton) throw new Error("Verfügbarkeitsspalte fehlt"); if (!sortButton) throw new Error("Verfügbarkeitsspalte fehlt");
const results = []; const results = [];
for (let turn = 0; turn < 4; turn++) { const originalOrder = [...fixture.snapshot.session.packageOrder];
const expected = sortPackageOrderByAvailability( for (let turn = 0; turn < 6; turn++) {
const phase = turn % 3;
const expected = (phase === 2 ? originalOrder : sortPackageOrderByAvailability(
fixture.snapshot.session.packageOrder, fixture.snapshot.session.packageOrder,
fixture.snapshot.session.packages, fixture.snapshot.session.packages,
fixture.snapshot.session.items, fixture.snapshot.session.items,
turn % 2 === 1 phase === 1
).map((id) => fixture.snapshot.session.packages[id].name); )).map((id) => fixture.snapshot.session.packages[id].name);
let wrongOrderUpdates = 0; let wrongOrderUpdates = 0;
const orderChanges: string[][] = []; const orderChanges: string[][] = [];
const body = document.querySelector<HTMLElement>(".downloads-table-body")!; const body = document.querySelector<HTMLElement>(".downloads-table-body")!;
@@ -77,9 +79,11 @@ async function verify(): Promise<void> {
})) movingFrames++; })) movingFrames++;
} }
observer.disconnect(); 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); }); void verify().catch((error) => { report.textContent = String(error); });