diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index ce7cbaf..d45a006 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -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//...` 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-`), 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 diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7d673b4..463691b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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("packages"); const [downloadFilter, setDownloadFilter] = useState("all"); const [downloadProviderFilter, setDownloadProviderFilter] = useState("all"); - const [downloadsSortColumn, setDownloadsSortColumn] = useState("name"); + const [downloadsSortColumn, setDownloadsSortColumn] = useState("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,9 +5302,7 @@ 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); + : sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending); commitPackageOrder(sorted); }, [commitPackageOrder, downloadsSortColumn, downloadsSortDescending, downloadsViewCore.packageRows, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]); diff --git a/src/renderer/package-order.ts b/src/renderer/package-order.ts index ada24aa..8bceb46 100644 --- a/src/renderer/package-order.ts +++ b/src/renderer/package-order.ts @@ -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 }; + } + }; +} diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index cf7688e..1459c8a 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -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[]; diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index 7f59232..7f5d19d 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -260,7 +260,7 @@ export function DownloadsContent({ actions, model }: { actions: DownloadsViewAct return (
- +
diff --git a/tests/download-sort.test.ts b/tests/download-sort.test.ts index c6be6de..77347d2 100644 --- a/tests/download-sort.test.ts +++ b/tests/download-sort.test.ts @@ -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", () => { diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 541a18d..ad28201 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -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(), diff --git a/tests/visual/sort-regression.tsx b/tests/visual/sort-regression.tsx index 0e74a72..575bd05 100644 --- a/tests/visual/sort-regression.tsx +++ b/tests/visual/sort-regression.tsx @@ -42,13 +42,15 @@ async function verify(): Promise { 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(".downloads-table-body")!; @@ -77,9 +79,11 @@ async function verify(): Promise { })) 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); });