Backup: nur Settings als Default + 4 Selektions/Flicker-Bugfixes
Backup:
- Neues Setting backupIncludeDownloads (Default aus) — Backup sichert
standardmaessig NUR Einstellungen, nicht die Download-Liste/History.
- buildBackupPayload/planBackupImport (testbare backup-payload.ts): Export
omittet session+history wenn Flag aus (explizites kind-Marker); Import folgt
dem FILE-Inhalt, nicht dem lokalen Toggle.
- importBackup: settings-only -> frueher Return nach setSettings, KEIN stop/
Queue-Wipe/Relaunch. Return {restored,relaunch,message}; main.ts gated den
Auto-Relaunch auf relaunch. Renderer re-seeded settingsDraft bei !relaunch.
Bugfixes:
- Ctrl+A waehlte das ungefilterte Paket-Map -> Loeschen nach Suche traf
versteckte Pakete. Jetzt visibleOrderIds (sichtbare Zeilen, inkl. Items).
- selectedIds nie geprunt bei Delta-Removal -> aufgeblaehte Counts. Neue pure
pruneSelection (selection.ts) + Effect.
- link-status-dot conditional -> Dateiname sprang ~14px. Platzhalter-Slot.
- sortPackagesForDisplay sortierte aktive Pakete nach Live-Progress -> Reshuffle
pro Tick. Jetzt stabile Queue-Reihenfolge je Gruppe (Anti-Flicker).
+17 Tests (backup-payload 9, selection 5, package-order anti-flicker 3).
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
|
||||
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
|
||||
|
||||
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
|
||||
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
|
||||
}
|
||||
|
||||
const session: SessionState = {
|
||||
version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never },
|
||||
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
|
||||
reconnectReason: "", paused: false, running: true, updatedAt: 0
|
||||
};
|
||||
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
|
||||
|
||||
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
|
||||
|
||||
describe("buildBackupPayload — default is settings-only", () => {
|
||||
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
|
||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||
expect(p.kind).toBe("settings-only");
|
||||
expect(p.session).toBeUndefined();
|
||||
expect(p.history).toBeUndefined();
|
||||
expect(p.settings).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes session + history when backupIncludeDownloads is true", () => {
|
||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
||||
expect(p.kind).toBe("full");
|
||||
expect(p.session).toBe(session);
|
||||
expect(p.history).toBe(history);
|
||||
});
|
||||
|
||||
it("treats a missing flag as settings-only (safe default)", () => {
|
||||
const p = buildBackupPayload({ ...baseInput, settings: {} as AppSettings });
|
||||
expect(p.kind).toBe("settings-only");
|
||||
expect(p.session).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ROUND-TRIP: toggle off -> exported payload carries the flag still false", () => {
|
||||
// "Haken aus bleibt aus": the exported settings object preserves the flag,
|
||||
// so importing it keeps the toggle off.
|
||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||
expect((p.settings as AppSettings).backupIncludeDownloads).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planBackupImport — decision follows the file, not the local toggle", () => {
|
||||
it("settings-only backup (no session) -> restore settings only, no relaunch", () => {
|
||||
const plan = planBackupImport({ version: 2, kind: "settings-only", settings: { theme: "dark" } });
|
||||
expect(plan.valid).toBe(true);
|
||||
expect(plan.restoreDownloads).toBe(false);
|
||||
expect(plan.message).toMatch(/Einstellungen/);
|
||||
});
|
||||
|
||||
it("full backup (with session) -> restore downloads + relaunch", () => {
|
||||
const plan = planBackupImport({ version: 2, kind: "full", settings: { theme: "dark" }, session });
|
||||
expect(plan.valid).toBe(true);
|
||||
expect(plan.restoreDownloads).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects payloads without settings", () => {
|
||||
expect(planBackupImport({ session }).valid).toBe(false);
|
||||
expect(planBackupImport(null).valid).toBe(false);
|
||||
expect(planBackupImport("nope").valid).toBe(false);
|
||||
expect(planBackupImport({}).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("a settings-only export then import does NOT pull in the download list", () => {
|
||||
// Build with toggle off, then plan the import of exactly that payload.
|
||||
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
|
||||
expect(plan.restoreDownloads).toBe(false); // queue stays untouched
|
||||
});
|
||||
|
||||
it("a full export then import DOES restore the download list", () => {
|
||||
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
||||
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
|
||||
expect(plan.restoreDownloads).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -44,23 +44,50 @@ function createItem(id: string, packageId: string, status: DownloadItem["status"
|
||||
}
|
||||
|
||||
describe("sortPackagesForDisplay", () => {
|
||||
it("moves active packages with more progress to the top when auto sort is enabled", () => {
|
||||
it("floats active packages to the top, keeping queue order within each group", () => {
|
||||
// pkg-a and pkg-b both have an active (downloading) item -> both float up in
|
||||
// their original queue order; pkg-c (queued only) sinks below.
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1", "a2"]),
|
||||
createPackage("pkg-b", ["b1", "b2"]),
|
||||
createPackage("pkg-c", ["c1"])
|
||||
createPackage("pkg-c", ["c1"]),
|
||||
createPackage("pkg-b", ["b1", "b2"])
|
||||
];
|
||||
const items: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "downloading", 250),
|
||||
a2: createItem("a2", "pkg-a", "completed", 500),
|
||||
c1: createItem("c1", "pkg-c", "queued", 0),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 800),
|
||||
b2: createItem("b2", "pkg-b", "completed", 900),
|
||||
c1: createItem("c1", "pkg-c", "queued", 0)
|
||||
b2: createItem("b2", "pkg-b", "completed", 900)
|
||||
};
|
||||
|
||||
const sorted = sortPackagesForDisplay(packages, items, true, true);
|
||||
|
||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-a", "pkg-c"]);
|
||||
// active group [pkg-a, pkg-b] in queue order, then rest [pkg-c]
|
||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
|
||||
});
|
||||
|
||||
it("does NOT reshuffle active packages when only their progress changes (anti-flicker)", () => {
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1"]),
|
||||
createPackage("pkg-b", ["b1"])
|
||||
];
|
||||
// Both active. pkg-b initially has more bytes than pkg-a.
|
||||
const before: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "downloading", 100),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 900)
|
||||
};
|
||||
const orderBefore = sortPackagesForDisplay(packages, before, true, true).map((p) => p.id);
|
||||
|
||||
// A progress tick: pkg-a overtakes pkg-b in bytes. Order must NOT change —
|
||||
// both are still active, so they keep queue order. (Old code swapped them.)
|
||||
const after: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "downloading", 5000),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 950)
|
||||
};
|
||||
const orderAfter = sortPackagesForDisplay(packages, after, true, true).map((p) => p.id);
|
||||
|
||||
expect(orderBefore).toEqual(["pkg-a", "pkg-b"]);
|
||||
expect(orderAfter).toEqual(orderBefore);
|
||||
});
|
||||
|
||||
it("keeps package order untouched when auto sort is disabled", () => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pruneSelection } from "../src/renderer/selection";
|
||||
import type { SessionState } from "../src/shared/types";
|
||||
|
||||
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
|
||||
const packages: Record<string, never> = {};
|
||||
const items: Record<string, never> = {};
|
||||
for (const id of packageIds) packages[id] = {} as never;
|
||||
for (const id of itemIds) items[id] = {} as never;
|
||||
return { packages, items };
|
||||
}
|
||||
|
||||
describe("pruneSelection", () => {
|
||||
it("drops ids whose package/item no longer exists", () => {
|
||||
const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]);
|
||||
const next = pruneSelection(sel, session(["p1"], ["i1"]));
|
||||
expect([...next].sort()).toEqual(["i1", "p1"]);
|
||||
});
|
||||
|
||||
it("returns the SAME set instance when nothing changed (no needless re-render)", () => {
|
||||
const sel = new Set(["p1", "i1"]);
|
||||
const next = pruneSelection(sel, session(["p1"], ["i1"]));
|
||||
expect(next).toBe(sel);
|
||||
});
|
||||
|
||||
it("returns the same instance for an empty selection", () => {
|
||||
const sel = new Set<string>();
|
||||
expect(pruneSelection(sel, session(["p1"], ["i1"]))).toBe(sel);
|
||||
});
|
||||
|
||||
it("prunes everything when the whole session was swapped out", () => {
|
||||
const sel = new Set(["p1", "i1"]);
|
||||
const next = pruneSelection(sel, session([], []));
|
||||
expect(next.size).toBe(0);
|
||||
expect(next).not.toBe(sel);
|
||||
});
|
||||
|
||||
it("keeps a mixed package+item selection when both survive", () => {
|
||||
const sel = new Set(["p1", "p2", "i1"]);
|
||||
const next = pruneSelection(sel, session(["p1", "p2"], ["i1", "i2"]));
|
||||
expect([...next].sort()).toEqual(["i1", "p1", "p2"]);
|
||||
expect(next).toBe(sel); // unchanged → same instance
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user