feat(downloads): animate priority reordering
Rename the package disclosure option to a general animations switch and use it for both disclosure and priority-order motion. Keep previously visible virtual rows mounted during reorders, animate measured start and target positions for 1.5 seconds, and cancel all motion immediately when the setting is disabled. Extend the isolated renderer fixture so enabled and disabled behavior can be measured without touching user download state.
This commit is contained in:
@@ -28,6 +28,12 @@ import {
|
||||
scheduleDownloadDisclosureActivation,
|
||||
stableDownloadDisclosureRows
|
||||
} from "../src/renderer/views/downloads/download-disclosure-transition";
|
||||
import {
|
||||
DOWNLOAD_ORDER_TRANSITION_DURATION_MS,
|
||||
getDownloadOrderTransitionPinnedIds,
|
||||
getDownloadOrderTransformKeyframes,
|
||||
isDownloadPackageOrderChange
|
||||
} from "../src/renderer/views/downloads/download-order-transition";
|
||||
import {
|
||||
DownloadsContent,
|
||||
DownloadsFooter,
|
||||
@@ -241,12 +247,36 @@ describe("virtualisierte Paketanimation", () => {
|
||||
expect(prepared.rows.some((row) => row.type === "item-group")).toBe(false);
|
||||
});
|
||||
|
||||
it("deaktiviert auch die CSS-Bewegung der virtuellen Folgezeilen", () => {
|
||||
const html = renderToStaticMarkup(<DownloadsContent actions={createActions()} model={withRuntime(createInput(), { animatePackageDisclosure: false })} />);
|
||||
it("deaktiviert alle Downloadbewegungen über den allgemeinen Animationsschalter", () => {
|
||||
const html = renderToStaticMarkup(<DownloadsContent actions={createActions()} model={withRuntime(createInput(), { animationsEnabled: false })} />);
|
||||
const css = readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||
|
||||
expect(html).toContain('class="downloads-table-body is-disclosure-motion-disabled"');
|
||||
expect(css).toMatch(/\.downloads-table-body\.is-disclosure-motion-disabled \.downloads-virtual-spacer,\s*\.downloads-table-body\.is-disclosure-motion-disabled \.downloads-virtual-row\s*\{[^}]*transition:\s*none !important;/s);
|
||||
expect(html).toContain('class="downloads-table-body is-download-motion-disabled"');
|
||||
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("pins only previously visible rows for a real priority reorder", () => {
|
||||
expect(DOWNLOAD_ORDER_TRANSITION_DURATION_MS).toBe(1500);
|
||||
expect(isDownloadPackageOrderChange(["a", "b", "c"], ["b", "c", "a"])).toBe(true);
|
||||
expect(isDownloadPackageOrderChange(["a", "b"], ["a", "c"])).toBe(false);
|
||||
expect(getDownloadOrderTransitionPinnedIds({
|
||||
enabled: true,
|
||||
previousOrder: ["a", "b", "c"],
|
||||
nextOrder: ["b", "c", "a"],
|
||||
previousVisibleIds: ["a", "a:items", "b"],
|
||||
activePinnedIds: []
|
||||
})).toEqual(["a", "a:items", "b"]);
|
||||
expect(getDownloadOrderTransitionPinnedIds({
|
||||
enabled: false,
|
||||
previousOrder: ["a", "b", "c"],
|
||||
nextOrder: ["b", "c", "a"],
|
||||
previousVisibleIds: ["a", "b"],
|
||||
activePinnedIds: ["c"]
|
||||
})).toEqual([]);
|
||||
expect(getDownloadOrderTransformKeyframes(240, 159, 315)).toEqual([
|
||||
{ transform: "translateY(84px)" },
|
||||
{ transform: "translateY(240px)" }
|
||||
]);
|
||||
});
|
||||
|
||||
it("zeichnet den Startzustand in einem eigenen Frame vor der Aktivierung", () => {
|
||||
@@ -681,7 +711,7 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
|
||||
scheduleLabel: "",
|
||||
packageSpeedBps: { "package-a": 12_000_000 },
|
||||
disclosureRevision: 0,
|
||||
animatePackageDisclosure: true,
|
||||
animationsEnabled: true,
|
||||
editingPackageId: null,
|
||||
editingName: "",
|
||||
columnOrder: ["name", "size", "hoster", "progress"] as const,
|
||||
@@ -1177,6 +1207,16 @@ describe("downloads App integration", () => {
|
||||
expect(source).toMatch(/disabled=\{allMatch\}[\s\S]{0,260}window\.rd\.setPackagePriority/);
|
||||
});
|
||||
|
||||
it("keeps previously visible package rows mounted while priority order glides", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/views/downloads/VirtualizedDownloadsBody.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||
|
||||
expect(source).toContain("getDownloadOrderTransitionPinnedIds");
|
||||
expect(source).toContain("animateDownloadOrderRows");
|
||||
expect(source).toContain("orderTransitionPinnedIdsRef.current");
|
||||
expect(source).toContain("data-download-row-id={entry.id}");
|
||||
expect(source).toContain("DOWNLOAD_ORDER_TRANSITION_DURATION_MS");
|
||||
});
|
||||
|
||||
it("updates the clipboard checkbox optimistically before IPC reconciliation", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ describe("renderer localization", () => {
|
||||
expect(translateUiText("Einstellungen speichern", "en")).toBe("Save settings");
|
||||
expect(translateUiText("Save settings", "de")).toBe("Einstellungen speichern");
|
||||
expect(translateUiText("Passwort/Zugang", "en")).toBe("Password/access");
|
||||
expect(translateUiText("Animationen", "en")).toBe("Animations");
|
||||
expect(translateUiText("Animations", "de")).toBe("Animationen");
|
||||
});
|
||||
|
||||
it("translates dynamic update and pagination text", () => {
|
||||
|
||||
@@ -501,7 +501,7 @@ describe("settings views", () => {
|
||||
expect(settingsCss).toMatch(/\.settings-select\.is-open\s+\.settings-select-options\s*\{[^}]*opacity:\s*1[^}]*transform:\s*translateY\(0\)/s);
|
||||
});
|
||||
|
||||
it("offers a general switch for package expand and collapse motion", () => {
|
||||
it("offers one general switch for interface animations", () => {
|
||||
const form = buildSettingsFormViewModel({
|
||||
settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" },
|
||||
section: "allgemein",
|
||||
@@ -514,7 +514,7 @@ describe("settings views", () => {
|
||||
expect(animation).toEqual({
|
||||
id: "animatePackageDisclosure",
|
||||
kind: "switch",
|
||||
label: "Paket-Ein-/Ausklappen animieren",
|
||||
label: "Animationen",
|
||||
value: true
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,6 +192,27 @@ describe("visual fixtures", () => {
|
||||
expect(dense).toHaveProperty("remoteDiagnostics.status.running", false);
|
||||
});
|
||||
|
||||
it("emits the priority-sorted snapshot so visual tests can observe package motion", async () => {
|
||||
const dense = createVisualFixture("dense");
|
||||
const api = createVisualElectronApi(dense);
|
||||
const snapshots: string[][] = [];
|
||||
const unsubscribe = api.onStateUpdate((snapshot) => snapshots.push([...snapshot.session.packageOrder]));
|
||||
const target = dense.snapshot.session.packageOrder[0];
|
||||
|
||||
await api.setPackagePriority(target, "low");
|
||||
|
||||
expect(dense.snapshot.session.packageOrder.at(-1)).toBe(target);
|
||||
expect(snapshots.at(-1)?.at(-1)).toBe(target);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("can boot the isolated renderer with animations disabled", async () => {
|
||||
const dense = createVisualFixture("dense");
|
||||
const api = createVisualElectronApi(dense, "?animations=off");
|
||||
|
||||
expect((await api.getSnapshot()).settings.animatePackageDisclosure).toBe(false);
|
||||
});
|
||||
|
||||
it("boots the dense query once and waits for both visible package names", async () => {
|
||||
expect(typeof window).toBe("undefined");
|
||||
const harness = createTestVisualBootstrap(
|
||||
|
||||
@@ -12,11 +12,19 @@ export function createVisualElectronApi(
|
||||
fixture: VisualFixture,
|
||||
search = typeof window === "undefined" ? "" : window.location.search
|
||||
): ElectronApi {
|
||||
const historyState = new URLSearchParams(search).get("history-state");
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const historyState = searchParams.get("history-state");
|
||||
if (searchParams.get("animations") === "off") fixture.snapshot.settings.animatePackageDisclosure = false;
|
||||
let historyRequestCount = 0;
|
||||
const stateUpdateListeners = new Set<Parameters<ElectronApi["onStateUpdate"]>[0]>();
|
||||
const emitStateUpdate = (): void => {
|
||||
const snapshot = clone(fixture.snapshot);
|
||||
for (const listener of stateUpdateListeners) listener(snapshot);
|
||||
};
|
||||
const updateSettings = (settings: RendererSettingsUpdate): RendererSettings => {
|
||||
const { archivePasswordList: _archivePasswordList, notifyUrl: _notifyUrl, ...safe } = settings;
|
||||
Object.assign(fixture.snapshot.settings, safe);
|
||||
emitStateUpdate();
|
||||
return clone(fixture.snapshot.settings);
|
||||
};
|
||||
|
||||
@@ -376,9 +384,24 @@ export function createVisualElectronApi(
|
||||
: { ok: false, reason: "entry-not-found" },
|
||||
setPackagePriority: async (packageId, priority) => {
|
||||
const entry = fixture.snapshot.session.packages[packageId];
|
||||
if (entry) {
|
||||
entry.priority = priority;
|
||||
if (!entry || entry.priority === priority) return;
|
||||
entry.priority = priority;
|
||||
const order = fixture.snapshot.session.packageOrder;
|
||||
const currentIndex = order.indexOf(packageId);
|
||||
if (currentIndex >= 0) {
|
||||
order.splice(currentIndex, 1);
|
||||
const priorityRank = { high: 0, normal: 1, low: 2 } as const;
|
||||
let insertAt = order.length;
|
||||
for (let index = 0; index < order.length; index += 1) {
|
||||
const otherPriority = fixture.snapshot.session.packages[order[index]]?.priority || "normal";
|
||||
if (priorityRank[otherPriority] > priorityRank[priority]) {
|
||||
insertAt = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
order.splice(insertAt, 0, packageId);
|
||||
}
|
||||
emitStateUpdate();
|
||||
},
|
||||
skipItems: async (itemIds) => {
|
||||
for (const itemId of itemIds) {
|
||||
@@ -413,7 +436,10 @@ export function createVisualElectronApi(
|
||||
fixture.snapshot.session.running = true;
|
||||
},
|
||||
reportRendererError: () => {},
|
||||
onStateUpdate: () => stableNoopUnsubscribe,
|
||||
onStateUpdate: (callback) => {
|
||||
stateUpdateListeners.add(callback);
|
||||
return () => stateUpdateListeners.delete(callback);
|
||||
},
|
||||
onClipboardDetected: () => stableNoopUnsubscribe,
|
||||
onUpdateInstallProgress: () => stableNoopUnsubscribe
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user