Prevent stale live snapshots from flickering sorted packages
This commit is contained in:
@@ -7,6 +7,7 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Disable CSS row transitions as well as JavaScript animations during column sorting, so packages immediately jump to their sorted positions.
|
- Disable CSS row transitions as well as JavaScript animations during column sorting, so packages immediately jump to their sorted positions.
|
||||||
|
- Keep the chosen package order stable while live snapshots and reorder confirmations arrive, preventing a brief jump back to the previous order when reversing a sort. Ignore failures from superseded reorder requests.
|
||||||
|
|
||||||
## [2.0.87] - 2026-09-04
|
## [2.0.87] - 2026-09-04
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
|||||||
- Der Sortierfix in `v2.0.87` unterdrückte nur die JavaScript-Animation. Die CSS-Transition der virtuellen Zeilen bewegte sie weiterhin 1,5 Sekunden lang. `VirtualizedDownloadsBody` verwendet jetzt denselben Sortierguard auch für `is-download-motion-disabled`, wodurch CSS und JavaScript beim Spaltensortieren gemeinsam ausgesetzt werden.
|
- Der Sortierfix in `v2.0.87` unterdrückte nur die JavaScript-Animation. Die CSS-Transition der virtuellen Zeilen bewegte sie weiterhin 1,5 Sekunden lang. `VirtualizedDownloadsBody` verwendet jetzt denselben Sortierguard auch für `is-download-motion-disabled`, wodurch CSS und JavaScript beim Spaltensortieren gemeinsam ausgesetzt werden.
|
||||||
- Verifiziert: 160 Tests aus `download-sort.test.ts` und `downloads-view.test.tsx` erfolgreich; TypeScript mit `tsc --noEmit` fehlerfrei. Im lokalen Browser-Test mit der bestehenden visuellen Fixture wurden beide Verfügbarkeits-Sortierrichtungen samt CSS-Bewegungssperre geprüft. Normales Einklappen hebt die sortierbedingte Sperre wieder auf.
|
- Verifiziert: 160 Tests aus `download-sort.test.ts` und `downloads-view.test.tsx` erfolgreich; TypeScript mit `tsc --noEmit` fehlerfrei. Im lokalen Browser-Test mit der bestehenden visuellen Fixture wurden beide Verfügbarkeits-Sortierrichtungen samt CSS-Bewegungssperre geprüft. Normales Einklappen hebt die sortierbedingte Sperre wieder auf.
|
||||||
- Die Dev-App läuft mit Vite auf Port 5180 und Main-Watcher; Vite hat die Änderung automatisch geladen. Paketversion bleibt `2.0.87`, der Fix ist noch nicht veröffentlicht. Nächster Schritt: Saschas Sichtprüfung in der Dev-App und gegebenenfalls ein ausdrücklich beauftragtes Release.
|
- Die Dev-App läuft mit Vite auf Port 5180 und Main-Watcher; Vite hat die Änderung automatisch geladen. Paketversion bleibt `2.0.87`, der Fix ist noch nicht veröffentlicht. Nächster Schritt: Saschas Sichtprüfung in der Dev-App und gegebenenfalls ein ausdrücklich beauftragtes Release.
|
||||||
|
- Anschließender Flackerfix: Der bisherige Effect behandelte die lokal optimistisch gesetzte Reihenfolge als Serverbestätigung und löschte den Pending-Schutz. Ein gepuffertes Live-Snapshot konnte dadurch kurz die alte Reihenfolge anzeigen. `package-order-state.ts` hält die neueste Sortierabsicht bis zur passenden Befehls- und Snapshotbestätigung fest. Die Projektion erfolgt beim Rendern des Live-Snapshots; Main-Zustand und Snapshot-Koordinator bleiben autoritativ. Neue Pakete werden angehängt, entfernte nicht wiederhergestellt. Fehler älterer Sortierbefehle können neuere Klicks nicht zurückrollen; Spaltensortierung und manuelles Verschieben verwenden denselben Ablauf.
|
||||||
|
- Reproduzierbare Browser-Prüfung: Vite mit `tests/visual/vite.config.mts` auf Port 5174 starten und `/sort-regression.html` öffnen. Die echte App läuft dort ausschließlich mit Testdaten, aktivierten Animationen und 750 ms verzögerten Sortierbestätigungen samt altem Zwischen-Snapshot. Vor dem Fix zeigte jeder der drei tatsächlichen Richtungswechsel die Folge neu → alt → neu; danach zeigten vier Durchläufe keine falschen Zwischenreihenfolgen oder gemessenen Zeilenbewegungen. DOM-Mutationsprüfung ergänzt die Bildmessung, damit Hintergrundtab-Drosselung kurze Zwischenstände nicht verdeckt.
|
||||||
|
- Verifiziert nach dem Flackerfix: 195 Tests in sieben Dateien erfolgreich, einschließlich sechs neuer Regressionen für verspätete Updates, schnelle Richtungswechsel, hinzugefügte/entfernte Pakete, veraltete Fehler und Bestätigungsreihenfolge. TypeScript und Renderer-Produktionsbuild erfolgreich; bekannte Warnung zum rund 583 KiB großen Chunk unverändert. Dev-App automatisch aktualisiert; kein neues Release erstellt.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm ci
|
npm ci
|
||||||
|
|||||||
+28
-89
@@ -40,6 +40,7 @@ import {
|
|||||||
getProviderUsageDayKey
|
getProviderUsageDayKey
|
||||||
} from "../shared/provider-daily-limits";
|
} from "../shared/provider-daily-limits";
|
||||||
import { preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order";
|
import { preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order";
|
||||||
|
import { createPackageOrderState } from "./package-order-state";
|
||||||
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, resolveSelectAllSelectionScope, shouldClearDownloadSelection } from "./selection";
|
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 { 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";
|
import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
|
||||||
@@ -1616,18 +1617,6 @@ const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "
|
|||||||
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"];
|
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"];
|
||||||
const COLUMN_DEFS = downloadColumnDefinitions;
|
const COLUMN_DEFS = downloadColumnDefinitions;
|
||||||
|
|
||||||
function sameStringArray(a: string[], b: string[]): boolean {
|
|
||||||
if (a.length !== b.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (let index = 0; index < a.length; index += 1) {
|
|
||||||
if (a[index] !== b[index]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMbpsInputFromKbps(kbps: number): string {
|
function formatMbpsInputFromKbps(kbps: number): string {
|
||||||
const mbps = Math.max(0, Number(kbps) || 0) / 1024;
|
const mbps = Math.max(0, Number(kbps) || 0) / 1024;
|
||||||
return String(Number(mbps.toFixed(2)));
|
return String(Number(mbps.toFixed(2)));
|
||||||
@@ -1894,9 +1883,7 @@ export function App(): ReactElement {
|
|||||||
const importCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve());
|
const importCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve());
|
||||||
const activeTabRef = useRef<Tab>(tab);
|
const activeTabRef = useRef<Tab>(tab);
|
||||||
const packageOrderRef = useRef<string[]>([]);
|
const packageOrderRef = useRef<string[]>([]);
|
||||||
const serverPackageOrderRef = useRef<string[]>([]);
|
const packageOrderStateRef = useRef(createPackageOrderState());
|
||||||
const pendingPackageOrderRef = useRef<string[] | null>(null);
|
|
||||||
const pendingPackageOrderAtRef = useRef(0);
|
|
||||||
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||||
const [downloadDisclosureRevision, setDownloadDisclosureRevision] = useState(0);
|
const [downloadDisclosureRevision, setDownloadDisclosureRevision] = useState(0);
|
||||||
const [downloadSearch, setDownloadSearch] = useState("");
|
const [downloadSearch, setDownloadSearch] = useState("");
|
||||||
@@ -2065,31 +2052,7 @@ export function App(): ReactElement {
|
|||||||
}, [tab]);
|
}, [tab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const incoming = snapshot.session.packageOrder;
|
packageOrderRef.current = snapshot.session.packageOrder;
|
||||||
serverPackageOrderRef.current = incoming;
|
|
||||||
|
|
||||||
const pending = pendingPackageOrderRef.current;
|
|
||||||
if (!pending) {
|
|
||||||
packageOrderRef.current = incoming;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sameStringArray(pending, incoming)) {
|
|
||||||
pendingPackageOrderRef.current = null;
|
|
||||||
pendingPackageOrderAtRef.current = 0;
|
|
||||||
packageOrderRef.current = incoming;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxOptimisticHoldMs = 1500;
|
|
||||||
if (Date.now() - pendingPackageOrderAtRef.current >= maxOptimisticHoldMs) {
|
|
||||||
pendingPackageOrderRef.current = null;
|
|
||||||
pendingPackageOrderAtRef.current = 0;
|
|
||||||
packageOrderRef.current = incoming;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
packageOrderRef.current = pending;
|
|
||||||
}, [snapshot.session.packageOrder]);
|
}, [snapshot.session.packageOrder]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2342,7 +2305,8 @@ export function App(): ReactElement {
|
|||||||
stateFlushTimerRef.current = null;
|
stateFlushTimerRef.current = null;
|
||||||
if (!latestStateRef.current) return;
|
if (!latestStateRef.current) return;
|
||||||
const next = latestStateRef.current;
|
const next = latestStateRef.current;
|
||||||
setSnapshot(next);
|
const packageOrder = packageOrderStateRef.current.accept(next.session.packageOrder);
|
||||||
|
setSnapshot(packageOrder === next.session.packageOrder ? next : { ...next, session: { ...next.session, packageOrder } });
|
||||||
if (!settingsDirtyRef.current) {
|
if (!settingsDirtyRef.current) {
|
||||||
setSettingsDraft((current) => createSettingsDraft(next.settings, current));
|
setSettingsDraft((current) => createSettingsDraft(next.settings, current));
|
||||||
}
|
}
|
||||||
@@ -2357,6 +2321,7 @@ export function App(): ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
authoritativeSnapshotApplied = true;
|
authoritativeSnapshotApplied = true;
|
||||||
|
packageOrderStateRef.current.accept(state.session.packageOrder);
|
||||||
masterSnapshotRef.current = state;
|
masterSnapshotRef.current = state;
|
||||||
persistedSettingsRef.current = state.settings;
|
persistedSettingsRef.current = state.settings;
|
||||||
const resolvedTheme = resolveSettingsThemeChoice(
|
const resolvedTheme = resolveSettingsThemeChoice(
|
||||||
@@ -4361,6 +4326,22 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const commitPackageOrder = useCallback((order: string[]): void => {
|
||||||
|
const requestId = packageOrderStateRef.current.begin(order);
|
||||||
|
packageOrderRef.current = [...order];
|
||||||
|
setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: [...order] } }));
|
||||||
|
void window.rd.reorderPackages(order).then(() => {
|
||||||
|
packageOrderStateRef.current.confirm(requestId);
|
||||||
|
}).catch((error) => {
|
||||||
|
const restoredOrder = packageOrderStateRef.current.reject(requestId);
|
||||||
|
if (!restoredOrder) return;
|
||||||
|
packageOrderRef.current = restoredOrder;
|
||||||
|
setDownloadsSortRevision((revision) => revision + 1);
|
||||||
|
setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: restoredOrder } }));
|
||||||
|
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
|
});
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
const movePackage = useCallback((packageId: string, direction: "up" | "down") => {
|
const movePackage = useCallback((packageId: string, direction: "up" | "down") => {
|
||||||
const currentOrder = packageOrderRef.current;
|
const currentOrder = packageOrderRef.current;
|
||||||
const order = [...currentOrder];
|
const order = [...currentOrder];
|
||||||
@@ -4370,24 +4351,8 @@ export function App(): ReactElement {
|
|||||||
if (target < 0 || target >= order.length) { return; }
|
if (target < 0 || target >= order.length) { return; }
|
||||||
[order[idx], order[target]] = [order[target], order[idx]];
|
[order[idx], order[target]] = [order[target], order[idx]];
|
||||||
setDownloadsSortDescending(false);
|
setDownloadsSortDescending(false);
|
||||||
pendingPackageOrderRef.current = [...order];
|
commitPackageOrder(order);
|
||||||
pendingPackageOrderAtRef.current = Date.now();
|
}, [commitPackageOrder]);
|
||||||
packageOrderRef.current = [...order];
|
|
||||||
setSnapshot((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: [...order] } };
|
|
||||||
});
|
|
||||||
void window.rd.reorderPackages(order).catch((error) => {
|
|
||||||
pendingPackageOrderRef.current = null;
|
|
||||||
pendingPackageOrderAtRef.current = 0;
|
|
||||||
packageOrderRef.current = serverPackageOrderRef.current;
|
|
||||||
setSnapshot((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
|
|
||||||
});
|
|
||||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
|
||||||
});
|
|
||||||
}, [showToast]);
|
|
||||||
|
|
||||||
const openCollectorInput = (): void => {
|
const openCollectorInput = (): void => {
|
||||||
setCollectorError("");
|
setCollectorError("");
|
||||||
@@ -4541,24 +4506,8 @@ export function App(): ReactElement {
|
|||||||
const unchanged = order.length === currentOrder.length && order.every((id, idx) => id === currentOrder[idx]);
|
const unchanged = order.length === currentOrder.length && order.every((id, idx) => id === currentOrder[idx]);
|
||||||
if (unchanged) return;
|
if (unchanged) return;
|
||||||
setDownloadsSortDescending(false);
|
setDownloadsSortDescending(false);
|
||||||
pendingPackageOrderRef.current = [...order];
|
commitPackageOrder(order);
|
||||||
pendingPackageOrderAtRef.current = Date.now();
|
}, [commitPackageOrder, selectedIds, snapshot.session.packages]);
|
||||||
packageOrderRef.current = [...order];
|
|
||||||
setSnapshot((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: [...order] } };
|
|
||||||
});
|
|
||||||
void window.rd.reorderPackages(order).catch((error) => {
|
|
||||||
pendingPackageOrderRef.current = null;
|
|
||||||
pendingPackageOrderAtRef.current = 0;
|
|
||||||
packageOrderRef.current = serverPackageOrderRef.current;
|
|
||||||
setSnapshot((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
|
|
||||||
});
|
|
||||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
|
||||||
});
|
|
||||||
}, [selectedIds, snapshot.session.packages, showToast]);
|
|
||||||
|
|
||||||
const onPackageToggle = useCallback((packageId: string): void => {
|
const onPackageToggle = useCallback((packageId: string): void => {
|
||||||
let previousEnabled: boolean | null = null;
|
let previousEnabled: boolean | null = null;
|
||||||
@@ -5312,18 +5261,8 @@ export function App(): ReactElement {
|
|||||||
: column === "availability"
|
: column === "availability"
|
||||||
? sortPackageOrderByAvailability(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
|
? sortPackageOrderByAvailability(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
|
||||||
: sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
|
: sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
|
||||||
pendingPackageOrderRef.current = [...sorted];
|
commitPackageOrder(sorted);
|
||||||
pendingPackageOrderAtRef.current = Date.now();
|
}, [commitPackageOrder, downloadsSortColumn, downloadsSortDescending, downloadsViewCore.packageRows, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]);
|
||||||
packageOrderRef.current = sorted;
|
|
||||||
setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: [...sorted] } }));
|
|
||||||
void window.rd.reorderPackages(sorted).catch((error) => {
|
|
||||||
pendingPackageOrderRef.current = null;
|
|
||||||
pendingPackageOrderAtRef.current = 0;
|
|
||||||
packageOrderRef.current = serverPackageOrderRef.current;
|
|
||||||
setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: serverPackageOrderRef.current } }));
|
|
||||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
|
||||||
});
|
|
||||||
}, [downloadsSortColumn, downloadsSortDescending, downloadsViewCore.packageRows, showToast, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]);
|
|
||||||
|
|
||||||
const clearDownloadQueue = useCallback((): void => {
|
const clearDownloadQueue = useCallback((): void => {
|
||||||
void performQuickAction(async () => {
|
void performQuickAction(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export function createPackageOrderState() {
|
||||||
|
let requestId = 0;
|
||||||
|
let serverOrder: string[] = [];
|
||||||
|
let pending: { id: number; order: string[]; confirmed: boolean } | null = null;
|
||||||
|
|
||||||
|
const project = (incoming: string[]): string[] => {
|
||||||
|
if (!pending) return incoming;
|
||||||
|
const available = new Set(incoming);
|
||||||
|
const order = pending.order.filter((id) => available.delete(id));
|
||||||
|
order.push(...available);
|
||||||
|
const matches = order.length === incoming.length && order.every((id, index) => id === incoming[index]);
|
||||||
|
if (matches && pending.confirmed) pending = null;
|
||||||
|
return matches ? incoming : order;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
begin(order: string[]): number {
|
||||||
|
pending = { id: ++requestId, order: [...order], confirmed: false };
|
||||||
|
return requestId;
|
||||||
|
},
|
||||||
|
accept(incoming: string[]): string[] {
|
||||||
|
serverOrder = incoming;
|
||||||
|
return project(incoming);
|
||||||
|
},
|
||||||
|
confirm(id: number): void {
|
||||||
|
if (pending?.id === id) pending.confirmed = true;
|
||||||
|
},
|
||||||
|
reject(id: number): string[] | null {
|
||||||
|
if (pending?.id !== id) return null;
|
||||||
|
pending = null;
|
||||||
|
return serverOrder;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createPackageOrderState } from "../src/renderer/package-order-state";
|
||||||
|
|
||||||
|
describe("pending package order", () => {
|
||||||
|
it("keeps the chosen order through stale updates until the command and snapshot agree", () => {
|
||||||
|
const state = createPackageOrderState();
|
||||||
|
state.accept(["a", "b", "c"]);
|
||||||
|
const request = state.begin(["c", "b", "a"]);
|
||||||
|
expect(state.accept(["a", "b", "c"])).toEqual(["c", "b", "a"]);
|
||||||
|
state.confirm(request);
|
||||||
|
expect(state.accept(["a", "b", "c"])).toEqual(["c", "b", "a"]);
|
||||||
|
expect(state.accept(["c", "b", "a"])).toEqual(["c", "b", "a"]);
|
||||||
|
expect(state.accept(["b", "a", "c"])).toEqual(["b", "a", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mistake the old order for confirmation when the user reverses quickly", () => {
|
||||||
|
const state = createPackageOrderState();
|
||||||
|
state.accept(["a", "b"]);
|
||||||
|
const first = state.begin(["b", "a"]);
|
||||||
|
const second = state.begin(["a", "b"]);
|
||||||
|
expect(state.accept(["a", "b"])).toEqual(["a", "b"]);
|
||||||
|
state.confirm(first);
|
||||||
|
expect(state.accept(["b", "a"])).toEqual(["a", "b"]);
|
||||||
|
state.confirm(second);
|
||||||
|
expect(state.accept(["b", "a"])).toEqual(["a", "b"]);
|
||||||
|
state.accept(["a", "b"]);
|
||||||
|
expect(state.accept(["b", "a"])).toEqual(["b", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains newly added packages and never resurrects removed packages", () => {
|
||||||
|
const state = createPackageOrderState();
|
||||||
|
state.accept(["a", "b", "c"]);
|
||||||
|
const request = state.begin(["c", "b", "a"]);
|
||||||
|
expect(state.accept(["a", "c", "new"])).toEqual(["c", "a", "new"]);
|
||||||
|
state.confirm(request);
|
||||||
|
state.accept(["c", "a", "new"]);
|
||||||
|
expect(state.accept(["new", "a", "c"])).toEqual(["new", "a", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores rejected obsolete requests and rolls back the current request to the latest server order", () => {
|
||||||
|
const state = createPackageOrderState();
|
||||||
|
state.accept(["a", "b"]);
|
||||||
|
const first = state.begin(["b", "a"]);
|
||||||
|
const second = state.begin(["a", "b"]);
|
||||||
|
expect(state.reject(first)).toBeNull();
|
||||||
|
state.accept(["b", "a", "new"]);
|
||||||
|
expect(state.reject(second)).toEqual(["b", "a", "new"]);
|
||||||
|
expect(state.accept(["new", "b", "a"])).toEqual(["new", "b", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can receive the matching snapshot before the command promise resolves", () => {
|
||||||
|
const state = createPackageOrderState();
|
||||||
|
state.accept(["a", "b"]);
|
||||||
|
const request = state.begin(["b", "a"]);
|
||||||
|
state.accept(["b", "a"]);
|
||||||
|
state.confirm(request);
|
||||||
|
const incoming = ["b", "a"];
|
||||||
|
expect(state.accept(incoming)).toBe(incoming);
|
||||||
|
expect(state.accept(["a", "b"])).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an empty authoritative queue", () => {
|
||||||
|
const state = createPackageOrderState();
|
||||||
|
const request = state.begin(["b", "a"]);
|
||||||
|
state.confirm(request);
|
||||||
|
expect(state.accept([])).toEqual([]);
|
||||||
|
expect(state.accept(["new"])).toEqual(["new"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sortier-Regressionsprüfung</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/sort-regression.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "../../src/renderer/App";
|
||||||
|
import type { ElectronApi } from "../../src/shared/preload-api";
|
||||||
|
import { sortPackageOrderByAvailability } from "../../src/renderer/package-order";
|
||||||
|
import { createVisualFixture } from "./fixtures";
|
||||||
|
import { createVisualElectronApi } from "./mock-electron-api";
|
||||||
|
import "../../src/renderer/theme.css";
|
||||||
|
import "../../src/renderer/styles.css";
|
||||||
|
|
||||||
|
const fixture = createVisualFixture("dense");
|
||||||
|
fixture.snapshot.settings.animatePackageDisclosure = true;
|
||||||
|
const listeners = new Set<Parameters<ElectronApi["onStateUpdate"]>[0]>();
|
||||||
|
const emit = (): void => {
|
||||||
|
fixture.snapshot.snapshotRevision = (fixture.snapshot.snapshotRevision ?? 0) + 1;
|
||||||
|
for (const listener of listeners) listener(structuredClone(fixture.snapshot));
|
||||||
|
};
|
||||||
|
window.rd = {
|
||||||
|
...createVisualElectronApi(fixture),
|
||||||
|
onStateUpdate: (listener) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
reorderPackages: async (order) => {
|
||||||
|
emit();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||||
|
fixture.snapshot.session.packageOrder = [...order];
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = document.createElement("output");
|
||||||
|
report.style.cssText = "position:fixed;bottom:0;left:0;z-index:9999;background:#fff;color:#111;font-size:12px;padding:8px";
|
||||||
|
report.textContent = "Prüfung wird vorbereitet";
|
||||||
|
document.body.append(report);
|
||||||
|
createRoot(document.getElementById("root")!).render(<App />);
|
||||||
|
|
||||||
|
async function verify(): Promise<void> {
|
||||||
|
const frame = (): Promise<void> => new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
const findSortButton = (): HTMLButtonElement | undefined => [...document.querySelectorAll<HTMLButtonElement>("button")]
|
||||||
|
.find((button) => /^Verfügbarkeit(?: [↑↓])?$/.test(button.textContent?.trim() ?? ""));
|
||||||
|
for (let attempt = 0; attempt < 180 && !findSortButton(); attempt++) await frame();
|
||||||
|
const sortButton = findSortButton();
|
||||||
|
if (!sortButton) throw new Error("Verfügbarkeitsspalte fehlt");
|
||||||
|
const results = [];
|
||||||
|
for (let turn = 0; turn < 4; turn++) {
|
||||||
|
const expected = sortPackageOrderByAvailability(
|
||||||
|
fixture.snapshot.session.packageOrder,
|
||||||
|
fixture.snapshot.session.packages,
|
||||||
|
fixture.snapshot.session.items,
|
||||||
|
turn % 2 === 1
|
||||||
|
).map((id) => fixture.snapshot.session.packages[id].name);
|
||||||
|
let wrongOrderUpdates = 0;
|
||||||
|
const orderChanges: string[][] = [];
|
||||||
|
const body = document.querySelector<HTMLElement>(".downloads-table-body")!;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const actual = [...body.querySelectorAll("article strong")].map((element) => element.textContent ?? "");
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(orderChanges.at(-1))) orderChanges.push(actual);
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) wrongOrderUpdates++;
|
||||||
|
});
|
||||||
|
observer.observe(body, { childList: true, subtree: true });
|
||||||
|
sortButton.click();
|
||||||
|
const deadline = performance.now() + 1600;
|
||||||
|
let frames = 0;
|
||||||
|
let wrongOrderFrames = 0;
|
||||||
|
let movingFrames = 0;
|
||||||
|
while (performance.now() < deadline) {
|
||||||
|
await frame();
|
||||||
|
frames++;
|
||||||
|
const body = document.querySelector<HTMLElement>(".downloads-table-body")!;
|
||||||
|
const actual = [...body.querySelectorAll("article strong")].map((element) => element.textContent);
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) wrongOrderFrames++;
|
||||||
|
const rows = [...body.querySelectorAll<HTMLElement>(".downloads-virtual-row")];
|
||||||
|
if (rows.some((row) => {
|
||||||
|
const target = Number.parseFloat(row.style.getPropertyValue("--downloads-virtual-row-top"));
|
||||||
|
const transform = new DOMMatrixReadOnly(getComputedStyle(row).transform);
|
||||||
|
return Math.abs(transform.m42 - target) > 0.5;
|
||||||
|
})) movingFrames++;
|
||||||
|
}
|
||||||
|
observer.disconnect();
|
||||||
|
results.push({ direction: turn % 2 === 1 ? "offline-first" : "online-first", frames, wrongOrderFrames, wrongOrderUpdates, movingFrames, orderChanges });
|
||||||
|
}
|
||||||
|
report.textContent = JSON.stringify({ passed: results.every((result) => 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