Prepare v2.0.74 sidepanel reliability release

Restore and persist the package-based link collector with progressive metadata, bounded high-volume updates, safe hydration, visible selection, and complete localization.

Harden download controls, snapshot ordering, history pagination, statistics recovery, settings saves, backup imports, notification persistence, and Windows storage races with regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-26 20:35:06 +02:00
parent bdcf9e3754
commit 2ea494cd5c
69 changed files with 7660 additions and 883 deletions
+12 -2
View File
@@ -134,16 +134,26 @@ describe("account preload contract", () => {
it("loads and saves the persistent collector state through dedicated channels", async () => {
const state = { packages: [], collapsedPackageIds: [] };
electron.sendSync.mockReturnValueOnce(state);
electron.sendSync.mockReturnValueOnce(true);
await electron.api?.getCollectorState();
const syncState = (electron.api as ElectronApi & {
getCollectorStateSync: () => typeof state;
}).getCollectorStateSync();
await electron.api?.saveCollectorState(state);
electron.api?.saveCollectorStateSync(state);
const savedSync = electron.api?.saveCollectorStateSync(state);
expect(electron.invoke.mock.calls).toEqual([
[IPC_CHANNELS.GET_COLLECTOR_STATE],
[IPC_CHANNELS.SAVE_COLLECTOR_STATE, state]
]);
expect(electron.sendSync).toHaveBeenCalledWith(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state);
expect(syncState).toEqual(state);
expect(savedSync).toBe(true);
expect(electron.sendSync.mock.calls).toEqual([
[IPC_CHANNELS.GET_COLLECTOR_STATE_SYNC],
[IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state]
]);
});
it("resolves dropped files through Electron webUtils without IPC", () => {
+556 -5
View File
@@ -6,13 +6,30 @@ import { AppController } from "../src/main/app-controller";
import { defaultSettings } from "../src/main/constants";
import { configureCredentialProtector } from "../src/main/credential-protection";
import { prepareDailyStartSettingsPatch } from "../src/main/daily-start-scheduler";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { logger } from "../src/main/logger";
import { createStatisticsLedger, loadStatisticsLedger, saveStatisticsLedger } from "../src/main/statistics-ledger";
import { createStoragePaths, emptySession, loadHistory, loadSession, loadSettings, saveHistory, saveSession, saveSettings } from "../src/main/storage";
import type { CollectorPersistenceState } from "../src/shared/collector";
import type { AppSettings, SessionState } from "../src/shared/types";
const appState = vi.hoisted(() => ({ userDataDir: "C:\\MDD\\Test" }));
const bootStorage = vi.hoisted(() => ({
settings: null as unknown,
loadResult: null as unknown
loadResult: null as unknown,
historyResetError: null as Error | null,
historyClearError: null as Error | null,
settingsSaveError: null as Error | null,
sessionSaveError: null as Error | null,
rollbackCreateError: null as Error | null,
barrierAcquisitions: 0,
barrierReleases: [] as boolean[],
barrierReleaseHook: null as ((replayBlocked: boolean) => void) | null
}));
const statisticsStorage = vi.hoisted(() => ({ saveError: null as Error | null }));
const debugStorage = vi.hoisted(() => ({
configError: null as Error | null,
restartCalls: 0,
restartErrors: [] as Error[]
}));
vi.mock("electron", () => ({
@@ -34,7 +51,28 @@ vi.mock("../src/main/debug-server", async () => {
return {
...actual,
startDebugServer: vi.fn(),
stopDebugServer: vi.fn()
stopDebugServer: vi.fn(),
restartDebugServer: vi.fn(async () => {
debugStorage.restartCalls += 1;
const error = debugStorage.restartErrors.shift();
if (error) throw error;
return actual.getDebugServerRuntimeStatus();
}),
writeDebugServerConfig: (...args: Parameters<typeof actual.writeDebugServerConfig>) => {
if (debugStorage.configError) throw debugStorage.configError;
return actual.writeDebugServerConfig(...args);
}
};
});
vi.mock("../src/main/statistics-ledger", async () => {
const actual = await vi.importActual<typeof import("../src/main/statistics-ledger")>("../src/main/statistics-ledger");
return {
...actual,
saveStatisticsLedger: (...args: Parameters<typeof actual.saveStatisticsLedger>) => {
if (statisticsStorage.saveError) throw statisticsStorage.saveError;
return actual.saveStatisticsLedger(...args);
}
};
});
@@ -47,7 +85,36 @@ vi.mock("../src/main/storage", async () => {
: actual.loadSettings(...args),
loadSessionWithStatus: (...args: Parameters<typeof actual.loadSessionWithStatus>) => bootStorage.loadResult
? bootStorage.loadResult as ReturnType<typeof actual.loadSessionWithStatus>
: actual.loadSessionWithStatus(...args)
: actual.loadSessionWithStatus(...args),
resetHistoryForRetention: (...args: Parameters<typeof actual.resetHistoryForRetention>) => {
if (bootStorage.historyResetError) throw bootStorage.historyResetError;
return actual.resetHistoryForRetention(...args);
},
clearHistory: (...args: Parameters<typeof actual.clearHistory>) => {
if (bootStorage.historyClearError) throw bootStorage.historyClearError;
return actual.clearHistory(...args);
},
acquirePersistenceBarrier: async () => {
bootStorage.barrierAcquisitions += 1;
return {
release: async ({ replayBlocked }: { replayBlocked: boolean }) => {
bootStorage.barrierReleases.push(replayBlocked);
bootStorage.barrierReleaseHook?.(replayBlocked);
}
};
},
createFileRollback: (...args: Parameters<typeof actual.createFileRollback>) => {
if (bootStorage.rollbackCreateError) throw bootStorage.rollbackCreateError;
return actual.createFileRollback(...args);
},
saveSettings: (...args: Parameters<typeof actual.saveSettings>) => {
if (bootStorage.settingsSaveError) throw bootStorage.settingsSaveError;
return actual.saveSettings(...args);
},
saveSession: (...args: Parameters<typeof actual.saveSession>) => {
if (bootStorage.sessionSaveError) throw bootStorage.sessionSaveError;
return actual.saveSession(...args);
}
};
});
@@ -60,7 +127,7 @@ function createController(settings: AppSettings): AppController {
const controller = Object.create(AppController.prototype) as any;
controller.settings = settings;
controller.storagePaths = createStoragePaths(dir);
controller.manager = { setSettings: vi.fn() };
controller.manager = { setSettings: vi.fn(), persistNowSync: vi.fn() };
controller.audit = vi.fn();
controller.overlayLiveUsageCounters = vi.fn();
controller.pruneRealDebridWebFallbacks = vi.fn();
@@ -123,12 +190,496 @@ afterEach(async () => {
}
bootStorage.settings = null;
bootStorage.loadResult = null;
bootStorage.historyResetError = null;
bootStorage.historyClearError = null;
bootStorage.settingsSaveError = null;
bootStorage.sessionSaveError = null;
bootStorage.rollbackCreateError = null;
bootStorage.barrierAcquisitions = 0;
bootStorage.barrierReleases = [];
bootStorage.barrierReleaseHook = null;
statisticsStorage.saveError = null;
debugStorage.configError = null;
debugStorage.restartCalls = 0;
debugStorage.restartErrors = [];
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("AppController collector persistence", () => {
it("updates and synchronously flushes the latest collector state before returning", () => {
const state: CollectorPersistenceState = { packages: [], collapsedPackageIds: [] };
const operations: string[] = [];
const controller = Object.create(AppController.prototype) as any;
controller.collectorStore = {
update: (value: CollectorPersistenceState) => {
expect(value).toEqual(state);
operations.push("update");
},
flushSync: () => { operations.push("flush"); },
getState: () => {
operations.push("get");
return structuredClone(state);
}
};
expect(controller.saveCollectorStateSync(state)).toEqual(state);
expect(operations).toEqual(["update", "flush", "get"]);
});
});
describe("AppController full backup history restore", () => {
it("clears existing history when a full backup explicitly contains an empty history", async () => {
const settings = { ...defaultSettings(), backupIncludeDownloads: true, historyRetentionMode: "permanent" as const };
const controller = createController(settings) as any;
controller.manager = {
setSettings: vi.fn(),
stop: vi.fn(),
abortAllPostProcessing: vi.fn(),
clearPersistTimer: vi.fn(),
skipShutdownPersist: false,
blockAllPersistence: false
};
controller.restoreRemoteDiagnosticsFromBackup = vi.fn();
saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]);
const payload = Buffer.from(JSON.stringify({
version: 2,
kind: "full",
settings,
session: emptySession(),
history: []
}), "utf8");
expect((await controller.importBackup(payload)).restored).toBe(true);
expect(bootStorage.barrierAcquisitions).toBe(1);
expect(bootStorage.barrierReleases).toEqual([false]);
expect(loadHistory(controller.storagePaths)).toEqual([]);
expect(JSON.parse(fs.readFileSync(`${controller.storagePaths.historyFile}.bak`, "utf8"))).toEqual([]);
});
it("rejects a nonempty backup history when every entry is invalid", async () => {
const settings = { ...defaultSettings(), backupIncludeDownloads: true, historyRetentionMode: "permanent" as const };
const controller = createController(settings) as any;
controller.manager = { setSettings: vi.fn() };
saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]);
const payload = Buffer.from(JSON.stringify({
version: 2,
kind: "full",
settings,
session: emptySession(),
history: [null]
}), "utf8");
await expect(controller.importBackup(payload)).resolves.toMatchObject({ restored: false });
expect(loadHistory(controller.storagePaths).map((entry) => entry.id)).toEqual(["existing"]);
});
it("keeps settings, session ownership, and existing history untouched when authoritative history replacement fails", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", backupIncludeDownloads: true, historyRetentionMode: "permanent" as const };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
const controller = createController(currentSettings) as any;
controller.manager = {
setSettings: vi.fn(),
stop: vi.fn(),
abortAllPostProcessing: vi.fn(),
clearPersistTimer: vi.fn(),
skipShutdownPersist: false,
blockAllPersistence: false
};
controller.restoreRemoteDiagnosticsFromBackup = vi.fn();
saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]);
const payload = Buffer.from(JSON.stringify({
version: 2,
kind: "full",
settings: importedSettings,
session: emptySession(),
history: [{ id: "imported", name: "Neu" }]
}), "utf8");
const renameFile = fs.renameSync.bind(fs);
let failed = false;
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => {
if (!failed && String(destination) === controller.storagePaths.historyFile) {
failed = true;
throw Object.assign(new Error("history locked"), { code: "EPERM" });
}
return renameFile(source, destination);
});
try {
await expect(controller.importBackup(payload)).rejects.toThrow("history locked");
} finally {
rename.mockRestore();
}
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(controller.manager.stop).not.toHaveBeenCalled();
expect(loadHistory(controller.storagePaths).map((entry) => entry.id)).toEqual(["existing"]);
expect(bootStorage.barrierReleases).toEqual([true]);
});
it("restores existing history when a later full-import write fails", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", backupIncludeDownloads: true, historyRetentionMode: "permanent" as const };
const controller = createController(currentSettings) as any;
controller.manager = {
setSettings: vi.fn(),
stop: vi.fn(),
abortAllPostProcessing: vi.fn(),
clearPersistTimer: vi.fn(),
skipShutdownPersist: false,
blockAllPersistence: false
};
controller.restoreRemoteDiagnosticsFromBackup = vi.fn();
saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]);
bootStorage.settingsSaveError = new Error("settings locked");
const payload = Buffer.from(JSON.stringify({
version: 2,
kind: "full",
settings: { ...currentSettings, outputDir: "C:\\Imported" },
session: emptySession(),
history: [{ id: "imported", name: "Neu" }]
}), "utf8");
await expect(controller.importBackup(payload)).rejects.toThrow("settings locked");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(loadHistory(controller.storagePaths).map((entry) => entry.id)).toEqual(["existing"]);
});
it.each(["session", "statistics", "remote"] as const)("rolls back every persisted and runtime surface when %s persistence fails", async (failure) => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", backupIncludeDownloads: true, historyRetentionMode: "permanent" as const };
const controller = createController(currentSettings) as any;
controller.manager = {
setSettings: vi.fn(),
stop: vi.fn(),
abortAllPostProcessing: vi.fn(),
clearPersistTimer: vi.fn(),
skipShutdownPersist: false,
blockAllPersistence: false
};
const currentSession = queuedSession();
saveSettings(controller.storagePaths, currentSettings);
saveSession(controller.storagePaths, currentSession);
saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]);
saveStatisticsLedger(controller.storagePaths.statisticsFile, createStatisticsLedger(1_000));
for (const [name, value] of [["debug_host.txt", "127.0.0.1\n"], ["debug_port.txt", "9868\n"], ["debug_allowlist.txt", "192.0.2.1\n"]]) {
fs.writeFileSync(path.join(controller.storagePaths.baseDir, name), value, "utf8");
}
if (failure === "session") bootStorage.sessionSaveError = new Error("session locked");
if (failure === "statistics") statisticsStorage.saveError = new Error("statistics locked");
if (failure === "remote") debugStorage.configError = new Error("remote locked");
const importedSession = emptySession();
importedSession.packageOrder = ["imported-package"];
const payload = Buffer.from(JSON.stringify({
version: 2,
kind: "full",
settings: { ...currentSettings, outputDir: "C:\\Imported" },
session: importedSession,
history: [{ id: "imported", name: "Neu" }],
statistics: createStatisticsLedger(2_000),
remoteDiagnostics: { allowlist: ["198.51.100.8"] }
}), "utf8");
await expect(controller.importBackup(payload)).rejects.toThrow(`${failure} locked`);
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(controller.manager.stop).not.toHaveBeenCalled();
expect(controller.manager.abortAllPostProcessing).not.toHaveBeenCalled();
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current");
expect(loadSession(controller.storagePaths).packageOrder).toEqual(["pkg"]);
expect(loadHistory(controller.storagePaths).map((entry) => entry.id)).toEqual(["existing"]);
expect(loadStatisticsLedger(controller.storagePaths.statisticsFile).startedAt).toBe(1_000);
expect(fs.readFileSync(path.join(controller.storagePaths.baseDir, "debug_allowlist.txt"), "utf8")).toBe("192.0.2.1\n");
});
it("restores runtime settings and persistence flags when full-import finalization fails", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", backupIncludeDownloads: true };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
const controller = createController(currentSettings) as any;
controller.manager = {
setSettings: vi.fn(),
stop: vi.fn(() => { throw new Error("stop failed"); }),
abortAllPostProcessing: vi.fn(),
clearPersistTimer: vi.fn(),
skipShutdownPersist: false,
blockAllPersistence: false
};
saveSettings(controller.storagePaths, currentSettings);
saveSession(controller.storagePaths, queuedSession());
const payload = Buffer.from(JSON.stringify({
version: 2,
kind: "full",
settings: importedSettings,
session: emptySession(),
history: []
}), "utf8");
await expect(controller.importBackup(payload)).rejects.toThrow("stop failed");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).toHaveBeenNthCalledWith(1, expect.objectContaining({ outputDir: "C:\\Imported" }));
expect(controller.manager.setSettings).toHaveBeenNthCalledWith(2, currentSettings, { settingsOnlyImport: true });
expect(controller.manager.skipShutdownPersist).toBe(false);
expect(controller.manager.blockAllPersistence).toBe(false);
expect(bootStorage.barrierReleases).toEqual([true]);
expect(controller.audit).not.toHaveBeenCalled();
});
});
describe("AppController settings-only backup transactions", () => {
it("applies runtime settings before releasing the successful import barrier", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
const controller = createController(currentSettings) as any;
const events: string[] = [];
controller.manager.setSettings = vi.fn(() => { events.push("runtime"); });
bootStorage.barrierReleaseHook = (replayBlocked) => { events.push(`release:${replayBlocked}`); };
await controller.applySettingsOnlyBackup(importedSettings);
expect(controller.getSettings().outputDir).toBe("C:\\Imported");
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Imported");
expect(events).toEqual(["runtime", "release:false"]);
expect(bootStorage.barrierAcquisitions).toBe(1);
});
it("persists the current live session before discarding blocked saves after a settings-only import", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
const controller = createController(currentSettings) as any;
const staleSession = { ...emptySession(), summaryText: "before remote restart" };
const liveSession = { ...emptySession(), summaryText: "completed during remote restart" };
const events: string[] = [];
saveSession(controller.storagePaths, staleSession);
controller.manager = {
setSettings: vi.fn(() => { events.push("runtime"); }),
persistNowSync: vi.fn(() => {
events.push("session");
saveSession(controller.storagePaths, liveSession);
})
};
bootStorage.barrierReleaseHook = (replayBlocked) => { events.push(`release:${replayBlocked}`); };
await controller.applySettingsOnlyBackup(importedSettings);
expect(loadSession(controller.storagePaths).summaryText).toBe("completed during remote restart");
expect(events).toEqual(["runtime", "session", "release:false"]);
});
it("rolls back a settings-only import when the final live-session flush fails", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
const controller = createController(currentSettings) as any;
controller.manager = {
setSettings: vi.fn(),
persistNowSync: vi.fn(() => { throw new Error("live session flush failed"); })
};
saveSettings(controller.storagePaths, currentSettings);
await expect(controller.applySettingsOnlyBackup(importedSettings)).rejects.toThrow("live session flush failed");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).toHaveBeenNthCalledWith(1, expect.objectContaining({ outputDir: "C:\\Imported" }), { settingsOnlyImport: true });
expect(controller.manager.setSettings).toHaveBeenNthCalledWith(2, currentSettings, { settingsOnlyImport: true });
expect(bootStorage.barrierReleases).toEqual([true]);
expect(controller.audit).not.toHaveBeenCalled();
});
it("releases the persistence barrier when the rollback snapshot cannot be created", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const controller = createController(currentSettings) as any;
bootStorage.rollbackCreateError = new Error("rollback snapshot unreadable");
await expect(controller.applySettingsOnlyBackup({ ...currentSettings, outputDir: "C:\\Imported" }))
.rejects.toThrow("rollback snapshot unreadable");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(bootStorage.barrierAcquisitions).toBe(1);
expect(bootStorage.barrierReleases).toEqual([true]);
});
it("does not reconfigure logs or runtime settings before settings persistence succeeds", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", logStorageLocation: "appdata" as const };
const controller = createController(currentSettings) as any;
controller.logDirectory = "C:\\CurrentLogs";
controller.reconfigureLogStorage = vi.fn(() => {
controller.logDirectory = "C:\\ChangedLogs";
return true;
});
saveSettings(controller.storagePaths, currentSettings);
bootStorage.settingsSaveError = new Error("settings-only locked");
await expect(controller.applySettingsOnlyBackup({ ...currentSettings, outputDir: "C:\\Imported", logStorageLocation: "desktop" })).rejects.toThrow("settings-only locked");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(controller.logDirectory).toBe("C:\\CurrentLogs");
expect(controller.reconfigureLogStorage).not.toHaveBeenCalled();
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current");
});
it("rolls back settings when remote diagnostics persistence fails", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const controller = createController(currentSettings) as any;
saveSettings(controller.storagePaths, currentSettings);
fs.writeFileSync(path.join(controller.storagePaths.baseDir, "debug_allowlist.txt"), "192.0.2.1\n", "utf8");
debugStorage.configError = new Error("remote settings-only locked");
await expect(controller.applySettingsOnlyBackup(
{ ...currentSettings, outputDir: "C:\\Imported" },
{ allowlist: ["198.51.100.8"] },
true
)).rejects.toThrow("remote settings-only locked");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current");
expect(fs.readFileSync(path.join(controller.storagePaths.baseDir, "debug_allowlist.txt"), "utf8")).toBe("192.0.2.1\n");
});
it("rejects and rolls back a settings-only import when the restored debug server cannot start", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
const controller = createController(currentSettings) as any;
controller.persistRemoteDiagnosticsFromBackup = vi.fn(() => ({
host: "0.0.0.0",
port: 9876,
allowlist: ["198.51.100.8"]
}));
saveSettings(controller.storagePaths, currentSettings);
debugStorage.restartErrors = [new Error("imported debug server failed")];
await expect(controller.applySettingsOnlyBackup(
importedSettings,
{ allowlist: ["198.51.100.8"] },
true
)).rejects.toThrow("imported debug server failed");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(debugStorage.restartCalls).toBe(2);
expect(controller.audit).not.toHaveBeenCalled();
expect(bootStorage.barrierAcquisitions).toBe(1);
expect(bootStorage.barrierReleases).toEqual([true]);
});
});
describe("AppController history failure isolation", () => {
it("does not abort package finalization when primary and backup history are unreadable", () => {
const controller = createController({ ...defaultSettings(), historyRetentionMode: "permanent" }) as any;
fs.writeFileSync(controller.storagePaths.historyFile, "{broken", "utf8");
fs.writeFileSync(`${controller.storagePaths.historyFile}.bak`, "{also-broken", "utf8");
expect(() => controller.recordHistoryEntry({ id: "history-failure", name: "Paket" } as never)).not.toThrow();
});
});
describe("AppController history retention lifecycle", () => {
function prepareBoot(retentionMode: "permanent" | "session"): void {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-history-lifecycle-"));
tempDirs.push(root);
appState.userDataDir = root;
bootStorage.settings = { ...defaultSettings(), historyRetentionMode: retentionMode } satisfies AppSettings;
bootStorage.loadResult = { session: emptySession(), status: "ok", wasRunning: false };
}
it("logs and tolerates retention cleanup failures during startup", () => {
prepareBoot("session");
bootStorage.historyResetError = Object.assign(new Error("startup history busy"), { code: "EBUSY" });
const warning = vi.spyOn(logger, "warn").mockImplementation(() => {});
let controller: AppController | null = null;
expect(() => { controller = new AppController(); }).not.toThrow();
expect(warning).toHaveBeenCalledWith(expect.stringContaining("startup history busy"));
if (controller) liveControllers.push(controller);
});
it("logs and tolerates retention cleanup failures during shutdown", async () => {
prepareBoot("permanent");
const controller = new AppController();
(controller as any).settings.historyRetentionMode = "session";
bootStorage.historyClearError = Object.assign(new Error("shutdown history busy"), { code: "EBUSY" });
const warning = vi.spyOn(logger, "warn").mockImplementation(() => {});
await expect(controller.shutdown()).resolves.toBeUndefined();
expect(warning).toHaveBeenCalledWith(expect.stringContaining("shutdown history busy"));
});
});
describe("AppController history retention updates", () => {
it("keeps the previous controller state when retention cleanup fails", () => {
const controller = createController({ ...defaultSettings(), historyRetentionMode: "permanent" });
bootStorage.historyResetError = Object.assign(new Error("retention history busy"), { code: "EBUSY" });
expect(() => controller.updateSettings({ historyRetentionMode: "session" })).toThrow("retention history busy");
expect(controller.getSettings().historyRetentionMode).toBe("permanent");
expect((controller as any).manager.setSettings).not.toHaveBeenCalled();
});
it("restores history when settings persistence fails after retention cleanup", () => {
const controller = createController({ ...defaultSettings(), historyRetentionMode: "permanent" });
saveHistory((controller as any).storagePaths, [{ id: "kept", name: "Behalten" } as never]);
bootStorage.settingsSaveError = new Error("settings history locked");
expect(() => controller.updateSettings({ historyRetentionMode: "session" })).toThrow("settings history locked");
expect(controller.getSettings().historyRetentionMode).toBe("permanent");
expect(loadHistory((controller as any).storagePaths).map((entry) => entry.id)).toEqual(["kept"]);
});
});
describe("AppController provider quota reset statistics", () => {
it("persists quota settings before rebasing statistics and applying runtime settings", () => {
const settings = {
...defaultSettings(),
providerDailyUsageBytes: { realdebrid: 1_024 }
};
const controller = createController(settings) as any;
controller.manager = {
getSettings: vi.fn(() => settings),
rebaseStatisticsProviderDailyUsage: vi.fn(),
setSettings: vi.fn()
};
const result = controller.resetProviderDailyUsage("realdebrid");
expect(result.providerDailyUsageBytes.realdebrid ?? 0).toBe(0);
expect(controller.manager.rebaseStatisticsProviderDailyUsage).toHaveBeenCalledWith("realdebrid", 0);
expect(controller.manager.rebaseStatisticsProviderDailyUsage.mock.invocationCallOrder[0])
.toBeLessThan(controller.manager.setSettings.mock.invocationCallOrder[0]);
});
it("rolls back quota settings when statistics rebasing cannot be persisted", () => {
const settings = {
...defaultSettings(),
providerDailyUsageBytes: { realdebrid: 1_024 }
};
const controller = createController(settings) as any;
controller.manager = {
getSettings: vi.fn(() => settings),
rebaseStatisticsProviderDailyUsage: vi.fn(() => { throw new Error("rebase ledger locked"); }),
setSettings: vi.fn()
};
saveSettings(controller.storagePaths, settings);
expect(() => controller.resetProviderDailyUsage("realdebrid")).toThrow("rebase ledger locked");
expect(controller.getSettings().providerDailyUsageBytes.realdebrid).toBe(1_024);
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(loadSettings(controller.storagePaths).providerDailyUsageBytes.realdebrid).toBe(1_024);
});
});
describe("AppController daily start settings", () => {
it("does not clear a legacy one-time schedule for an unprepared daily settings patch", () => {
configureCredentialProtector({
+193 -1
View File
@@ -3,11 +3,203 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/AvatarMenu";
import { AppHeader } from "../src/renderer/shell/AppHeader";
import { AppShell } from "../src/renderer/shell/AppShell";
import { AppShell } from "../src/renderer/shell/AppShell";
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
import { getSnapshotRenderDelay } from "../src/renderer/App";
import * as appModule from "../src/renderer/App";
import { createVisualFixture } from "./visual/fixtures";
describe("desktop shell", () => {
it("keeps delta-only startup state behind the loading gate", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const coordinator = appModule.createSnapshotBootstrapCoordinator() as ReturnType<typeof appModule.createSnapshotBootstrapCoordinator> & {
getStatus?: () => { phase: string; message: string };
};
const delta = structuredClone(createVisualFixture("dense").snapshot);
delta.payloadKind = "delta";
expect(coordinator.push(delta)).toBeNull();
expect(coordinator.getStatus).toBeTypeOf("function");
if (!coordinator.getStatus) return;
expect(coordinator.getStatus()).toEqual({ phase: "loading", message: "" });
expect(source).toContain('if (snapshotBootstrapStatus.phase !== "ready")');
expect(source).toContain("Anwendungsdaten werden geladen");
expect(source.indexOf('if (snapshotBootstrapStatus.phase !== "ready")')).toBeLessThan(source.indexOf("<AppShell"));
});
it("accepts an early full stream snapshot as authoritative startup state", () => {
const coordinator = appModule.createSnapshotBootstrapCoordinator() as ReturnType<typeof appModule.createSnapshotBootstrapCoordinator> & {
getStatus?: () => { phase: string; message: string };
};
const full = structuredClone(createVisualFixture("dense").snapshot);
full.payloadKind = "full";
expect(coordinator.push(full)).toEqual(full);
expect(coordinator.getStatus).toBeTypeOf("function");
if (!coordinator.getStatus) return;
expect(coordinator.getStatus()).toEqual({ phase: "ready", message: "" });
});
it("marks the initial snapshot as authoritative after replaying queued deltas", () => {
const coordinator = appModule.createSnapshotBootstrapCoordinator() as ReturnType<typeof appModule.createSnapshotBootstrapCoordinator> & {
getStatus?: () => { phase: string; message: string };
};
const initial = structuredClone(createVisualFixture("dense").snapshot);
const delta = structuredClone(initial);
delta.payloadKind = "delta";
delta.session.running = true;
expect(coordinator.push(delta)).toBeNull();
expect(coordinator.initialize(initial).session.running).toBe(true);
expect(coordinator.getStatus).toBeTypeOf("function");
if (!coordinator.getStatus) return;
expect(coordinator.getStatus()).toEqual({ phase: "ready", message: "" });
});
it("shows a retryable startup error without promoting placeholder data", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const coordinator = appModule.createSnapshotBootstrapCoordinator() as ReturnType<typeof appModule.createSnapshotBootstrapCoordinator> & {
fail?: (message: string) => { phase: string; message: string };
getStatus?: () => { phase: string; message: string };
};
expect(coordinator.fail).toBeTypeOf("function");
if (!coordinator.fail || !coordinator.getStatus) return;
expect(coordinator.fail("Snapshot nicht erreichbar")).toEqual({ phase: "error", message: "Snapshot nicht erreichbar" });
expect(coordinator.getStatus()).toEqual({ phase: "error", message: "Snapshot nicht erreichbar" });
expect(source).toContain('snapshotBootstrapStatus.phase === "error"');
expect(source).toContain('onClick={() => retrySnapshotBootstrapRef.current()}');
expect(source).toContain("Erneut versuchen");
});
it("retries a failed snapshot load until authoritative state arrives", () => {
const coordinator = appModule.createSnapshotBootstrapCoordinator() as ReturnType<typeof appModule.createSnapshotBootstrapCoordinator> & {
fail?: (message: string) => { phase: string; message: string };
retry?: () => { phase: string; message: string };
getStatus?: () => { phase: string; message: string };
};
const full = structuredClone(createVisualFixture("dense").snapshot);
full.payloadKind = "full";
expect(coordinator.fail).toBeTypeOf("function");
expect(coordinator.retry).toBeTypeOf("function");
if (!coordinator.fail || !coordinator.retry || !coordinator.getStatus) return;
coordinator.fail("Snapshot nicht erreichbar");
expect(coordinator.retry()).toEqual({ phase: "loading", message: "" });
coordinator.push(full);
coordinator.fail("Verspäteter Fehler");
expect(coordinator.getStatus()).toEqual({ phase: "ready", message: "" });
});
it("applies queued live removals before accepting a delayed initial snapshot", () => {
const createSnapshotBootstrapCoordinator = (appModule as unknown as {
createSnapshotBootstrapCoordinator?: () => {
initialize: (snapshot: ReturnType<typeof createVisualFixture>["snapshot"]) => ReturnType<typeof createVisualFixture>["snapshot"];
push: (snapshot: ReturnType<typeof createVisualFixture>["snapshot"]) => ReturnType<typeof createVisualFixture>["snapshot"] | null;
};
}).createSnapshotBootstrapCoordinator;
expect(createSnapshotBootstrapCoordinator).toBeTypeOf("function");
if (!createSnapshotBootstrapCoordinator) return;
const initial = structuredClone(createVisualFixture("dense").snapshot);
const removedItemId = Object.keys(initial.session.items)[0];
const removedPackageId = initial.session.items[removedItemId].packageId;
const coordinator = createSnapshotBootstrapCoordinator();
const delta = structuredClone(initial);
delta.payloadKind = "delta";
delta.session.items = {};
delta.session.packages = {};
delta.removedItemIds = [removedItemId];
delta.removedPackageIds = [removedPackageId];
expect(coordinator.push(delta)).toBeNull();
const merged = coordinator.initialize(initial);
expect(merged.session.items[removedItemId]).toBeUndefined();
expect(merged.session.packages[removedPackageId]).toBeUndefined();
expect(coordinator.push({ ...merged, payloadKind: "full" })).toEqual(expect.objectContaining({ payloadKind: "full" }));
const fullCoordinator = createSnapshotBootstrapCoordinator();
const newerFull = structuredClone(initial);
newerFull.payloadKind = "full";
delete newerFull.session.items[removedItemId];
expect(fullCoordinator.push(newerFull)?.session.items[removedItemId]).toBeUndefined();
expect(fullCoordinator.initialize(initial).session.items[removedItemId]).toBeUndefined();
});
it("drops buffered snapshots that are not newer than a retry snapshot", () => {
const coordinator = appModule.createSnapshotBootstrapCoordinator();
const retrySnapshot = structuredClone(createVisualFixture("dense").snapshot) as ReturnType<typeof createVisualFixture>["snapshot"] & {
snapshotRevision?: number;
};
retrySnapshot.snapshotRevision = 20;
retrySnapshot.canStart = true;
const staleDelta = structuredClone(retrySnapshot);
staleDelta.snapshotRevision = 19;
staleDelta.payloadKind = "delta";
staleDelta.canStart = false;
staleDelta.session.packageOrder = [];
staleDelta.session.items = {};
staleDelta.session.packages = {};
expect(coordinator.push(staleDelta)).toBeNull();
coordinator.fail("Snapshot nicht erreichbar");
coordinator.retry();
const restored = coordinator.initialize(retrySnapshot);
expect(restored.canStart).toBe(true);
expect(restored.session.packageOrder).toEqual(retrySnapshot.session.packageOrder);
expect(restored.snapshotRevision).toBe(20);
});
it("ignores stale live snapshots and accepts a newer full snapshot", () => {
const coordinator = appModule.createSnapshotBootstrapCoordinator();
const current = structuredClone(createVisualFixture("dense").snapshot) as ReturnType<typeof createVisualFixture>["snapshot"] & {
snapshotRevision?: number;
};
current.snapshotRevision = 30;
current.payloadKind = "full";
current.canStart = true;
const stale = structuredClone(current);
stale.snapshotRevision = 29;
stale.canStart = false;
const newer = structuredClone(current);
newer.snapshotRevision = 31;
newer.canStart = false;
expect(coordinator.push(current)).toEqual(current);
expect(coordinator.push(stale)).toBeNull();
expect(coordinator.push(newer)).toEqual(newer);
});
it("lets a newer initial GET replace an earlier full stream snapshot", () => {
const coordinator = appModule.createSnapshotBootstrapCoordinator();
const early = structuredClone(createVisualFixture("dense").snapshot) as ReturnType<typeof createVisualFixture>["snapshot"] & {
snapshotRevision?: number;
};
early.snapshotRevision = 40;
early.payloadKind = "full";
early.canStart = false;
const initial = structuredClone(early);
initial.snapshotRevision = 41;
initial.canStart = true;
expect(coordinator.push(early)).toEqual(early);
expect(coordinator.initialize(initial)).toEqual(initial);
});
it("applies a resolved initial GET after an early full stream bootstrap", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const start = source.indexOf("const state = snapshotCoordinator.initialize(initialState)");
const end = source.indexOf("}).catch((error) =>", start);
const resolution = source.slice(start, end);
expect(start).toBeGreaterThan(-1);
expect(resolution).toContain("authoritativeSnapshotApplied");
expect(resolution).toContain("applyStreamSnapshot(state)");
expect(resolution).toContain("applyAuthoritativeSnapshot(state");
});
it("uses keyboard-focusable controls for every copy target", () => {
const appSource = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const dialogSource = readFileSync(new URL("../src/renderer/ui/LinkAddressesDialog.tsx", import.meta.url), "utf8");
+13
View File
@@ -19,6 +19,19 @@ const statistics: StatisticsLedger = { version: 2, startedAt: 1, days: [], minut
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history, statistics };
describe("buildBackupPayload — default is settings-only", () => {
it("keeps the semantic theme preference in exported settings", () => {
const payload = buildBackupPayload({
...baseInput,
settings: settings({ theme: "dark", themePreference: "system" })
});
expect(payload.settings).toEqual(expect.objectContaining({
theme: "dark",
themePreference: "system"
}));
expect((JSON.parse(JSON.stringify(payload)) as { settings: AppSettings }).settings.themePreference).toBe("system");
});
it("keeps the Deepbrid key only inside the encrypted backup payload", () => {
const key = "fixture-deepbrid-backup-key-4eF6";
const payload = buildBackupPayload({
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import * as collector from "../src/shared/collector";
import * as collectorModel from "../src/renderer/views/collector/collector-model";
import type { CollectorLink, CollectorPackage } from "../src/shared/collector";
function link(index: number): CollectorLink {
return {
id: `link-${index}`,
url: `https://example.test/${index}`,
fileName: `file-${index}.bin`,
fileSizeBytes: null,
hoster: "example",
availability: "unknown",
status: "unknown",
addedAt: 1
};
}
function packageEntry(index: number, links: CollectorLink[] = [link(index)]): CollectorPackage {
return {
id: `package-${index}`,
name: `Package ${index}`,
nameSource: "explicit",
links,
addedAt: 1
};
}
describe("collector capacity", () => {
it("reports package and link overflow without cloning the state", () => {
const inspect = (collector as unknown as {
inspectCollectorCapacity?: (packages: CollectorPackage[]) => unknown;
}).inspectCollectorCapacity;
expect(inspect).toBeTypeOf("function");
expect(inspect?.(Array.from({ length: 2_001 }, (_, index) => packageEntry(index)))).toEqual({
ok: false,
packageCount: 2_001,
linkCount: 2_001,
message: "Der Linksammler kann höchstens 2.000 Pakete enthalten."
});
expect(inspect?.([packageEntry(0, Array.from({ length: 20_001 }, (_, index) => link(index)))])).toEqual({
ok: false,
packageCount: 1,
linkCount: 20_001,
message: "Der Linksammler kann höchstens 20.000 Links enthalten."
});
});
it("reports the exact serialized persistence overflow independently of count limits", () => {
const inspectPersistence = (collector as unknown as {
inspectCollectorPersistenceSize?: (state: { packages: CollectorPackage[]; collapsedPackageIds: string[] }, maximumBytes?: number) => { ok: boolean; byteCount: number; message?: string };
}).inspectCollectorPersistenceSize;
const state = { packages: [packageEntry(1)], collapsedPackageIds: ["package-1"] };
expect(inspectPersistence).toBeTypeOf("function");
const result = inspectPersistence?.(state, 128);
expect(result?.ok).toBe(false);
expect(result?.byteCount).toBeGreaterThan(128);
expect(result?.message).toBe("Der Linksammler ist zu groß, um gespeichert zu werden.");
});
it("rejects only the cumulative post-merge result and leaves the current state untouched", () => {
const mergeWithinCapacity = (collectorModel as unknown as {
mergeCollectorPackagesWithinCapacity?: (current: CollectorPackage[], incoming: CollectorPackage[], enrichment?: boolean) => unknown;
}).mergeCollectorPackagesWithinCapacity;
const current = Array.from({ length: 1_999 }, (_, index) => packageEntry(index));
const incoming = [packageEntry(1_999), packageEntry(2_000)];
expect(mergeWithinCapacity).toBeTypeOf("function");
expect(mergeWithinCapacity?.(current, incoming)).toEqual({
ok: false,
packageCount: 2_001,
linkCount: 2_001,
message: "Der Linksammler kann höchstens 2.000 Pakete enthalten."
});
expect(current).toHaveLength(1_999);
});
it("accepts an at-limit merge when the incoming link is only a duplicate", () => {
const mergeWithinCapacity = (collectorModel as unknown as {
mergeCollectorPackagesWithinCapacity?: (current: CollectorPackage[], incoming: CollectorPackage[], enrichment?: boolean) => { ok: boolean; value?: { packages: CollectorPackage[] } };
}).mergeCollectorPackagesWithinCapacity;
const current = Array.from({ length: 2_000 }, (_, index) => packageEntry(index));
expect(mergeWithinCapacity).toBeTypeOf("function");
const result = mergeWithinCapacity?.(current, [{ ...current[0], links: [{ ...current[0].links[0] }] }]);
expect(result?.ok).toBe(true);
expect(result?.value?.packages).toHaveLength(2_000);
});
});
+60 -3
View File
@@ -1,9 +1,23 @@
import { describe, expect, it } from "vitest";
import {
import * as collectorEnrichment from "../src/renderer/collector-enrichment";
import type { CollectorPackage } from "../src/shared/collector";
const {
beginCollectorEnrichment,
filterCurrentCollectorEnrichment
} from "../src/renderer/collector-enrichment";
import type { CollectorPackage } from "../src/shared/collector";
} = collectorEnrichment;
type PruneCollectorEnrichmentGenerations = (
current: Map<string, number>,
packages: CollectorPackage[],
activeSnapshots: Iterable<ReadonlyMap<string, number>>
) => void;
function pruner(): PruneCollectorEnrichmentGenerations | undefined {
return (collectorEnrichment as unknown as {
pruneCollectorEnrichmentGenerations?: PruneCollectorEnrichmentGenerations;
}).pruneCollectorEnrichmentGenerations;
}
function collectorPackage(url: string, status: "ready" | "offline" | "unknown" = "unknown"): CollectorPackage {
return {
@@ -34,4 +48,47 @@ describe("collector enrichment generations", () => {
expect(filterCurrentCollectorEnrichment([collectorPackage("https://example.test/file", "offline")], first, current)).toEqual([]);
expect(filterCurrentCollectorEnrichment([collectorPackage("https://example.test/file", "ready")], second, current)).toHaveLength(1);
});
it("retains an in-flight removed URL so reimport cannot reuse its generation", () => {
const url = "https://example.test/reimported";
const current = new Map<string, number>();
const first = beginCollectorEnrichment([collectorPackage(url)], current);
const activeSnapshots = new Map([["request-1", first]]);
expect(pruner()).toBeTypeOf("function");
pruner()?.(current, [], activeSnapshots.values());
expect(current).toEqual(new Map([[url, 1]]));
const second = beginCollectorEnrichment([collectorPackage(url)], current);
expect(second.get(url)).toBe(2);
expect(filterCurrentCollectorEnrichment([collectorPackage(url, "offline")], first, current)).toEqual([]);
expect(filterCurrentCollectorEnrichment([collectorPackage(url, "ready")], second, current)).toHaveLength(1);
});
it("removes the last generation after its request ends and the collector stays empty", () => {
const url = "https://example.test/completed";
const current = new Map<string, number>();
const snapshot = beginCollectorEnrichment([collectorPackage(url)], current);
pruner()?.(current, [], [snapshot]);
expect(current.size).toBe(1);
pruner()?.(current, [], []);
expect(current.size).toBe(0);
});
it("does not grow across many unique import and removal cycles", () => {
const current = new Map<string, number>();
for (let index = 0; index < 5_000; index += 1) {
const pkg = collectorPackage(`https://example.test/cycle-${index}`);
beginCollectorEnrichment([pkg], current);
pruner()?.(current, [pkg], []);
expect(current.size).toBe(1);
pruner()?.(current, [], []);
expect(current.size).toBe(0);
}
});
});
+329
View File
@@ -0,0 +1,329 @@
import { describe, expect, it, vi } from "vitest";
import type { CollectorLink, CollectorPackage, CollectorPersistenceState } from "../src/shared/collector";
import * as collectorHydration from "../src/renderer/views/collector/collector-hydration";
function link(index: number): CollectorLink {
return {
id: `link-${index}`,
url: `https://example.test/${index}`,
fileName: `file-${index}.bin`,
fileSizeBytes: null,
hoster: "example",
availability: "unknown",
status: "unknown",
addedAt: index
};
}
function packageEntry(index: number, links: CollectorLink[] = [link(index)]): CollectorPackage {
return {
id: `package-${index}`,
name: `Package ${index}`,
nameSource: "explicit",
links,
addedAt: index
};
}
type ResolveCollectorLateHydration = (
persistedState: CollectorPersistenceState,
currentState: CollectorPersistenceState,
maximumBytes?: number
) => {
ok: boolean;
state?: CollectorPersistenceState;
message?: string;
packageCount?: number;
linkCount?: number;
attemptedState?: CollectorPersistenceState;
currentState?: CollectorPersistenceState;
rollbackState?: CollectorPersistenceState;
};
type PersistCollectorStateBeforeUnload = (request: {
hydrated: boolean;
currentState: CollectorPersistenceState;
getPersistedStateSync: () => unknown;
saveStateSync: (state: CollectorPersistenceState) => boolean;
maximumBytes?: number;
}) => boolean;
type GuardCollectorBeforeUnload = (
event: { preventDefault: () => void; returnValue: string },
request: Parameters<PersistCollectorStateBeforeUnload>[0]
) => boolean;
function resolver(): ResolveCollectorLateHydration | undefined {
return (collectorHydration as unknown as {
resolveCollectorLateHydration?: ResolveCollectorLateHydration;
}).resolveCollectorLateHydration;
}
function beforeUnloadPersistence(): PersistCollectorStateBeforeUnload | undefined {
return (collectorHydration as unknown as {
persistCollectorStateBeforeUnload?: PersistCollectorStateBeforeUnload;
}).persistCollectorStateBeforeUnload;
}
function beforeUnloadGuard(): GuardCollectorBeforeUnload | undefined {
return (collectorHydration as unknown as {
guardCollectorBeforeUnload?: GuardCollectorBeforeUnload;
}).guardCollectorBeforeUnload;
}
describe("collector late hydration", () => {
it.each([
["synchronous loading fails", {
hydrated: false,
currentState: { packages: [packageEntry(1)], collapsedPackageIds: [] },
getPersistedStateSync: () => { throw new Error("collector unavailable"); },
saveStateSync: (): boolean => true
}],
["synchronous saving fails", {
hydrated: true,
currentState: { packages: [packageEntry(1)], collapsedPackageIds: [] },
getPersistedStateSync: vi.fn(),
saveStateSync: (): boolean => false
}],
["the merged state exceeds the persistence limit", {
hydrated: false,
currentState: { packages: [packageEntry(2)], collapsedPackageIds: [] },
getPersistedStateSync: () => ({ packages: [packageEntry(1)], collapsedPackageIds: [] }),
saveStateSync: (): boolean => true,
maximumBytes: 128
}]
])("blocks unload when %s", (_name, request) => {
const event = { preventDefault: vi.fn(), returnValue: "unchanged" };
expect(beforeUnloadGuard()).toBeTypeOf("function");
const persisted = beforeUnloadGuard()?.(event, request);
expect(persisted).toBe(false);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(event.returnValue).toBe("");
});
it("allows unload without changing the event after confirmed persistence", () => {
const event = { preventDefault: vi.fn(), returnValue: "unchanged" };
const persisted = beforeUnloadGuard()?.(event, {
hydrated: true,
currentState: { packages: [packageEntry(1)], collapsedPackageIds: [] },
getPersistedStateSync: vi.fn(),
saveStateSync: () => true
});
expect(persisted).toBe(true);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.returnValue).toBe("unchanged");
});
it("synchronously merges a later main-store snapshot while async hydration is still pending", async () => {
const persistedState = {
packages: [packageEntry(1)],
collapsedPackageIds: ["package-1"]
};
const currentState = {
packages: [packageEntry(2)],
collapsedPackageIds: ["package-2"]
};
let resolveAsyncHydration = (_state: CollectorPersistenceState): void => {};
let asyncHydrationSettled = false;
const asyncHydration = new Promise<CollectorPersistenceState>((resolve) => {
resolveAsyncHydration = resolve;
}).then((state) => {
asyncHydrationSettled = true;
return state;
});
const getPersistedStateSync = vi.fn(() => persistedState);
const saveStateSync = vi.fn(() => true);
expect(beforeUnloadPersistence()).toBeTypeOf("function");
const saved = beforeUnloadPersistence()?.({
hydrated: false,
currentState,
getPersistedStateSync,
saveStateSync
});
expect(asyncHydrationSettled).toBe(false);
expect(saved).toBe(true);
expect(getPersistedStateSync).toHaveBeenCalledTimes(1);
expect(saveStateSync).toHaveBeenCalledWith({
packages: [packageEntry(1), packageEntry(2)],
collapsedPackageIds: ["package-1", "package-2"]
});
expect(saveStateSync).not.toHaveBeenCalledWith(currentState);
resolveAsyncHydration(persistedState);
await asyncHydration;
});
it("saves the validated current renderer state without reloading after hydration", () => {
const currentState = {
packages: [packageEntry(1)],
collapsedPackageIds: ["package-1", "missing-package"]
};
const getPersistedStateSync = vi.fn();
const saveStateSync = vi.fn(() => true);
const saved = beforeUnloadPersistence()?.({
hydrated: true,
currentState,
getPersistedStateSync,
saveStateSync
});
expect(saved).toBe(true);
expect(getPersistedStateSync).not.toHaveBeenCalled();
expect(saveStateSync).toHaveBeenCalledWith({
packages: [packageEntry(1)],
collapsedPackageIds: ["package-1"]
});
});
it("leaves the persisted state untouched when the early merge exceeds a limit", () => {
const persistedState = {
packages: [packageEntry(1)],
collapsedPackageIds: []
};
const saveStateSync = vi.fn(() => true);
const saved = beforeUnloadPersistence()?.({
hydrated: false,
currentState: {
packages: [packageEntry(2)],
collapsedPackageIds: []
},
getPersistedStateSync: () => persistedState,
saveStateSync,
maximumBytes: 128
});
expect(saved).toBe(false);
expect(saveStateSync).not.toHaveBeenCalled();
});
it("leaves the persisted state untouched when synchronous loading fails", () => {
const saveStateSync = vi.fn(() => true);
expect(() => beforeUnloadPersistence()?.({
hydrated: false,
currentState: {
packages: [packageEntry(2)],
collapsedPackageIds: []
},
getPersistedStateSync: () => { throw new Error("collector unavailable"); },
saveStateSync
})).not.toThrow();
expect(saveStateSync).not.toHaveBeenCalled();
});
it("does not report success when synchronous persistence rejects the write", () => {
const saveStateSync = vi.fn(() => false);
const saved = beforeUnloadPersistence()?.({
hydrated: true,
currentState: {
packages: [packageEntry(1)],
collapsedPackageIds: []
},
getPersistedStateSync: vi.fn(),
saveStateSync
});
expect(saved).toBe(false);
expect(saveStateSync).toHaveBeenCalledTimes(1);
});
it("merges persisted and pre-load imports with collapse state from both sides", () => {
const persistedState = {
packages: [packageEntry(1)],
collapsedPackageIds: ["package-1"]
};
const duplicateCurrentPackage = {
...packageEntry(1),
id: "current-duplicate",
links: [{ ...link(1), id: "current-duplicate-link" }]
};
const currentState = {
packages: [duplicateCurrentPackage, packageEntry(2)],
collapsedPackageIds: ["current-duplicate", "package-2"]
};
expect(resolver()).toBeTypeOf("function");
expect(resolver()?.(persistedState, currentState)).toEqual({
ok: true,
state: {
packages: [packageEntry(1), packageEntry(2)],
collapsedPackageIds: ["package-1", "package-2"]
}
});
expect(persistedState.packages).toEqual([packageEntry(1)]);
expect(currentState.packages).toEqual([duplicateCurrentPackage, packageEntry(2)]);
});
it("returns the cumulative attempted state and persisted rollback when package capacity is exceeded", () => {
const persistedState = {
packages: Array.from({ length: 1_999 }, (_, index) => packageEntry(index)),
collapsedPackageIds: ["package-0"]
};
const currentState = {
packages: [packageEntry(1_999), packageEntry(2_000)],
collapsedPackageIds: ["package-2000"]
};
expect(resolver()).toBeTypeOf("function");
expect(() => resolver()?.(persistedState, currentState)).not.toThrow();
const result = resolver()?.(persistedState, currentState);
expect(result).toMatchObject({
ok: false,
message: "Der Linksammler kann höchstens 2.000 Pakete enthalten.",
packageCount: 2_001,
linkCount: 2_001,
currentState,
rollbackState: persistedState
});
expect(result?.attemptedState?.packages).toHaveLength(2_001);
expect(result?.attemptedState?.collapsedPackageIds).toEqual(["package-0", "package-2000"]);
});
it("returns an explicit failure when only cumulative link capacity is exceeded", () => {
const persistedState = {
packages: [packageEntry(1, Array.from({ length: 10_000 }, (_, index) => link(index)))],
collapsedPackageIds: ["package-1"]
};
const currentState = {
packages: [packageEntry(2, Array.from({ length: 10_001 }, (_, index) => link(index + 10_000)))],
collapsedPackageIds: ["package-2"]
};
expect(resolver()).toBeTypeOf("function");
const result = resolver()?.(persistedState, currentState);
expect(result).toMatchObject({
ok: false,
message: "Der Linksammler kann höchstens 20.000 Links enthalten.",
packageCount: 2,
linkCount: 20_001,
currentState,
rollbackState: persistedState
});
expect(result?.attemptedState?.packages.flatMap((pkg) => pkg.links)).toHaveLength(20_001);
});
it("returns the persisted rollback before a cumulatively oversized state reaches the UI", () => {
const persistedState = { packages: [packageEntry(1)], collapsedPackageIds: [] };
const currentState = { packages: [packageEntry(2)], collapsedPackageIds: [] };
const result = resolver()?.(persistedState, currentState, 128);
expect(result).toMatchObject({
ok: false,
message: "Der Linksammler ist zu groß, um gespeichert zu werden.",
currentState,
rollbackState: persistedState
});
expect(result?.attemptedState?.packages).toHaveLength(2);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { parseCollectorQueueExport } from "../src/main/collector-import";
describe("collector queue export import", () => {
it("leaves non-JSON text to the regular collector parser", () => {
expect(parseCollectorQueueExport([
"# package: Staffel 1",
"https://example.com/episode-1.mkv"
].join("\n"))).toBeNull();
});
it("preserves URL and file-name positions while normalizing duplicate packages and links", () => {
const result = parseCollectorQueueExport(JSON.stringify({
version: 1,
packages: [{
name: "Staffel? 1",
links: [
" https://example.com/episode-1.mkv ",
"https://example.com/episode-2.mkv",
"https://example.com/episode-2.mkv"
],
fileNames: ["Episode 1.mkv", "", "Episode 2.mkv"]
}, {
name: "Staffel 1",
links: [
"https://example.com/episode-2.mkv",
"https://example.com/episode-3.mkv"
],
fileNames: ["ignored.mkv", "Episode 3.mkv"]
}]
}));
expect(result).toEqual([{
name: "Staffel 1",
links: [
"https://example.com/episode-1.mkv",
"https://example.com/episode-2.mkv",
"https://example.com/episode-3.mkv"
],
fileNames: ["Episode 1.mkv", "Episode 2.mkv", "Episode 3.mkv"]
}]);
});
it("reports malformed JSON instead of treating it as regular text", () => {
expect(() => parseCollectorQueueExport('{"version":1,"packages":[')).toThrow(/JSON/i);
});
it("rejects JSON that does not match the version-one queue export schema", () => {
const unsuitable = [
[],
{ version: 2, packages: [] },
{ version: 1, packages: [], exportedAt: "2026-08-26" },
{ version: 1, packages: [{ name: "Paket", links: ["not-a-url"] }] },
{
version: 1,
packages: [{
name: "Paket",
links: ["https://example.com/file.bin"],
fileNames: ["file.bin", "orphan.bin"]
}]
}
];
for (const payload of unsuitable) {
expect(() => parseCollectorQueueExport(JSON.stringify(payload))).toThrow(/Queue-Export.*ungültig/i);
}
});
it("rejects queue exports above the collector package and link limits", () => {
const tooManyPackages = {
version: 1,
packages: Array.from({ length: 2_001 }, (_, index) => ({
name: `Paket ${index}`,
links: ["https://example.com/file.bin"]
}))
};
const tooManyLinks = {
version: 1,
packages: [{
name: "Paket",
links: Array.from({ length: 20_001 }, (_, index) => `https://example.com/${index}`)
}]
};
expect(() => parseCollectorQueueExport(JSON.stringify(tooManyPackages))).toThrow(/2\.000 Pakete/i);
expect(() => parseCollectorQueueExport(JSON.stringify(tooManyLinks))).toThrow(/20\.000 Links/i);
});
});
+102
View File
@@ -6,8 +6,10 @@ import {
prepareCollectorText
} from "../src/main/collector-inspection";
import {
COLLECTOR_MAX_NAME_LENGTH,
validateCollectorContainerPreparationRequest,
validateCollectorEnrichmentRequest,
validateCollectorPersistenceState,
validateCollectorTextPreparationRequest
} from "../src/shared/collector";
import type { CollectorInspectionResult } from "../src/shared/collector";
@@ -45,6 +47,30 @@ describe("collector preparation", () => {
fetchRequest.mockRestore();
});
it("prepares the app's queue export JSON as collector packages", () => {
const rawText = JSON.stringify({
version: 1,
packages: [{
name: "Roundtrip",
links: ["https://example.test/one", "https://example.test/two"],
fileNames: ["one.bin", "two.bin"]
}]
}, null, 2);
const result = prepareCollectorText({ rawText, addedAt: 4_000 });
expect(result.invalidCount).toBe(0);
expect(result.duplicateCount).toBe(0);
expect(result.packages).toEqual([expect.objectContaining({
name: "Roundtrip",
nameSource: "explicit",
links: [
expect.objectContaining({ url: "https://example.test/one", fileName: "one.bin" }),
expect.objectContaining({ url: "https://example.test/two", fileName: "two.bin" })
]
})]);
});
it("decrypts selected DLC files into a skeleton without metadata enrichment", async () => {
const importContainers = vi.fn(async () => [{
name: "DLC Paket",
@@ -70,6 +96,33 @@ describe("collector preparation", () => {
}));
});
it("normalizes package names from every preparation input to the persistence limit", async () => {
const oversizedName = "n".repeat(COLLECTOR_MAX_NAME_LENGTH + 1);
const textResult = prepareCollectorText({
rawText: [`# Package: ${oversizedName}`, "# File: text.bin", "https://example.com/text"].join("\n"),
addedAt: 3_100
});
const containerResult = await prepareCollectorContainers(
["C:\\Imports\\oversized.dlc"],
3_200,
{
importContainers: async () => [{
name: oversizedName,
links: ["https://example.com/container"],
fileNames: ["container.bin"]
}]
}
);
for (const result of [textResult, containerResult]) {
expect(result.packages[0].name).toBe("n".repeat(COLLECTOR_MAX_NAME_LENGTH));
expect(() => validateCollectorPersistenceState({
packages: result.packages,
collapsedPackageIds: []
})).not.toThrow();
}
});
it("rejects oversized text and invalid container paths", () => {
expect(() => validateCollectorTextPreparationRequest({ rawText: "x".repeat(2_000_001), addedAt: 1 })).toThrow(/ungültig/i);
expect(() => validateCollectorTextPreparationRequest({ rawText: "ä".repeat(1_100_000), addedAt: 1 })).toThrow(/ungültig/i);
@@ -181,6 +234,55 @@ describe("collector enrichment", () => {
await run;
});
it("normalizes every external enrichment file name before progress and persistence", async () => {
const urls = [
"https://1fichier.com/?abc123def456ghi789jk",
"https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/opaque.html",
"https://ddownload.com/abcdefghij",
"https://example.com/"
];
const prepared = prepareCollectorText({
rawText: ["# Package: External", ...urls.slice(0, 3), "# File: download.bin", urls[3]].join("\n"),
addedAt: 8_000
});
const externalFileName = "x".repeat(1_025);
const progress: CollectorInspectionResult[] = [];
const result = await enrichCollectorPackages(
{ requestId: "request-external-file-names", packages: prepared.packages },
defaultSettings(),
{
checkOneFichier: async (links) => new Map(links.map((url) => [url, {
online: true,
fileName: externalFileName,
fileSizeBytes: 100,
accessRestricted: false
}])),
checkRapidgator: async () => ({ online: true, fileName: externalFileName, fileSizeBytes: 200 }),
checkDdownload: async () => ({ online: true, fileName: externalFileName, fileSizeBytes: 300 }),
resolveFilenames: async (links, onResolved) => {
for (const url of links) onResolved?.(url, externalFileName);
return new Map(links.map((url) => [url, externalFileName]));
}
},
(entry) => progress.push(entry)
);
const finalLinks = result.packages.flatMap((pkg) => pkg.links);
const progressLinks = progress.flatMap((entry) => entry.packages).flatMap((pkg) => pkg.links);
expect(finalLinks.map((link) => link.url).sort()).toEqual([...urls].sort());
expect([...new Set(progressLinks.map((link) => link.url))].sort()).toEqual([...urls].sort());
for (const link of [...finalLinks, ...progressLinks]) {
expect(link.fileName).toBe("x".repeat(1_024));
}
for (const entry of [...progress, result]) {
expect(() => validateCollectorPersistenceState({
packages: entry.packages,
collapsedPackageIds: []
})).not.toThrow();
}
});
it("rejects enrichment payloads that do not contain prepared absolute links", () => {
expect(() => validateCollectorEnrichmentRequest({ requestId: "request-empty", packages: [] })).toThrow(/ungültig/i);
expect(() => validateCollectorEnrichmentRequest({
+222
View File
@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import {
inspectCollectorPersistenceSize,
type CollectorLink,
type CollectorPackage,
type CollectorPersistenceState
} from "../src/shared/collector";
import {
advanceCollectorPersistenceBudget,
createCollectorPersistenceBudget
} from "../src/renderer/views/collector/collector-persistence-budget";
import { mergeCollectorPackages } from "../src/renderer/views/collector/collector-model";
function link(id: string, url = `https://example.test/${id}`): CollectorLink {
return {
id,
url,
fileName: `${id}.bin`,
fileSizeBytes: null,
hoster: "example",
availability: "unknown",
status: "unknown",
addedAt: 1
};
}
function packageEntry(id: string, name: string, links: CollectorLink[]): CollectorPackage {
return { id, name, nameSource: "explicit", links, addedAt: 1 };
}
function requiredByteCount(state: CollectorPersistenceState): number {
const result = inspectCollectorPersistenceSize(state, Number.MAX_SAFE_INTEGER);
if (!result.ok) throw new Error(result.message);
return result.byteCount;
}
describe("collector persistence budget", () => {
it("creates exact UTF-8 package and envelope accounting with one serialization per link", () => {
const firstLink = link("link-ä");
const secondLink = link("link-two");
const plainFirst = packageEntry("package-ä", "Paket ä", [firstLink]);
const plainSecond = packageEntry("package-two", "Paket 二", [secondLink]);
const calls = new Map<string, number>();
const tracked = (entry: CollectorLink): CollectorLink => Object.assign({}, entry, {
toJSON: () => {
calls.set(entry.id, (calls.get(entry.id) ?? 0) + 1);
return entry;
}
});
const state = {
packages: [
{ ...plainFirst, links: [tracked(firstLink)] },
{ ...plainSecond, links: [tracked(secondLink)] }
],
collapsedPackageIds: ["package-ä"]
};
const expectedState = { packages: [plainFirst, plainSecond], collapsedPackageIds: ["package-ä"] };
const expectedBytes = requiredByteCount(expectedState);
const result = createCollectorPersistenceBudget(state, expectedBytes);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.byteCount).toBe(expectedBytes);
expect(result.nextBudget.byteCount).toBe(expectedBytes);
expect(result.nextBudget.packageByteCounts).toEqual(new Map([
[plainFirst.id, new TextEncoder().encode(JSON.stringify(plainFirst)).byteLength],
[plainSecond.id, new TextEncoder().encode(JSON.stringify(plainSecond)).byteLength]
]));
expect(result.nextBudget.packageBaseByteCounts).toBeInstanceOf(Map);
expect(result.nextBudget.linkByteCounts).toBeInstanceOf(Map);
expect(result.nextBudget.packageLinkCounts).toBeInstanceOf(Map);
expect(result.nextBudget.packageBaseByteCounts.size).toBe(2);
expect(result.nextBudget.linkByteCounts.size).toBe(2);
expect(result.nextBudget.packageLinkCounts).toEqual(new Map([[plainFirst.id, 1], [plainSecond.id, 1]]));
expect(calls).toEqual(new Map([[firstLink.id, 1], [secondLink.id, 1]]));
});
it("serializes only the changed link when advancing a 20,000-link package", () => {
const plainLinks = Array.from({ length: 20_000 }, (_, index) => link(`large-${index}`));
const calls = new Map<string, number>();
const tracked = (entry: CollectorLink): CollectorLink => Object.assign({}, entry, {
toJSON: () => {
calls.set(entry.id, (calls.get(entry.id) ?? 0) + 1);
return entry;
}
});
const currentLinks = plainLinks.map(tracked);
const currentPackage = packageEntry("large-package", "Großes Paket", currentLinks);
const baseline = createCollectorPersistenceBudget({ packages: [currentPackage], collapsedPackageIds: [] });
expect(baseline.ok).toBe(true);
if (!baseline.ok) return;
calls.clear();
const changedIndex = 12_345;
const changedPlainLink = { ...plainLinks[changedIndex], fileName: "angereichert.bin", availability: "online" as const, status: "ready" as const };
const nextLinks = [...currentLinks];
nextLinks[changedIndex] = tracked(changedPlainLink);
const nextPackage = packageEntry("large-package", "Großes Paket", nextLinks);
const expectedLinks = [...plainLinks];
expectedLinks[changedIndex] = changedPlainLink;
const expectedState = {
packages: [packageEntry("large-package", "Großes Paket", expectedLinks)],
collapsedPackageIds: []
};
const expectedBytes = requiredByteCount(expectedState);
let result: ReturnType<typeof advanceCollectorPersistenceBudget> | undefined;
let elapsedMs = Number.POSITIVE_INFINITY;
expect(() => {
const startedAt = performance.now();
result = advanceCollectorPersistenceBudget(
baseline.nextBudget,
{
links: [{
url: changedPlainLink.url,
previousPackageId: currentPackage.id,
previousLink: currentLinks[changedIndex],
nextPackageId: nextPackage.id,
nextLink: nextLinks[changedIndex]
}],
packages: [{
package: { id: nextPackage.id, name: nextPackage.name, nameSource: nextPackage.nameSource, addedAt: nextPackage.addedAt },
linkCount: nextPackage.links.length
}],
removedPackageIds: [],
packageCount: 1
},
[]
);
elapsedMs = performance.now() - startedAt;
}).not.toThrow();
if (!result) return;
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.byteCount).toBe(expectedBytes);
expect([...calls.values()].reduce((sum, count) => sum + count, 0)).toBe(1);
expect(calls).toEqual(new Map([[changedPlainLink.id, 1]]));
expect(elapsedMs).toBeLessThan(5);
});
it("updates moved links, package ids and comma bytes from the merge delta", () => {
const first = link("first", "https://example.test/first");
const second = link("second", "https://example.test/second");
const pending = packageEntry("pending", "Pending", [first, second]);
const untouched = packageEntry(
"untouched",
"Unberührt",
Array.from({ length: 512 }, (_, index) => link(`bulk-${index}`))
);
const resolvedFirst = packageEntry("resolved-first", "Erstes Paket", [{ ...first, fileName: "Erstes Paket.bin" }]);
const resolvedSecond = packageEntry("resolved-second", "Zweites Paket", [second]);
const baselineState = { packages: [pending, untouched], collapsedPackageIds: [] };
const baseline = createCollectorPersistenceBudget(baselineState);
expect(baseline.ok).toBe(true);
if (!baseline.ok) return;
const untouchedBytes = baseline.nextBudget.packageByteCounts.get(untouched.id);
const merged = mergeCollectorPackages(baselineState.packages, [resolvedFirst, resolvedSecond]);
const expectedState = {
packages: merged.packages,
collapsedPackageIds: ["resolved-second"]
};
const result = advanceCollectorPersistenceBudget(
baseline.nextBudget,
merged.persistenceDelta,
expectedState.collapsedPackageIds
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.byteCount).toBe(requiredByteCount(expectedState));
expect([...result.nextBudget.packageByteCounts.keys()].sort()).toEqual(["resolved-first", "resolved-second", "untouched"]);
expect(result.nextBudget.packageByteCounts.get(untouched.id)).toBe(untouchedBytes);
});
it("atomically rejects enrichment that moves a skeleton over a configurable byte limit", () => {
const skeletonPackage = packageEntry("stable-package", "x", [link("stable-link")]);
const enrichedPackage = { ...skeletonPackage, name: "ä".repeat(1_024) };
const skeletonState = { packages: [skeletonPackage], collapsedPackageIds: [] };
const enrichedState = { packages: [enrichedPackage], collapsedPackageIds: [] };
const skeletonBytes = requiredByteCount(skeletonState);
const enrichedBytes = requiredByteCount(enrichedState);
const maximumBytes = skeletonBytes + 1;
const baseline = createCollectorPersistenceBudget(skeletonState, maximumBytes);
expect(skeletonBytes).toBeLessThan(maximumBytes);
expect(enrichedBytes).toBeGreaterThan(maximumBytes);
expect(baseline.ok).toBe(true);
if (!baseline.ok) return;
const originalPackageByteCounts = baseline.nextBudget.packageByteCounts;
const originalEntries = [...originalPackageByteCounts];
const result = advanceCollectorPersistenceBudget(
baseline.nextBudget,
{
links: [],
packages: [{
package: {
id: enrichedPackage.id,
name: enrichedPackage.name,
nameSource: enrichedPackage.nameSource,
addedAt: enrichedPackage.addedAt
},
linkCount: enrichedPackage.links.length
}],
removedPackageIds: [],
packageCount: 1
},
[],
maximumBytes
);
expect(result).toEqual({
ok: false,
byteCount: enrichedBytes,
message: "Der Linksammler ist zu groß, um gespeichert zu werden."
});
expect(baseline.nextBudget.byteCount).toBe(skeletonBytes);
expect(baseline.nextBudget.packageByteCounts).toBe(originalPackageByteCounts);
expect([...baseline.nextBudget.packageByteCounts]).toEqual(originalEntries);
});
});
+120 -2
View File
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { validateCollectorPersistenceState } from "../src/shared/collector";
import {
createCollectorPersistenceCoordinator,
restoreCollectorPersistenceState
restoreCollectorPersistenceState,
type CollectorPersistenceFailure
} from "../src/renderer/views/collector/collector-persistence";
const packageEntry = {
@@ -45,6 +46,123 @@ describe("collector persistence payload", () => {
});
describe("collector persistence renderer flow", () => {
it("does not inspect or clone scheduled state", () => {
const coordinator = createCollectorPersistenceCoordinator(async (state) => state, 60_000);
const inaccessibleState = new Proxy({ packages: [], collapsedPackageIds: [] }, {
ownKeys: () => {
throw new Error("scheduled state was inspected");
}
});
expect(() => coordinator.schedule(inaccessibleState)).not.toThrow();
coordinator.dispose();
});
it("does not throw when timer scheduling fails", async () => {
const saved: string[] = [];
const coordinator = createCollectorPersistenceCoordinator(async (state) => {
saved.push(state.packages[0]?.id || "empty");
return state;
});
const timerSpy = vi.spyOn(globalThis, "setTimeout").mockImplementationOnce(() => {
throw new Error("timer failed");
});
try {
expect(() => coordinator.schedule({ packages: [packageEntry], collapsedPackageIds: [] })).not.toThrow();
} finally {
timerSpy.mockRestore();
}
await coordinator.flush();
expect(saved).toEqual(["package-one"]);
});
it("debounces twenty thousand progressive schedules before validating only the newest state", async () => {
const saved: string[] = [];
const coordinator = createCollectorPersistenceCoordinator(async (state) => {
saved.push(state.packages[0]?.id || "empty");
return state;
}, 60_000);
const cloneSpy = vi.spyOn(globalThis, "structuredClone");
try {
for (let index = 0; index < 20_000; index += 1) {
coordinator.schedule({
packages: [{ ...packageEntry, id: `progress-${index}` }],
collapsedPackageIds: []
});
}
expect(cloneSpy).not.toHaveBeenCalled();
await coordinator.flush();
expect(saved).toEqual(["progress-19999"]);
expect(cloneSpy).toHaveBeenCalledTimes(1);
} finally {
coordinator.dispose();
cloneSpy.mockRestore();
}
});
it("reports validation failures with the attempted state and configured rollback baseline", async () => {
const baseline = { packages: [packageEntry], collapsedPackageIds: ["package-one"] };
const attemptedState = {
packages: [{ ...packageEntry, name: "" }],
collapsedPackageIds: []
};
const failures: CollectorPersistenceFailure[] = [];
let saveCalls = 0;
const coordinator = createCollectorPersistenceCoordinator(async (state) => {
saveCalls += 1;
return state;
}, 60_000, (failure) => {
failures.push(failure);
});
coordinator.setBaseline(baseline);
expect(() => coordinator.schedule(attemptedState)).not.toThrow();
await expect(coordinator.flush()).resolves.toBeUndefined();
expect(saveCalls).toBe(0);
expect(failures).toHaveLength(1);
expect(failures[0]?.error).toBeInstanceOf(Error);
expect(failures[0]?.attemptedState).toBe(attemptedState);
expect(failures[0]?.rollbackState).toBe(baseline);
});
it("reports save failures without rejecting flush and rolls back to the last successful save", async () => {
const failures: CollectorPersistenceFailure[] = [];
const coordinator = createCollectorPersistenceCoordinator(async (state) => {
if (state.packages[0]?.id === "save-fails") throw new Error("save failed");
return state;
}, 60_000, (failure) => {
failures.push(failure);
});
const successfulState = {
packages: [{ ...packageEntry, id: "saved" }],
collapsedPackageIds: []
};
const failedState = {
packages: [{ ...packageEntry, id: "save-fails" }],
collapsedPackageIds: []
};
coordinator.schedule(successfulState);
await coordinator.flush();
coordinator.schedule(failedState);
await expect(coordinator.flush()).resolves.toBeUndefined();
expect(failures).toHaveLength(1);
expect(failures[0]?.error).toEqual(new Error("save failed"));
expect(failures[0]?.attemptedState).toBe(failedState);
expect(failures[0]?.rollbackState).toEqual(successfulState);
expect(failures[0]?.rollbackState).not.toBe(successfulState);
});
it("merges a late restore with current imports and keeps collapse state from both sides", () => {
const persisted = { packages: [packageEntry], collapsedPackageIds: ["package-one"] };
const currentPackage = {
+219 -2
View File
@@ -8,8 +8,12 @@ import {
mergeCollectorEnrichment,
mergeCollectorPackages,
removeCollectorLinks,
reconcileCollectorCollapsedPackageIdsWithPackages,
reconcileCollectorCollapsedPackageIds,
reconcileCollectorSelectionAfterRemoval,
reconcileCollectorSelectionWithPackages,
selectCollectorPackageLinks,
setCollectorVisibleSelection,
type CollectorPackage
} from "../src/renderer/views/collector/collector-model";
import {
@@ -18,6 +22,7 @@ import {
MemoizedCollectorContent,
CollectorSidebar,
CollectorSidebarStatus,
CollectorTableHeader,
CollectorToolbar,
CollectorView,
collectorFileInteractionAttributes,
@@ -69,6 +74,7 @@ function createActions(overrides: Partial<CollectorViewActions> = {}): Collector
onQueryChange: () => {},
onLinkSelectionChange: () => {},
onPackageSelectionChange: () => {},
onSetVisibleSelection: () => {},
onPackageCollapseChange: () => {},
onToggleAllPackages: () => {},
onRemoveSelected: () => {},
@@ -97,6 +103,31 @@ const packages: CollectorPackage[] = [{
}];
describe("collector workspace model", () => {
it("prunes rolled-back selection so an identical link reimport is not ghost-selected", () => {
const afterRollback = reconcileCollectorSelectionWithPackages(new Set(["link-1", "link-4"]), [packages[1]]);
const afterReimport = buildCollectorWorkspaceViewModel(packages, "all", "", false, [...afterRollback], [], "", true);
expect(afterRollback).toEqual(new Set(["link-4"]));
expect(afterReimport.selectedIds).toEqual(["link-4"]);
});
it("prunes removed collapsed package ids so an identical package reimport stays expanded", () => {
const afterRemoval = reconcileCollectorCollapsedPackageIdsWithPackages(new Set(["package-sbs", "package-mixed"]), [packages[1]]);
const afterReimport = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [...afterRemoval], "", true);
expect(afterRemoval).toEqual(new Set(["package-mixed"]));
expect(afterReimport.packages.map((pkg) => [pkg.id, pkg.collapsed])).toEqual([
["package-sbs", false],
["package-mixed", true]
]);
});
it("sets only visible link selection while preserving hidden ids", () => {
const selected = setCollectorVisibleSelection(new Set(["link-1", "hidden-link"]), ["link-1", "link-2", "link-4"], true);
expect(selected).toEqual(new Set(["link-1", "hidden-link", "link-2", "link-4"]));
expect(setCollectorVisibleSelection(selected, ["link-1", "link-2", "link-4"], false)).toEqual(new Set(["hidden-link"]));
});
it("merges late enrichment by URL without duplicates and moves the link into its resolved package", () => {
const initial: CollectorPackage[] = [{
id: "pending",
@@ -126,6 +157,21 @@ describe("collector workspace model", () => {
status: "ready",
addedAt: 1_000
})]);
expect(result.persistenceDelta).toEqual({
links: [{
url: "https://1fichier.com/?one11111",
previousPackageId: "pending",
previousLink: initial[0].links[0],
nextPackageId: "resolved",
nextLink: result.packages[0].links[0]
}],
packages: [{
package: { id: "resolved", name: "SBS14HD", nameSource: "inferred", addedAt: 1_000 },
linkCount: 1
}],
removedPackageIds: ["pending"],
packageCount: 1
});
});
it("deduplicates repeated incoming URLs while preserving distinct links", () => {
@@ -267,6 +313,15 @@ describe("collector workspace model", () => {
]);
});
it("preserves hidden selections when visible selected links are removed", () => {
const next = reconcileCollectorSelectionAfterRemoval(
new Set(["link-1", "link-4"]),
new Set(["link-1"])
);
expect(next).toEqual(new Set(["link-4"]));
});
it("derives aggregates, filters and search once for the view", () => {
const model = buildCollectorWorkspaceViewModel(packages, "online", "part02", false, ["link-2"], ["package-mixed"], "", true);
@@ -281,6 +336,43 @@ describe("collector workspace model", () => {
]);
});
it("limits selection and action scope to links visible through filter and search", () => {
const model = buildCollectorWorkspaceViewModel(
packages,
"online",
"part02",
false,
["link-1", "link-2", "link-3"],
[],
"",
true
);
expect(model.visibleIds).toEqual(["link-2"]);
expect(model.selectedIds).toEqual(["link-2"]);
expect(model.selectedCount).toBe(1);
expect(model.packages[0].selectedCount).toBe(1);
});
it("keeps offline links selectable and removable but excludes them from transfers", () => {
const model = buildCollectorWorkspaceViewModel(
packages,
"all",
"",
false,
["link-1", "link-2", "link-3", "link-4"],
[],
"",
true
);
expect(model.visibleIds).toEqual(["link-1", "link-2", "link-3", "link-4"]);
expect(model.selectedIds).toEqual(["link-1", "link-2", "link-3", "link-4"]);
expect(model.visibleTransferableIds).toEqual(["link-1", "link-2", "link-3"]);
expect(model.selectedTransferableIds).toEqual(["link-1", "link-2", "link-3"]);
expect(model.selectedCount).toBe(4);
});
it("keeps initially unknown links visible while background analysis runs", () => {
const model = buildCollectorWorkspaceViewModel([packages[1]], "all", "", true, [], [], "", true);
@@ -336,6 +428,111 @@ describe("collector disclosure virtualization", () => {
});
describe("CollectorView", () => {
it("derives header selection without iterating visible ids and forwards them unchanged", () => {
const visibleIds = new Proxy(["link-1", "link-2"], {
get(target, property, receiver) {
if (property === Symbol.iterator || property === "reduce") throw new Error("visible ids must not be iterated");
return Reflect.get(target, property, receiver);
}
});
const base = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true);
const model = { ...base, visibleIds, selectedIds: [], selectedCount: 1 };
const calls: Array<{ ids: string[]; selected: boolean }> = [];
let header: ReactElement | undefined;
expect(() => {
header = CollectorTableHeader({
actions: createActions({ onSetVisibleSelection: (ids, selected) => calls.push({ ids, selected }) }),
model,
scrollLeft: 0
});
}).not.toThrow();
if (!header) return;
const checkbox = findElement(header, (element) => element.type === "input");
checkbox.props.onChange({ target: { checked: true } });
expect(checkbox.props["aria-checked"]).toBe("mixed");
expect(checkbox.props.checked).toBe(false);
expect(calls).toHaveLength(1);
expect(calls[0].ids).toBe(visibleIds);
expect(calls[0].selected).toBe(true);
});
it("routes every visible link including offline through the header checkbox", () => {
const calls: unknown[] = [];
const model = buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1", "link-4"], [], "", true);
const header = CollectorTableHeader({
actions: createActions({ onSetVisibleSelection: (ids, selected) => calls.push([ids, selected]) }),
model,
scrollLeft: 0
});
const checkbox = findElement(header, (element) => element.type === "input");
const input = { indeterminate: false };
(checkbox as unknown as { ref: (element: typeof input) => void }).ref(input);
checkbox.props.onChange({ target: { checked: true } });
expect(checkbox.props["aria-label"]).toBe("Alle sichtbaren Links auswählen");
expect(checkbox.props["aria-checked"]).toBe("mixed");
expect(checkbox.props.checked).toBe(false);
expect(checkbox.props.disabled).toBe(false);
expect(input.indeterminate).toBe(true);
expect(calls).toEqual([[['link-1', 'link-2', 'link-3', 'link-4'], true]]);
});
it("checks or disables the header checkbox from visible selection state", () => {
const selectedHeader = CollectorTableHeader({
actions: createActions(),
model: buildCollectorWorkspaceViewModel(packages, "offline", "", false, ["link-4"], [], "", true),
scrollLeft: 19
});
const selectedCheckbox = findElement(selectedHeader, (element) => element.type === "input");
const emptyHeader = CollectorTableHeader({
actions: createActions(),
model: buildCollectorWorkspaceViewModel(packages, "online", "missing", false, [], [], "", true),
scrollLeft: 0
});
const emptyCheckbox = findElement(emptyHeader, (element) => element.type === "input");
expect(selectedCheckbox.props["aria-checked"]).toBe(true);
expect(selectedCheckbox.props.checked).toBe(true);
expect(selectedCheckbox.props.disabled).toBe(false);
expect(selectedHeader.props.style).toEqual({ transform: "translateX(-19px)" });
expect(emptyCheckbox.props["aria-checked"]).toBe(false);
expect(emptyCheckbox.props.checked).toBe(false);
expect(emptyCheckbox.props.disabled).toBe(true);
});
it("uses visible link counts and selection state for filtered package actions", () => {
const model = buildCollectorWorkspaceViewModel(packages, "online", "part02", false, ["link-2"], [], "", true);
const toolbar = CollectorToolbar({ actions: createActions(), model });
const content = renderToStaticMarkup(<CollectorContent actions={createActions()} model={model} />);
expect(findButton(toolbar, "Alle übergeben (1)").props.disabled).toBe(false);
expect(content).toMatch(/aria-checked="true" aria-label="Paket SBS14HD auswählen"/);
expect(content).not.toContain('aria-checked="mixed" aria-label="Paket SBS14HD auswählen"');
});
it("disables transfer actions for an offline selection while keeping removal enabled", () => {
const model = buildCollectorWorkspaceViewModel(packages, "offline", "", false, ["link-4"], [], "", true);
const toolbar = CollectorToolbar({ actions: createActions(), model });
expect(findButton(toolbar, "Auswahl übergeben (0)").props.disabled).toBe(true);
expect(findButton(toolbar, "Alle übergeben (0)").props.disabled).toBe(true);
expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false);
});
it("counts only transferable links in transfer actions", () => {
const model = buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1", "link-2", "link-3", "link-4"], [], "", true);
const toolbar = CollectorToolbar({ actions: createActions(), model });
expect(findButton(toolbar, "Auswahl übergeben (3)").props.disabled).toBe(false);
expect(findButton(toolbar, "Alle übergeben (3)").props.disabled).toBe(false);
expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false);
});
it("collapses newly imported packages while preserving existing disclosure state", () => {
const incoming = [{ ...packages[0], id: "temporary-import-package" }];
const collapsed = reconcileCollectorCollapsedPackageIds(new Set(["package-mixed"]), [packages[1]], packages, incoming, true);
@@ -431,10 +628,30 @@ describe("CollectorView", () => {
expect(sidebarStatus).toContain('role="status"');
expect(html).toContain("SBS14HD.part01.rar");
expect(findButton(toolbar, "Auswahl übergeben (1)").props.disabled).toBe(false);
expect(findButton(toolbar, "Alle übergeben (4)").props.disabled).toBe(false);
expect(findButton(toolbar, "Alle übergeben (3)").props.disabled).toBe(false);
expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false);
});
it("formats collector sidebar and filter counters as German integers", () => {
const base = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true);
const model = {
...base,
packageCount: 1_250,
totalCount: 3_180,
selectedCount: 1_000,
filters: base.filters.map((filter, index) => ({ ...filter, count: [3_180, 1_250, 1_000, 930][index] ?? 0 }))
};
const sidebar = renderToStaticMarkup(<CollectorSidebar actions={createActions()} model={model} />);
const status = renderToStaticMarkup(<CollectorSidebarStatus model={model} />);
for (const count of ["3.180", "1.250", "1.000", "930"]) expect(sidebar).toContain(`>${count}<`);
expect(status).toContain("Pakete: 1.250");
expect(status).toContain("Links: 3.180");
expect(status).toContain("Ausgewählt: 1.000");
expect(sidebar).not.toContain(">3180<");
expect(status).not.toContain("Links: 3180");
});
it("derives status exclusively from availability instead of filename readiness", () => {
const unknownReady = [{
...packages[0],
@@ -505,7 +722,7 @@ describe("CollectorView", () => {
});
findButton(toolbar, "Auswahl übergeben (1)").props.onClick();
findButton(toolbar, "Alle übergeben (4)").props.onClick();
findButton(toolbar, "Alle übergeben (3)").props.onClick();
expect(selected).toBe(1);
expect(all).toBe(1);
});
+6
View File
@@ -117,4 +117,10 @@ describe("ContextMenu", () => {
expect(stylesSource).toMatch(/\.ctx-menu-sub-items\s*{[^}]*position:\s*fixed;/s);
expect(stylesSource).not.toMatch(/\.ctx-menu-sub-items\s*{[^}]*position:\s*absolute;/s);
});
it("dims disabled context actions without pointer or hover affordances", () => {
expect(stylesSource).toMatch(/\.ctx-menu-item:disabled\s*\{[^}]*opacity:\s*(?:0?\.\d+|\d+%);[^}]*cursor:\s*(?:default|not-allowed);/s);
expect(stylesSource).toMatch(/\.ctx-menu-item:hover:not\(:disabled\)\s*\{[^}]*background:\s*var\(--button-bg-hover\);/s);
expect(stylesSource).not.toMatch(/\.ctx-menu-item:hover\s*\{[^}]*background:/s);
});
});
+238 -4
View File
@@ -14,8 +14,8 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
import { createStoragePaths, emptySession, loadSession, saveSession } from "../src/main/storage";
import { loadStatisticsLedger } from "../src/main/statistics-ledger";
import { createStoragePaths, emptySession, loadSession, loadSettings, saveSession } from "../src/main/storage";
import { loadStatisticsLedger, saveStatisticsLedger } from "../src/main/statistics-ledger";
import { getProviderRuntimeSnapshot, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests, primeRealDebridRuntimeCooldownForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
@@ -86,6 +86,22 @@ describe("download live update cadence", () => {
});
});
describe("snapshot revision", () => {
it("assigns a strictly increasing revision to every captured snapshot", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-snapshot-revision-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const first = manager.getSnapshot() as ReturnType<DownloadManager["getSnapshot"]> & { snapshotRevision?: number };
const second = manager.getSnapshot() as ReturnType<DownloadManager["getSnapshot"]> & { snapshotRevision?: number };
const emitted = manager.getSnapshotForEmit() as ReturnType<DownloadManager["getSnapshotForEmit"]> & { snapshotRevision?: number };
expect(first.snapshotRevision).toEqual(expect.any(Number));
expect(second.snapshotRevision).toBeGreaterThan(first.snapshotRevision ?? Number.MAX_SAFE_INTEGER);
expect(emitted.snapshotRevision).toBeGreaterThan(second.snapshotRevision ?? Number.MAX_SAFE_INTEGER);
});
});
describe("resolveUnrestrictTimeoutBudgetMs", () => {
it("covers the complete provider plan and each later Real-Debrid account attempt", () => {
const settings = {
@@ -1039,8 +1055,37 @@ describe("download start account gate", () => {
expect(manager.getSnapshot().session.paused).toBe(true);
});
});
describe("extractArchiveNameFromExtractorLogMessage", () => {
describe("run-scoped remaining volume", () => {
it("projects remaining bytes only for items in the current run", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-run-remaining-"));
tempDirs.push(root);
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token" },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([
{ name: "selected", links: ["https://example.test/selected"] },
{ name: "excluded", links: ["https://example.test/excluded"] }
]);
const internal = manager as any;
const itemIds = Object.keys(internal.session.items);
internal.session.running = true;
internal.runItemIds = new Set([itemIds[0]]);
internal.session.items[itemIds[0]].totalBytes = 600 * 1024 * 1024;
internal.session.items[itemIds[0]].downloadedBytes = 0;
internal.session.items[itemIds[1]].totalBytes = 60 * 1024 * 1024 * 1024;
internal.session.items[itemIds[1]].downloadedBytes = 0;
const snapshot = manager.getSnapshot();
expect(snapshot.runRemainingBytes).toBe(600 * 1024 * 1024);
expect(snapshot.runRemainingUnknownItems).toBe(0);
expect("runItemIds" in snapshot).toBe(false);
});
});
describe("extractArchiveNameFromExtractorLogMessage", () => {
it("detects archive names from extractor log variants", () => {
expect(extractArchiveNameFromExtractorLogMessage("Extract-Backend Start: archive=scn-dhanbs7-S02E008.rar, mode=legacy")).toBe("scn-dhanbs7-S02E008.rar");
expect(extractArchiveNameFromExtractorLogMessage("Entpacke Archiv: scn-dhanbs7-S02E008.rar -> C:\\target")).toBe("scn-dhanbs7-S02E008.rar");
@@ -13820,6 +13865,195 @@ describe("download manager", () => {
expect(loadStatisticsLedger(createStoragePaths(path.join(root, "state")).statisticsFile).minutes).toEqual([]);
});
it("keeps today's statistics reset after restart without clearing provider quota usage", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-statistics-reset-restart-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const day = getProviderUsageDayKey();
const settings = {
...defaultSettings(),
providerDailyUsageDay: day,
providerDailyUsageBytes: { realdebrid: 4_096 }
};
const manager = new DownloadManager(settings, emptySession(), paths);
manager.resetDownloadStats();
const resetLedger = loadStatisticsLedger(paths.statisticsFile);
expect(settings.providerDailyUsageBytes).toEqual({ realdebrid: 4_096 });
expect(resetLedger.providerSeedSuppressedDay).toBe(day);
expect(resetLedger.providerSeedBaselineBytes).toEqual({ realdebrid: 4_096 });
expect(resetLedger.days.find((entry) => entry.day === day)).toBeUndefined();
const restored = new DownloadManager(settings, emptySession(), paths);
const restoredDay = restored.getStats().statistics?.days.find((entry) => entry.day === day);
expect(settings.providerDailyUsageBytes).toEqual({ realdebrid: 4_096 });
expect(restored.getStats().statistics?.providerSeedSuppressedDay).toBe(day);
expect(restoredDay).toBeUndefined();
});
it("recovers post-reset provider traffic from disk after a failed ledger save", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-statistics-reset-catchup-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const settings = {
...defaultSettings(),
providerDailyUsageDay: getProviderUsageDayKey(),
providerDailyUsageBytes: { realdebrid: 4_096 }
};
const manager = new DownloadManager(settings, emptySession(), paths);
const internal = manager as unknown as {
recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number) => void;
persistNow: () => void;
};
manager.resetDownloadStats();
internal.recordProviderDownloadedBytes("realdebrid", 1_024);
const renameFile = fs.renameSync.bind(fs);
let failed = false;
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => {
if (!failed && String(target) === paths.statisticsFile) {
failed = true;
throw Object.assign(new Error("ledger write failed"), { code: "EIO" });
}
return renameFile(source, target);
});
try {
internal.persistNow();
} finally {
rename.mockRestore();
}
const persistedSettings = loadSettings(paths);
const restored = new DownloadManager(persistedSettings, emptySession(), paths);
const day = restored.getStats().statistics?.days.find((entry) => entry.day === getProviderUsageDayKey());
expect(failed).toBe(true);
expect(persistedSettings.providerDailyUsageBytes.realdebrid).toBe(5_120);
expect(day?.downloadedBytes).toBe(1_024);
expect(day?.providers.realdebrid?.bytes).toBe(1_024);
});
it("captures the reset baseline only after refreshing stale daily provider usage", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-statistics-reset-baseline-day-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const settings = {
...defaultSettings(),
providerDailyUsageDay: "2000-01-01",
providerDailyUsageBytes: { realdebrid: 4_096 }
};
const manager = new DownloadManager(settings, emptySession(), paths);
manager.resetDownloadStats();
const resetLedger = loadStatisticsLedger(paths.statisticsFile);
expect(settings.providerDailyUsageDay).toBe(getProviderUsageDayKey());
expect(settings.providerDailyUsageBytes).toEqual({});
expect(resetLedger.providerSeedBaselineBytes).toEqual({});
});
it("persists a rebased provider quota baseline for manual daily usage resets", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-statistics-manual-quota-reset-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const settings = {
...defaultSettings(),
providerDailyUsageDay: getProviderUsageDayKey(),
providerDailyUsageBytes: { realdebrid: 500 }
};
const manager = new DownloadManager(settings, emptySession(), paths);
const internal = manager as unknown as {
recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number) => void;
};
manager.resetDownloadStats();
internal.recordProviderDownloadedBytes("realdebrid", 200);
manager.rebaseStatisticsProviderDailyUsage("realdebrid", 0);
expect(loadStatisticsLedger(paths.statisticsFile).providerSeedBaselineBytes).toEqual({ realdebrid: -200 });
expect(manager.getStats().statistics?.days[0]?.providers.realdebrid?.bytes).toBe(200);
});
it("keeps runtime and disk statistics unchanged when the ledger reset write fails", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-statistics-reset-ledger-failure-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const settings = {
...defaultSettings(),
totalDownloadedAllTime: 8_192,
providerDailyUsageDay: getProviderUsageDayKey(),
providerDailyUsageBytes: { realdebrid: 1_024 }
};
const manager = new DownloadManager(settings, emptySession(), paths);
const internal = manager as unknown as {
recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number) => void;
};
internal.recordProviderDownloadedBytes("realdebrid", 512);
manager.persistNowSync();
const beforeLedger = loadStatisticsLedger(paths.statisticsFile);
const renameFile = fs.renameSync.bind(fs);
let failed = false;
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => {
if (!failed && String(target) === paths.statisticsFile) {
failed = true;
throw Object.assign(new Error("ledger write failed"), { code: "EIO" });
}
return renameFile(source, target);
});
try {
expect(() => manager.resetDownloadStats()).toThrow("ledger write failed");
} finally {
rename.mockRestore();
}
expect(settings.totalDownloadedAllTime).toBe(8_192);
expect(manager.getStats().statistics?.days).toEqual(beforeLedger.days);
expect(loadStatisticsLedger(paths.statisticsFile).days).toEqual(beforeLedger.days);
expect(loadSettings(paths).totalDownloadedAllTime).toBe(8_192);
});
it("rolls back the persisted ledger when the settings reset write fails", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-statistics-reset-settings-failure-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const settings = {
...defaultSettings(),
totalDownloadedAllTime: 16_384,
providerDailyUsageDay: getProviderUsageDayKey(),
providerDailyUsageBytes: { realdebrid: 2_048 }
};
const manager = new DownloadManager(settings, emptySession(), paths);
const internal = manager as unknown as {
recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number) => void;
};
internal.recordProviderDownloadedBytes("realdebrid", 768);
manager.persistNowSync();
const beforeLedger = loadStatisticsLedger(paths.statisticsFile);
const renameFile = fs.renameSync.bind(fs);
let failed = false;
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => {
if (!failed && String(target) === paths.configFile) {
failed = true;
throw Object.assign(new Error("settings write failed"), { code: "EIO" });
}
return renameFile(source, target);
});
try {
expect(() => manager.resetDownloadStats()).toThrow("settings write failed");
} finally {
rename.mockRestore();
}
expect(settings.totalDownloadedAllTime).toBe(16_384);
expect(manager.getStats().statistics?.days).toEqual(beforeLedger.days);
expect(loadStatisticsLedger(paths.statisticsFile).days).toEqual(beforeLedger.days);
expect(loadSettings(paths).totalDownloadedAllTime).toBe(16_384);
});
it("does not recreate account usage when the source account was removed during the download", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
+429 -10
View File
@@ -11,6 +11,7 @@ import {
classifyDownloadStatus,
formatRemainingDownloadBytes,
formatRemainingDownloadTooltip,
formatDownloadEta,
getDownloadQueueTotalBytes,
getDownloadQueueStatusMetrics,
getRemainingDownloadBytes,
@@ -46,6 +47,7 @@ import {
type DownloadsViewActions,
type DownloadsViewModel
} from "../src/renderer/views/downloads/DownloadsView";
import * as downloadsViewModule from "../src/renderer/views/downloads/DownloadsView";
import {
DownloadsTableHeader,
ItemRowContent,
@@ -206,13 +208,19 @@ function pkg(id: string, name: string, itemIds: string[]): PackageEntry {
}
describe("Download-Gesamtgröße", () => {
it("summiert bekannte Dateigrößen und verwendet geladene Bytes nur als Fallback", () => {
it("kennzeichnet fehlende Gesamtgrößen trotz geladener Untergrenze als unbekannt", () => {
const items = [
item("known", "package-a", "queued", { totalBytes: 4_000, downloadedBytes: 500 }),
item("fallback", "package-a", "queued", { totalBytes: null, downloadedBytes: 750 })
];
expect(getDownloadQueueTotalBytes(items)).toBe(4_750);
expect(getDownloadQueueTotalBytes(items)).toEqual({ bytes: 4_750, unknownItems: 1 });
});
it("zählt auch vollständig unbekannte Gesamtgrößen ohne falsche exakte Null", () => {
expect(getDownloadQueueTotalBytes([
item("unknown", "package-a", "queued", { totalBytes: null, downloadedBytes: 0 })
])).toEqual({ bytes: 0, unknownItems: 1 });
});
it("preserves completed package bytes and progress after immediate cleanup", () => {
@@ -233,6 +241,20 @@ describe("Download-Gesamtgröße", () => {
});
});
describe("Download-ETA", () => {
it("derives ETA from remaining bytes and current throughput before any file completes", () => {
expect(formatDownloadEta({ bytes: 600 * 1024 * 1024, unknownItems: 0 }, 10 * 1024 * 1024, true, false)).toBe("01:00");
});
it("keeps ETA unknown while paused, stopped, stalled, or missing file sizes", () => {
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 0 }, 10, false, false)).toBe("--");
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 0 }, 10, true, true)).toBe("--");
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 0 }, 0, true, false)).toBe("--");
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 1 }, 10, true, false)).toBe("--");
});
});
describe("verbleibendes Downloadvolumen", () => {
it("shows a fourth TB decimal so fast downloads stay visibly responsive", () => {
expect(formatRemainingDownloadBytes({ bytes: Math.round(1.125 * 1024 ** 4), unknownItems: 0 })).toBe("1.1250 TB");
@@ -881,6 +903,92 @@ describe("downloads model", () => {
expect(model.visibleItemIds).toEqual(["failed"]);
});
it("uses package lifecycle status for counts and filters without counting children twice", () => {
const extractingPackage = { ...pkg("extracting-package", "Entpacken", ["extracting-a", "extracting-b"]), status: "extracting" } as PackageEntry;
const failedPackage = { ...pkg("failed-package", "Entpackfehler", ["extract-failed"]), status: "failed" } as PackageEntry;
const pausedPackage = { ...pkg("paused-package", "Pausiert", ["paused-item"]), status: "paused" } as PackageEntry;
const input = createInput({
packageOrder: [extractingPackage.id, failedPackage.id, pausedPackage.id],
packages: {
[extractingPackage.id]: extractingPackage,
[failedPackage.id]: failedPackage,
[pausedPackage.id]: pausedPackage
},
items: {
"extracting-a": item("extracting-a", extractingPackage.id, "completed"),
"extracting-b": item("extracting-b", extractingPackage.id, "completed"),
"extract-failed": item("extract-failed", failedPackage.id, "completed", { fullStatus: "Entpack-Fehler: CRC" }),
"paused-item": item("paused-item", pausedPackage.id, "queued")
}
});
const all = buildDownloadsViewModel(input);
const active = buildDownloadsViewModel({ ...input, filter: "active" });
const failed = buildDownloadsViewModel({ ...input, filter: "failed" });
const paused = buildDownloadsViewModel({ ...input, filter: "paused" });
expect(all.counts).toEqual({ all: 4, active: 2, queued: 0, paused: 1, completed: 0, failed: 1 });
expect(all.counts.active + all.counts.queued + all.counts.paused + all.counts.completed + all.counts.failed).toBe(all.counts.all);
expect(active.visibleItemIds).toEqual(["extracting-a", "extracting-b"]);
expect(failed.visibleItemIds).toEqual(["extract-failed"]);
expect(paused.visibleItemIds).toEqual(["paused-item"]);
});
it("preserves terminal children while unfinished children inherit failed or paused package state", () => {
const failedPackage = { ...pkg("mixed-failed", "Gemischter Fehler", ["failed-complete", "failed-pending"]), status: "failed" } as PackageEntry;
const pausedPackage = { ...pkg("mixed-paused", "Gemischt pausiert", ["paused-complete", "paused-pending"]), status: "paused" } as PackageEntry;
const input = createInput({
packageOrder: [failedPackage.id, pausedPackage.id],
packages: { [failedPackage.id]: failedPackage, [pausedPackage.id]: pausedPackage },
items: {
"failed-complete": item("failed-complete", failedPackage.id, "completed"),
"failed-pending": item("failed-pending", failedPackage.id, "queued"),
"paused-complete": item("paused-complete", pausedPackage.id, "completed"),
"paused-pending": item("paused-pending", pausedPackage.id, "queued")
}
});
const model = buildDownloadsViewModel(input);
expect(model.counts).toEqual({ all: 4, active: 0, queued: 0, paused: 1, completed: 2, failed: 1 });
expect(buildDownloadsViewModel({ ...input, filter: "completed" }).visibleItemIds).toEqual(["failed-complete", "paused-complete"]);
expect(buildDownloadsViewModel({ ...input, filter: "failed" }).visibleItemIds).toEqual(["failed-pending"]);
expect(buildDownloadsViewModel({ ...input, filter: "paused" }).visibleItemIds).toEqual(["paused-pending"]);
});
it("classifies Entpacken Error status variants as failures", () => {
const packageEntry = { ...pkg("extract-error-package", "Entpackfehler", ["extract-error-item"]), status: "queued" } as PackageEntry;
const input = createInput({
packageOrder: [packageEntry.id],
packages: { [packageEntry.id]: packageEntry },
items: {
"extract-error-item": item("extract-error-item", packageEntry.id, "completed", { fullStatus: "Entpacken - Error: CRC" })
}
});
const model = buildDownloadsViewModel(input);
expect(model.counts).toEqual({ all: 1, active: 0, queued: 0, paused: 0, completed: 0, failed: 1 });
expect(buildDownloadsViewModel({ ...input, filter: "failed" }).visibleItemIds).toEqual(["extract-error-item"]);
});
it("keeps cancelled, extracted, and queued siblings out of an active extraction bucket", () => {
const packageEntry = { ...pkg("extracting-mixed", "Gemischtes Entpacken", ["extracting-ready", "extracting-cancelled", "extracting-done", "extracting-queued"]), status: "extracting" } as PackageEntry;
const input = createInput({
packageOrder: [packageEntry.id],
packages: { [packageEntry.id]: packageEntry },
items: {
"extracting-ready": item("extracting-ready", packageEntry.id, "completed", { fullStatus: "Entpacken - 50%" }),
"extracting-cancelled": item("extracting-cancelled", packageEntry.id, "cancelled"),
"extracting-done": item("extracting-done", packageEntry.id, "completed", { fullStatus: "Entpackt" }),
"extracting-queued": item("extracting-queued", packageEntry.id, "queued")
}
});
expect(buildDownloadsViewModel(input).counts).toEqual({ all: 4, active: 1, queued: 1, paused: 0, completed: 1, failed: 0 });
expect(buildDownloadsViewModel({ ...input, filter: "active" }).visibleItemIds).toEqual(["extracting-ready"]);
expect(buildDownloadsViewModel({ ...input, filter: "queued" }).visibleItemIds).toEqual(["extracting-queued"]);
expect(buildDownloadsViewModel({ ...input, filter: "completed" }).visibleItemIds).toEqual(["extracting-done"]);
});
it("filters by package name, file name, provider, status and extracted visibility", () => {
const byPackage = buildDownloadsViewModel(createInput({ query: "aktive serie" }));
const byFile = buildDownloadsViewModel(createInput({ query: "done.mkv" }));
@@ -898,7 +1006,7 @@ describe("downloads model", () => {
expect(hiddenExtracted.visibleItemIds).not.toContain("done");
});
it("removes hidden extracted downloads from every queue-wide model source", () => {
it("removes hidden extracted downloads from queue-wide visibility sources while preserving package aggregates", () => {
const model = buildDownloadsViewModel(createInput({
packageOrder: ["visible-package", "extracted-package"],
packages: {
@@ -921,29 +1029,89 @@ describe("downloads model", () => {
expect(model.filteredEmpty).toBe(false);
});
it("treats a queue containing only hidden extracted downloads as empty", () => {
it("keeps hidden extracted children in package progress, size, and total counts", () => {
const input = createInput({
packageOrder: ["mixed-package"],
packages: { "mixed-package": pkg("mixed-package", "Gemischtes Paket", ["extracted-item", "queued-item"]) },
items: {
"extracted-item": item("extracted-item", "mixed-package", "completed", { downloadedBytes: 100, totalBytes: 100, progressPercent: 100, fullStatus: "Entpackt" }),
"queued-item": item("queued-item", "mixed-package", "queued", { downloadedBytes: 0, totalBytes: 100, progressPercent: 0, fullStatus: "Wartet" })
},
hideExtractedItems: true
});
const model = buildDownloadsViewModel(input);
const row = model.packageRows[0];
expect(row.items.map((entry) => entry.id)).toEqual(["queued-item"]);
expect(row.allItems.map((entry) => entry.id)).toEqual(["extracted-item", "queued-item"]);
expect(getPackageProgress(row)).toEqual({ done: 1, failed: 0, cancelled: 0, total: 2, value: 50 });
expect(getPackageSizeProgress(row)).toEqual({ downloaded: 100, total: 200, value: 50 });
});
it("resets a stale service filter when its last eligible provider disappears", () => {
const model = buildDownloadsViewModel(createInput({
packageOrder: ["visible-package", "extracted-package"],
packages: {
"visible-package": pkg("visible-package", "Wartend", ["visible-item"]),
"extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"])
},
items: {
"visible-item": item("visible-item", "visible-package", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
"extracted-item": item("extracted-item", "extracted-package", "completed", { provider: "alldebrid", providerLabel: "AllDebrid", fullStatus: "Entpackt" })
},
providerFilter: "alldebrid",
hideExtractedItems: true
}));
expect(model.providerFilter).toBe("all");
expect(model.visibleItemIds).toEqual(["visible-item"]);
expect(model.filteredEmpty).toBe(false);
});
it("keeps a hidden-only source queue clearable while presenting no visible entries", () => {
const input = createInput({
packageOrder: ["extracted-package"],
packages: { "extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"]) },
items: { "extracted-item": item("extracted-item", "extracted-package", "completed", { fullStatus: "Entpackt" }) },
hideExtractedItems: true
}));
});
const model = buildDownloadsViewModel(input);
const runtimeModel = withRuntime(input);
const sidebar = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={runtimeModel} />);
const content = renderToStaticMarkup(<DownloadsContent actions={createActions()} model={runtimeModel} />);
expect(model.eligibleItems).toEqual([]);
expect(model.eligiblePackageCount).toBe(0);
expect(model.counts.all).toBe(0);
expect(model.providerOptions).toEqual([]);
expect(model.empty).toBe(true);
expect(model.filteredEmpty).toBe(false);
expect(model.sourceEmpty).toBe(false);
expect(model.presentationEmpty).toBe(true);
expect(model.empty).toBe(false);
expect(model.filteredEmpty).toBe(true);
expect(sidebar).not.toMatch(/<button disabled="" type="button">Liste leeren<\/button>/);
expect(content).toContain("Entpackte Downloads sind ausgeblendet");
expect(content).toContain("Entpackte Einträge ausblenden");
expect(content).not.toContain("Passe Filter oder Suche an");
expect(content).not.toContain("Noch keine Downloads");
expect(getDownloadQueueStatusMetrics(model.eligibleItems)).toEqual({
packageCount: 0,
pendingItemCount: 0,
totalBytes: 0,
total: { bytes: 0, unknownItems: 0 },
remaining: { bytes: 0, unknownItems: 0 },
hosterCount: 0
});
});
it("distinguishes a genuinely empty source queue from presentation filtering", () => {
const model = buildDownloadsViewModel(createInput({ packageOrder: [], packages: {}, items: {} }));
expect(model.sourceEmpty).toBe(true);
expect(model.presentationEmpty).toBe(true);
expect(model.empty).toBe(true);
expect(model.filteredEmpty).toBe(false);
});
it("supports the genuine flat file mode without synthetic package rows", () => {
const model = buildDownloadsViewModel(createInput({ displayMode: "files" }));
@@ -1098,6 +1266,18 @@ describe("downloads view", () => {
expect(html.match(/aria-current="page"/g)).toHaveLength(1);
});
it("formats download filter counters as German integers", () => {
const model = withRuntime(createInput(), {
counts: { all: 3_180, active: 1_250, queued: 1_000, paused: 900, completed: 30, failed: 0 }
});
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={model} />);
for (const count of ["3.180", "1.250", "1.000", "900", "30", "0"]) {
expect(html).toContain(`<b>${count}</b>`);
}
expect(html).not.toContain("<b>3180</b>");
});
it("disables the service filter until more than one concrete service is available", () => {
const none = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput(), { providerOptions: [] })} />);
const one = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput(), { providerOptions: [{ id: "rapidgator", label: "RapidGator" }] })} />);
@@ -1156,6 +1336,231 @@ describe("downloads view", () => {
expect(findButton(toolbar, "Alle ein-/ausklappen").props.disabled).toBe(true);
});
it("disables the global package disclosure action when every source row is hidden", () => {
const model = withRuntime(createInput({
packageOrder: ["extracted-package"],
packages: { "extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"]) },
items: { "extracted-item": item("extracted-item", "extracted-package", "completed", { fullStatus: "Entpackt" }) },
hideExtractedItems: true
}), { running: false });
expect(model.empty).toBe(false);
expect(model.presentationEmpty).toBe(true);
expect(findButton(DownloadsToolbar({ actions: createActions(), model }), "Alle ein-/ausklappen").props.disabled).toBe(true);
});
it("gates context start actions and reports blocked or rejected starts", async () => {
const DownloadContextStartActions = (downloadsViewModule as unknown as {
DownloadContextStartActions?: (props: {
actionBusy: boolean;
canStart: boolean;
onStartAll: () => void;
onStartSelected: () => void;
selectedLabel: string;
showSelected: boolean;
}) => ReactElement;
}).DownloadContextStartActions;
const runDownloadStartAction = (downloadsViewModule as unknown as {
runDownloadStartAction?: (
canStart: boolean,
action: () => Promise<void>,
onBlocked: () => void,
onError: (error: unknown) => void
) => Promise<boolean>;
}).runDownloadStartAction;
const resumeDownloadSession = (downloadsViewModule as unknown as {
resumeDownloadSession?: (
canStart: boolean,
togglePause: () => Promise<boolean>,
applyPaused: (paused: boolean) => void,
onBlocked: () => void,
onError: (error: unknown) => void
) => Promise<boolean>;
}).resumeDownloadSession;
const pauseDownloadSession = (downloadsViewModule as unknown as {
pauseDownloadSession?: (
togglePause: () => Promise<boolean>,
applyPaused: (paused: boolean) => void,
onError: (error: unknown) => void
) => Promise<boolean>;
}).pauseDownloadSession;
expect(DownloadContextStartActions).toBeTypeOf("function");
expect(runDownloadStartAction).toBeTypeOf("function");
expect(resumeDownloadSession).toBeTypeOf("function");
expect(pauseDownloadSession).toBeTypeOf("function");
if (!DownloadContextStartActions || !runDownloadStartAction || !resumeDownloadSession || !pauseDownloadSession) return;
let allStarts = 0;
let selectedStarts = 0;
const blocked = DownloadContextStartActions({
actionBusy: false,
canStart: false,
onStartAll: () => { allStarts += 1; },
onStartSelected: () => { selectedStarts += 1; },
selectedLabel: "Ausgewählte Downloads starten (2)",
showSelected: true
});
const blockedButtons: ReactElement[] = [];
visitElements(blocked, (element) => { if (element.type === "button") blockedButtons.push(element); });
expect(blockedButtons).toHaveLength(2);
expect(blockedButtons.every((button) => button.props.disabled === true)).toBe(true);
const busy = DownloadContextStartActions({
actionBusy: true,
canStart: true,
onStartAll: () => { allStarts += 1; },
onStartSelected: () => { selectedStarts += 1; },
selectedLabel: "Ausgewählte Downloads starten (2)",
showSelected: true
});
const busyButtons: ReactElement[] = [];
visitElements(busy, (element) => { if (element.type === "button") busyButtons.push(element); });
expect(busyButtons.every((button) => button.props.disabled === true)).toBe(true);
const blockedNotice = vi.fn();
const rejectedNotice = vi.fn();
const action = vi.fn(async () => { throw new Error("Kein aktiver Download-Account verfügbar"); });
await expect(runDownloadStartAction(false, action, blockedNotice, rejectedNotice)).resolves.toBe(false);
expect(action).not.toHaveBeenCalled();
expect(blockedNotice).toHaveBeenCalledTimes(1);
expect(rejectedNotice).not.toHaveBeenCalled();
await expect(runDownloadStartAction(true, action, blockedNotice, rejectedNotice)).resolves.toBe(false);
expect(rejectedNotice).toHaveBeenCalledWith(expect.objectContaining({ message: "Kein aktiver Download-Account verfügbar" }));
const applyPaused = vi.fn();
await expect(resumeDownloadSession(
true,
async () => { throw new Error("Account fehlt"); },
applyPaused,
blockedNotice,
rejectedNotice
)).resolves.toBe(false);
expect(applyPaused).not.toHaveBeenCalled();
await expect(resumeDownloadSession(true, async () => false, applyPaused, blockedNotice, rejectedNotice)).resolves.toBe(true);
expect(applyPaused).toHaveBeenCalledWith(false);
applyPaused.mockClear();
await expect(pauseDownloadSession(
async () => { throw new Error("Pause fehlgeschlagen"); },
applyPaused,
rejectedNotice
)).resolves.toBe(false);
expect(applyPaused).not.toHaveBeenCalled();
await expect(pauseDownloadSession(async () => true, applyPaused, rejectedNotice)).resolves.toBe(true);
expect(applyPaused).toHaveBeenCalledWith(true);
const enabled = DownloadContextStartActions({
actionBusy: false,
canStart: true,
onStartAll: () => { allStarts += 1; },
onStartSelected: () => { selectedStarts += 1; },
selectedLabel: "Ausgewählte Downloads starten (2)",
showSelected: true
});
const enabledButtons: ReactElement[] = [];
visitElements(enabled, (element) => { if (element.type === "button") enabledButtons.push(element); });
enabledButtons[0].props.onClick();
enabledButtons[1].props.onClick();
expect(selectedStarts).toBe(1);
expect(allStarts).toBe(1);
});
it("runs resume through the shared single-flight gate", async () => {
const runResumeDownloadAction = (downloadsViewModule as unknown as {
runResumeDownloadAction?: (
runSingleFlight: (action: () => Promise<unknown>) => Promise<void>,
canStart: boolean,
togglePause: () => Promise<boolean>,
applyPaused: (paused: boolean) => void,
onBlocked: () => void,
onError: (error: unknown) => void
) => Promise<void>;
}).runResumeDownloadAction;
expect(runResumeDownloadAction).toBeTypeOf("function");
if (!runResumeDownloadAction) return;
let busy = false;
const runSingleFlight = async (action: () => Promise<unknown>): Promise<void> => {
if (busy) return;
busy = true;
try {
await action();
} finally {
busy = false;
}
};
let release = (): void => {};
const pending = new Promise<void>((resolve) => { release = resolve; });
let paused = true;
let toggleCalls = 0;
const applied: boolean[] = [];
const togglePause = async (): Promise<boolean> => {
toggleCalls += 1;
await pending;
paused = !paused;
return paused;
};
const first = runResumeDownloadAction(runSingleFlight, true, togglePause, (value) => applied.push(value), () => {}, () => {});
const second = runResumeDownloadAction(runSingleFlight, true, togglePause, (value) => applied.push(value), () => {}, () => {});
expect(toggleCalls).toBe(1);
release();
await Promise.all([first, second]);
expect(paused).toBe(false);
expect(applied).toEqual([false]);
});
it("runs pause through the shared single-flight gate", async () => {
const runPauseDownloadAction = (downloadsViewModule as unknown as {
runPauseDownloadAction?: (
runSingleFlight: (action: () => Promise<unknown>) => Promise<void>,
togglePause: () => Promise<boolean>,
applyPaused: (paused: boolean) => void,
onError: (error: unknown) => void
) => Promise<void>;
}).runPauseDownloadAction;
expect(runPauseDownloadAction).toBeTypeOf("function");
if (!runPauseDownloadAction) return;
let busy = false;
const runSingleFlight = async (action: () => Promise<unknown>): Promise<void> => {
if (busy) return;
busy = true;
try {
await action();
} finally {
busy = false;
}
};
let release = (): void => {};
const pending = new Promise<void>((resolve) => { release = resolve; });
let paused = false;
let toggleCalls = 0;
const applied: boolean[] = [];
const togglePause = async (): Promise<boolean> => {
toggleCalls += 1;
await pending;
paused = !paused;
return paused;
};
const first = runPauseDownloadAction(runSingleFlight, togglePause, (value) => applied.push(value), () => {});
const second = runPauseDownloadAction(runSingleFlight, togglePause, (value) => applied.push(value), () => {});
expect(toggleCalls).toBe(1);
release();
await Promise.all([first, second]);
expect(paused).toBe(true);
expect(applied).toEqual([true]);
});
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
const occupied = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
const empty = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false })} />);
@@ -1259,7 +1664,7 @@ describe("downloads view", () => {
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
});
it("blocks resume without a usable account and keeps pause independent from unrelated action busy state", () => {
it("blocks resume without a usable account and pause while another action is busy", () => {
const pausedToolbar = DownloadsToolbar({
actions: createActions(),
model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true })
@@ -1271,7 +1676,7 @@ describe("downloads view", () => {
expect(findButton(pausedToolbar, "Start").props.disabled).toBe(true);
expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true);
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(false);
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(true);
});
it("enables package movement only for a visible selected package row", () => {
@@ -1469,6 +1874,20 @@ describe("downloads view", () => {
});
describe("downloads App integration", () => {
it("keeps search input immediate while deferred text drives expensive filtering", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
expect(source).toMatch(/buildDownloadsViewModel\(\{[\s\S]*?query: deferredDownloadSearch,/);
expect(source).toMatch(/const downloadsViewModel = useMemo<DownloadsViewModel>\(\(\) => \(\{\n\s*\.\.\.downloadsViewCore,\n\s*query: downloadSearch,/);
});
it("commits a normalized provider filter back to renderer state", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
expect(source).toMatch(/if \(downloadsTabActive && downloadProviderFilter !== downloadsViewCore\.providerFilter\) \{\n\s*setDownloadProviderFilter\(downloadsViewCore\.providerFilter\);/);
expect(source).toContain("[downloadProviderFilter, downloadsTabActive, downloadsViewCore.providerFilter]");
});
it("keeps the priority menu open when the selected priority is already active", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { paginateHistoryRows, type HistoryViewEntry } from "../src/renderer/views/history/history-model";
import * as historyViewModule from "../src/renderer/views/history/HistoryView";
function historyEntry(id: string): HistoryViewEntry {
return {
id,
name: id,
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid",
completedAt: 1,
durationSeconds: 1,
status: "completed",
outputDir: "",
urls: []
};
}
describe("history page reporting", () => {
it("reports the exact current page ids and count for App integration", () => {
const reportHistoryVisiblePage = (historyViewModule as unknown as {
reportHistoryVisiblePage?: (
actions: {
onVisiblePageChange?: (ids: string[]) => void;
onVisiblePageCountChange?: (count: number) => void;
},
page: ReturnType<typeof paginateHistoryRows>
) => void;
}).reportHistoryVisiblePage;
expect(reportHistoryVisiblePage).toBeTypeOf("function");
if (!reportHistoryVisiblePage) return;
const ids: string[][] = [];
const counts: number[] = [];
reportHistoryVisiblePage({
onVisiblePageChange: (value) => ids.push(value),
onVisiblePageCountChange: (value) => counts.push(value)
}, paginateHistoryRows([historyEntry("page-101"), historyEntry("page-102")], 1));
expect(ids).toEqual([["page-101", "page-102"]]);
expect(counts).toEqual([2]);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { performance } from "node:perf_hooks";
import { describe, expect, it } from "vitest";
import * as historyModelModule from "../src/renderer/views/history/history-model";
import { buildHistoryViewModel, type HistoryViewEntry } from "../src/renderer/views/history/history-model";
function createLargeHistory(count: number, now: number): HistoryViewEntry[] {
return Array.from({ length: count }, (_, index) => ({
id: `history-${index}`,
name: `Package ${index}`,
totalBytes: 2_097_152,
downloadedBytes: 1_572_864,
fileCount: 1,
provider: "realdebrid",
completedAt: now - index,
durationSeconds: 60,
status: index % 5 === 0 ? "failed" : "completed",
outputDir: `C:\\Downloads\\Package ${index}`,
urls: [`https://rapidgator.net/file/${index}`]
}));
}
describe("history 100k performance contract", () => {
it("keeps an unchanged second tick and cached page below one frame", () => {
const getHistoryPage = (historyModelModule as unknown as {
getHistoryPage?: (
model: ReturnType<typeof buildHistoryViewModel>,
requestedPage: number
) => { rows: unknown[] };
}).getHistoryPage;
expect(getHistoryPage).toBeTypeOf("function");
if (!getHistoryPage) return;
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
const entries = createLargeHistory(100_000, now);
const initial = buildHistoryViewModel(entries, "all", "", [], [], false, "", now);
const initialPage = getHistoryPage(initial, 500);
expect(initialPage.rows).toHaveLength(100);
const startedAt = performance.now();
const next = buildHistoryViewModel(entries, "all", "", [], [], false, "", now + 1_000);
const nextPage = getHistoryPage(next, 500);
const elapsedMs = performance.now() - startedAt;
expect(next.rows).toBe(initial.rows);
expect(nextPage).toBe(initialPage);
expect(elapsedMs).toBeLessThan(10);
});
it("keeps a warmed 100k search interaction below one frame", () => {
const getHistoryPage = (historyModelModule as unknown as {
getHistoryPage?: (
model: ReturnType<typeof buildHistoryViewModel>,
requestedPage: number
) => { rows: unknown[] };
}).getHistoryPage;
expect(getHistoryPage).toBeTypeOf("function");
if (!getHistoryPage) return;
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
const entries = createLargeHistory(100_000, now);
buildHistoryViewModel(entries, "all", "", [], [], false, "", now);
const startedAt = performance.now();
const searched = buildHistoryViewModel(entries, "all", "99999", [], [], false, "", now);
const page = getHistoryPage(searched, 1);
const elapsedMs = performance.now() - startedAt;
expect(searched.rows).toHaveLength(1);
expect(page.rows).toHaveLength(1);
expect(elapsedMs).toBeLessThan(16);
});
});
+327 -23
View File
@@ -19,8 +19,9 @@ import {
selectVisibleHistoryIds,
type HistoryFilter,
type HistoryViewEntry
} from "../src/renderer/views/history/history-model";
import {
} from "../src/renderer/views/history/history-model";
import * as historyModelModule from "../src/renderer/views/history/history-model";
import {
HistoryContent,
HistoryContentPage,
HistoryPagination,
@@ -30,10 +31,13 @@ import {
historyPageStatusLabel,
type HistoryViewActions
} from "../src/renderer/views/history/HistoryView";
import { createVisualFixture } from "./visual/fixtures";
import { createVisualElectronApi } from "./visual/mock-electron-api";
import * as historyViewModule from "../src/renderer/views/history/HistoryView";
import { routeHistoryLiveEntry } from "../src/renderer/App";
import * as appModule from "../src/renderer/App";
import { createVisualFixture } from "./visual/fixtures";
import { createVisualElectronApi } from "./visual/mock-electron-api";
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
if (Array.isArray(node)) {
node.forEach((child) => visitElements(child, visit));
return;
@@ -43,7 +47,83 @@ function visitElements(node: ReactNode, visit: (element: ReactElement) => void):
}
visit(node);
visitElements(node.props.children, visit);
}
}
describe("history sidepanel counts", () => {
it("formats all three counters with the same German integer formatter", () => {
const formatHistorySidebarCounts = (appModule as unknown as {
formatHistorySidebarCounts?: (entries: number, visible: number, selected: number) => {
entries: string;
visible: string;
selected: string;
};
}).formatHistorySidebarCounts;
expect(formatHistorySidebarCounts).toBeTypeOf("function");
if (!formatHistorySidebarCounts) return;
expect(formatHistorySidebarCounts(1_234, 2_345, 3_456)).toEqual({
entries: "Einträge: 1.234",
visible: "Sichtbar: 2.345",
selected: "Ausgewählt: 3.456"
});
});
});
describe("history page selection", () => {
it("adds the current page without replacing selections from other pages", () => {
const toggleHistoryPageSelection = (historyModelModule as unknown as {
toggleHistoryPageSelection?: (current: ReadonlySet<string>, visibleIds: readonly string[]) => Set<string>;
}).toggleHistoryPageSelection;
expect(toggleHistoryPageSelection).toBeTypeOf("function");
if (!toggleHistoryPageSelection) return;
expect([...toggleHistoryPageSelection(new Set(["other-page"]), ["page-a", "page-b"])]).toEqual([
"other-page",
"page-a",
"page-b"
]);
});
it("removes only the current page when every visible row is selected", () => {
const toggleHistoryPageSelection = (historyModelModule as unknown as {
toggleHistoryPageSelection?: (current: ReadonlySet<string>, visibleIds: readonly string[]) => Set<string>;
}).toggleHistoryPageSelection;
expect(toggleHistoryPageSelection).toBeTypeOf("function");
if (!toggleHistoryPageSelection) return;
expect([...toggleHistoryPageSelection(new Set(["other-page", "page-a", "page-b"]), ["page-a", "page-b"])]).toEqual([
"other-page"
]);
});
it("keeps other pages selected when Ctrl+A toggles the current page", () => {
const selectHistoryPageFromShortcut = (appModule as unknown as {
selectHistoryPageFromShortcut?: (current: ReadonlySet<string>, visibleIds: readonly string[]) => Set<string>;
}).selectHistoryPageFromShortcut;
expect(selectHistoryPageFromShortcut).toBeTypeOf("function");
if (!selectHistoryPageFromShortcut) return;
expect([...selectHistoryPageFromShortcut(new Set(["other-page"]), ["page-a", "page-b"])]).toEqual([
"other-page",
"page-a",
"page-b"
]);
});
it("keeps other pages selected when opening context actions for a selected entry", () => {
const resolveHistoryContextSelection = (appModule as unknown as {
resolveHistoryContextSelection?: (current: ReadonlySet<string>, entryId: string) => Set<string>;
}).resolveHistoryContextSelection;
expect(resolveHistoryContextSelection).toBeTypeOf("function");
if (!resolveHistoryContextSelection) return;
expect([...resolveHistoryContextSelection(new Set(["other-page", "page-a"]), "page-a")]).toEqual([
"other-page",
"page-a"
]);
expect([...resolveHistoryContextSelection(new Set(["other-page"]), "page-a")]).toEqual(["page-a"]);
});
});
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
let result: ReactElement | null = null;
@@ -108,6 +188,87 @@ const entries: HistoryViewEntry[] = [
];
describe("history model", () => {
it("indexes matching history once per local day and projects only the requested page", () => {
const getHistoryPage = (historyModelModule as unknown as {
getHistoryPage?: (
model: ReturnType<typeof buildHistoryViewModel>,
requestedPage: number
) => ReturnType<typeof paginateHistoryRows>;
}).getHistoryPage;
expect(getHistoryPage).toBeTypeOf("function");
if (!getHistoryPage) return;
const source = Array.from({ length: 205 }, (_, index) => entry({
id: `indexed-${index + 1}`,
name: `Indexed ${index + 1}`,
completedAt: now - index
}));
const firstModel = buildHistoryViewModel(source, "all", "", ["indexed-101"], [], false, "", now);
const secondModel = buildHistoryViewModel(source, "all", "", ["indexed-101"], [], false, "", now + 1_000);
const firstPage = getHistoryPage(firstModel, 2);
const repeatedPage = getHistoryPage(secondModel, 2);
expect(firstModel.rows).toBe(secondModel.rows);
expect(firstModel.counts).toBe(secondModel.counts);
expect(firstModel.visibleIds).toBe(secondModel.visibleIds);
expect(repeatedPage).toBe(firstPage);
expect(firstPage.rows).toHaveLength(100);
expect(firstPage.rows[0]).toEqual(expect.objectContaining({
id: "indexed-101",
statusLabel: "Abgeschlossen"
}));
expect(firstPage.rows[99].id).toBe("indexed-200");
});
it("formats projected history rows and pagination with the selected locale", () => {
const getHistoryPage = (historyModelModule as unknown as {
getHistoryPage?: (
model: ReturnType<typeof buildHistoryViewModel>,
requestedPage: number
) => ReturnType<typeof paginateHistoryRows>;
}).getHistoryPage;
expect(getHistoryPage).toBeTypeOf("function");
if (!getHistoryPage) return;
const completedAt = new Date(2026, 7, 10, 14, 5, 0, 0).getTime();
const localized = entry({
id: "localized",
name: "Localized",
completedAt,
startedAt: completedAt - 60_000,
downloadedBytes: 1_572_864,
totalBytes: 2_097_152,
downloadFailures: 1_000,
offlineFailures: 2,
extractionFailures: 3,
remuxFailures: 4,
cleanupFailures: 5,
postProcessFailures: 6
});
const tiny = { ...localized, id: "tiny", downloadedBytes: 1_000, totalBytes: 1_000 };
const german = getHistoryPage(buildHistoryViewModel([localized], "all", "", [], [], false, "", now, true, "de"), 1);
const english = getHistoryPage(buildHistoryViewModel([localized], "all", "", [], [], false, "", now, true, "en"), 1);
const germanTiny = getHistoryPage(buildHistoryViewModel([tiny], "all", "", [], [], false, "", now, true, "de"), 1);
const englishTiny = getHistoryPage(buildHistoryViewModel([tiny], "all", "", [], [], false, "", now, true, "en"), 1);
expect(german).toEqual(expect.objectContaining({ rangeLabel: "11 von 1", language: "de" }));
expect(german.rows[0]).toEqual(expect.objectContaining({
sizeLabel: "1,5 MB / 2 MB",
completedLabel: "10.08.2026, 14:05",
failureCountsLabel: "1.000 / 2 / 3 / 4 / 5 / 6",
statusLabel: "Abgeschlossen"
}));
expect(english).toEqual(expect.objectContaining({ rangeLabel: "11 of 1", language: "en" }));
expect(english.rows[0]).toEqual(expect.objectContaining({
sizeLabel: "1.5 MB / 2 MB",
completedLabel: "08/10/2026, 02:05 PM",
failureCountsLabel: "1,000 / 2 / 3 / 4 / 5 / 6",
statusLabel: "Completed"
}));
expect(germanTiny.rows[0].sizeLabel).toBe("1.000 B / 1.000 B");
expect(englishTiny.rows[0].sizeLabel).toBe("1,000 B / 1,000 B");
});
it("prepends live entries without duplicates and applies retention limits", () => {
const incoming = entry({ id: "live", name: "Live", completedAt: now }) as HistoryEntry;
const existing: HistoryEntry[] = [
@@ -120,11 +281,11 @@ describe("history model", () => {
.toEqual(["live", "current"]);
});
it("separates today, previous six calendar days, older and status filters at exact boundaries", () => {
it("defines the last seven days as today plus the previous six calendar days", () => {
const expected: Record<HistoryFilter, string[]> = {
all: ["today", "week-edge", "week", "older"],
today: ["today"],
week: ["week-edge", "week"],
week: ["today", "week-edge", "week"],
older: ["older"],
completed: ["today", "week-edge"],
partial: [],
@@ -184,7 +345,7 @@ describe("history model", () => {
], "week", "", autumnNow).map((row) => row.id)).toEqual(["autumn-boundary"]);
});
it("bounds today to the exact local calendar day and excludes future timestamps", () => {
it("bounds today to the exact local calendar day and excludes future timestamps", () => {
const tomorrowStart = new Date(2026, 7, 11, 0, 0, 0, 0).getTime();
const temporalEntries = [
entry({ id: "today-last", name: "Today last", completedAt: tomorrowStart - 1 }),
@@ -192,10 +353,63 @@ describe("history model", () => {
entry({ id: "future", name: "Future", completedAt: tomorrowStart + 86_400_000 })
];
expect(filterHistoryRows(temporalEntries, "today", "", now).map((row) => row.id)).toEqual(["today-last"]);
});
it("searches name, path, hoster, provider and URLs without changing newest-first input order", () => {
expect(filterHistoryRows(temporalEntries, "today", "", now).map((row) => row.id)).toEqual(["today-last"]);
expect(filterHistoryRows(temporalEntries, "week", "", now).map((row) => row.id)).toEqual(["today-last"]);
});
it("suppresses stale history rows and counters while loading or after a load error", () => {
const loading = buildHistoryViewModel(entries, "all", "", ["today"], ["today"], true, "", now);
const failed = buildHistoryViewModel(entries, "all", "", ["today"], ["today"], false, "Verlauf konnte nicht geladen werden", now);
for (const model of [loading, failed]) {
expect(model.rows).toEqual([]);
expect(model.counts).toEqual({
all: 0,
today: 0,
week: 0,
older: 0,
completed: 0,
partial: 0,
cancelled: 0,
deleted: 0,
failed: 0
});
expect(model.totalCount).toBe(0);
expect(model.selectedIds).toEqual([]);
}
});
it("reloads the full history instead of accepting one live entry after a load failure", () => {
const transitions: string[] = [];
routeHistoryLiveEntry(
entry({ id: "live-after-error", name: "Live after error" }) as HistoryEntry,
true,
{
reload: () => transitions.push("reload"),
accept: () => transitions.push("accept")
}
);
expect(transitions).toEqual(["reload"]);
});
it("accepts live entries directly after a successful history load", () => {
const transitions: string[] = [];
routeHistoryLiveEntry(
entry({ id: "live-after-success", name: "Live after success" }) as HistoryEntry,
false,
{
reload: () => transitions.push("reload"),
accept: (value) => transitions.push(`accept:${value.id}`)
}
);
expect(transitions).toEqual(["accept:live-after-success"]);
});
it("searches name, path, hoster, provider and URLs without changing newest-first input order", () => {
const searchable = [
entry({ id: "new", name: "Neu", completedAt: now, provider: "debridlink", outputDir: "C:\\Filme\\Staffel", urls: ["https://rapidgator.net/file/needle"] }),
entry({ id: "old", name: "Älter", completedAt: now - 1, provider: "realdebrid", outputDir: "D:\\Archiv", urls: ["https://ddownload.com/archive"] })
@@ -326,16 +540,11 @@ describe("HistoryView", () => {
});
it("renders only one fixed-size page with accessible previous and next controls", () => {
const template = filterHistoryRows([entry({ id: "template", name: "Vorlage" })], "all", "", now)[0];
const model = {
...buildHistoryViewModel([], "all", "", [], [], false, "", now),
rows: Array.from({ length: 205 }, (_, index) => ({
...template,
id: `visible-${index + 1}`,
name: `Sichtbar ${index + 1}`
})),
totalCount: 205
};
const source = Array.from({ length: 205 }, (_, index) => entry({
id: `visible-${index + 1}`,
name: `Sichtbar ${index + 1}`
}));
const model = buildHistoryViewModel(source, "all", "", [], [], false, "", now);
const html = renderToStaticMarkup(<HistoryView actions={createActions()} model={model} />);
expect(html.match(/data-history-row-id=/g)).toHaveLength(100);
@@ -368,6 +577,29 @@ describe("HistoryView", () => {
expect(calls).toEqual([2, 1, 3]);
});
it("reports the number of rows actually shown on the current page", () => {
const template = filterHistoryRows([entry({ id: "template", name: "Vorlage" })], "all", "", now)[0];
const rows = Array.from({ length: 205 }, (_, index) => ({
...template,
id: `visible-${index + 1}`,
name: `Sichtbar ${index + 1}`
}));
const report = (historyViewModule as unknown as {
reportHistoryVisiblePageCount?: (
actions: { onVisiblePageCountChange?: (count: number) => void },
page: ReturnType<typeof paginateHistoryRows>
) => void;
}).reportHistoryVisiblePageCount;
const counts: number[] = [];
const actions = { onVisiblePageCountChange: (count: number) => counts.push(count) };
expect(report).toBeTypeOf("function");
report?.(actions, paginateHistoryRows(rows, 1));
report?.(actions, paginateHistoryRows(rows, 3));
expect(counts).toEqual([100, 5]);
});
it("keeps a visible page title in the main content when the filter sidebar is unavailable", () => {
const html = renderToStaticMarkup(
<HistoryContent actions={createActions()} model={buildHistoryViewModel(entries, "all", "", [], [], false, "", now)} />
@@ -406,6 +638,30 @@ describe("HistoryView", () => {
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
});
it("formats history filter counters as German integers", () => {
const base = buildHistoryViewModel(entries, "all", "", [], [], false, "", now);
const model = {
...base,
counts: {
all: 3_180,
today: 1_250,
week: 1_000,
older: 900,
completed: 20,
partial: 5,
failed: 3,
cancelled: 2,
deleted: 0
}
};
const html = renderToStaticMarkup(<HistorySidebar actions={createActions()} model={model} />);
for (const count of ["3.180", "1.250", "1.000", "900", "20", "5", "3", "2", "0"]) {
expect(html).toContain(`<span>${count}</span>`);
}
expect(html).not.toContain("<span>3180</span>");
});
it("keeps one clipped header synchronized with the scrollable history rows", () => {
const html = renderToStaticMarkup(
<HistoryView
@@ -720,6 +976,41 @@ describe("HistoryView", () => {
expect(html).not.toContain("Downloaddauer (Altbestand)");
});
it("formats lifecycle and operation counts with the selected history locale", () => {
const structured = entry({
id: "localized-counts",
name: "Localized counts",
startedAt: todayStart,
successfulFiles: 1_000,
failedFiles: 2_000,
cancelledFiles: 3_000,
archiveCount: 4_000,
partCount: 5_000,
outputCount: 6_000,
archiveOperations: [{
id: "archive-localized",
name: "archive.rar",
itemIds: ["item-1"],
partCount: 7_000,
startedAt: todayStart,
completedAt: todayStart + 1_000,
durationMs: 1_000,
status: "completed",
errorCategory: ""
}]
});
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
model={buildHistoryViewModel([structured], "all", "", [], [structured.id], false, "", now, true, "en")}
/>
);
expect(html).toContain("1,000 / 2,000 / 3,000");
expect(html).toContain("4,000 / 5,000 / 6,000");
expect(html).toContain("7,000 Parts");
});
it("labels durationSeconds honestly for legacy entries", () => {
const legacy = entry({ id: "legacy", name: "Altbestand" });
const html = renderToStaticMarkup(
@@ -786,6 +1077,19 @@ describe("visual history states", () => {
expect(listener).not.toContain("getHistory()");
});
it("reloads the complete history before accepting a live entry after a load failure", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
const listenerStart = source.indexOf("window.rd.onHistoryEntryAdded");
const listenerEnd = source.indexOf("return unsubscribeHistoryEntryAdded", listenerStart);
const listener = source.slice(listenerStart, listenerEnd);
expect(source).toContain("historyLoadFailedRef.current = true");
expect(source).toContain("historyLoadFailedRef.current = false");
expect(listener).toContain("routeHistoryLiveEntry(");
expect(listener).toContain("historyLoadFailedRef.current");
expect(listener).toContain("loadHistoryEntries");
});
it("removes the selected history ids through one backend call", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
const removalStart = source.indexOf("const removeHistoryEntries = useCallback");
+196
View File
@@ -94,6 +94,140 @@ describe("renderer localization", () => {
}
});
it.each([
["Download gestartet", "Download started"],
["Download beendet", "Download finished"],
["Nachbearbeitung gestartet", "Post-processing started"],
["Gesamtdauer", "Total duration"],
["Erfolgreich / Fehlgeschlagen / Abgebrochen", "Successful / Failed / Cancelled"],
["Archive / Parts / Ausgaben", "Archives / parts / outputs"],
["Fehlerphase", "Failure phase"],
["Fehlerkategorie", "Error category"],
["Download / Offline / Entpacken / Remux / Cleanup / Nachbearbeitung", "Download / Offline / Extraction / Remux / Cleanup / Post-processing"],
["Downloaddauer (Altbestand)", "Download duration (legacy)"],
["Archivvorgänge", "Archive operations"],
["Remuxvorgänge", "Remux operations"],
["Keine Archivvorgänge", "No archive operations"],
["Keine Remuxvorgänge", "No remux operations"]
])("translates history lifecycle text %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["2 Parts", "2 parts"],
["1.250 Parts", "1,250 parts"]
])("translates dynamic history operation text %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Letzte 24 Stunden", "Last 24 hours"],
["Account-Traffic der vergangenen 24 Stunden.", "Account traffic over the past 24 hours."],
["Letzte 24 Stunden: Werte seit Beginn der Aufzeichnung.", "Last 24 hours: values since tracking began."],
["Heutige Werte stammen aus der lokalen Statistikaufzeichnung.", "Today's values come from local statistics tracking."],
["Datenmenge und Dateien stammen aus den Gesamtzählern; Ergebnisse und Durchschnitt seit Beginn der Statistikaufzeichnung.", "Data volume and files come from the total counters; results and averages cover the period since statistics tracking began."],
["Dateien werden nicht minutengenau nach Account erfasst", "File counts are not tracked per account at minute precision"],
["Ergebnisse werden nicht minutengenau nach Account erfasst", "Results are not tracked per account at minute precision"],
["Aktive Downloadzeit wird nur tagesweise erfasst", "Active download time is tracked by day only"],
["Fehler werden nicht minutengenau nach Account erfasst", "Errors are not tracked per account at minute precision"],
["Letzte sieben Tage", "Last seven days"],
["Letzte 30 Tage", "Last 30 days"],
["Seit Beginn der Statistikaufzeichnung", "Since statistics tracking began"],
["Noch keine aktive Downloadzeit mit übertragenen Daten erfasst", "No active download time with transferred data has been recorded yet"],
["In den vergangenen 24 Stunden wurde noch kein Account-Traffic erfasst.", "No account traffic has been recorded in the past 24 hours."],
["In diesem Zeitraum wurden noch keine Providerwerte erfasst.", "No provider values have been recorded for this period."]
])("translates statistics copy %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Letzte sieben Tage: 1 erfasster Tag wird bis heute zusammengefasst.", "Last seven days: 1 recorded day is summarized through today."],
["Letzte 30 Tage: 12 erfasste Tage werden bis heute zusammengefasst.", "Last 30 days: 12 recorded days are summarized through today."],
["1,5 GB", "1.5 GB"],
["1.250 GB", "1,250 GB"],
["1,5 MB/s", "1.5 MB/s"],
["50,5 %", "50.5 %"],
["Daten: 1.250 GB", "Data: 1,250 GB"],
["Dateien: 1.250", "Files: 1,250"],
["Erfolg: 50,5 %", "Success: 50.5 %"],
["Fehler: 1.250", "Errors: 1,250"],
["Provider: 1.250", "Providers: 1,250"],
["Accounts: 1.250", "Accounts: 1,250"],
["5 fertig · 2 Fehler", "5 completed · 2 errors"],
["1.250 fertig · 2.500 Fehler", "1,250 completed · 2,500 errors"]
])("localizes dynamic statistics text %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["1.250 GB", "de", "1.250 GB"],
["1,5 GB", "de", "1,5 GB"],
["1,250 GB", "en", "1,250 GB"],
["1.5 GB", "en", "1.5 GB"],
["50,5 %", "de", "50,5 %"],
["50.5 %", "en", "50.5 %"]
])("preserves statistics numbers already formatted for %s", (value, language, expected) => {
expect(translateUiText(value, language as "de" | "en")).toBe(expected);
});
it.each([
["Hell", "Light"],
["Dunkel", "Dark"]
])("translates the German theme name %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Entpackte Downloads sind ausgeblendet", "Extracted downloads are hidden"],
["Deaktiviere „Entpackte Einträge ausblenden“, um sie wieder anzuzeigen.", "Disable “Hide extracted entries” to show them again."],
["Downloads können derzeit nicht gestartet werden", "Downloads cannot be started right now"]
])("translates download state text %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Start fehlgeschlagen: IPC_TIMEOUT", "Start failed: IPC_TIMEOUT"],
["Pause fehlgeschlagen: IPC_TIMEOUT", "Pause failed: IPC_TIMEOUT"]
])("translates download action failures %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Ausgewählte Downloads starten (1.250)", "Start selected downloads (1,250)"],
["Ausgewählte Pakete exportieren (1.250)", "Export selected packages (1,250)"],
["Ausgewählte Dateien exportieren (1.250)", "Export selected files (1,250)"],
["Alle 1.250 umschalten", "Toggle all 1,250"],
["Ausgewählte Dateien entfernen (1.250)", "Remove selected files (1,250)"],
["Zurücksetzen (1.250)", "Reset (1,250)"],
["überspringen (1.250)", "skip (1,250)"],
["Ausgewählte entfernen (1.250)", "Remove selected (1,250)"]
])("localizes dynamic download action %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Ausgewählte Downloads starten (1250)", "Start selected downloads (1,250)", "Ausgewählte Downloads starten (1.250)"],
["Ausgewählte Pakete exportieren (1250)", "Export selected packages (1,250)", "Ausgewählte Pakete exportieren (1.250)"],
["Ausgewählte Dateien exportieren (1250)", "Export selected files (1,250)", "Ausgewählte Dateien exportieren (1.250)"],
["Alle 1250 umschalten", "Toggle all 1,250", "Alle 1.250 umschalten"],
["Ausgewählte Dateien entfernen (1250)", "Remove selected files (1,250)", "Ausgewählte Dateien entfernen (1.250)"],
["Zurücksetzen (1250)", "Reset (1,250)", "Zurücksetzen (1.250)"],
["überspringen (1250)", "skip (1,250)", "überspringen (1.250)"],
["Ausgewählte entfernen (1250)", "Remove selected (1,250)", "Ausgewählte entfernen (1.250)"]
])("formats raw download action counts in both target locales", (germanRaw, english, germanLocalized) => {
expect(translateUiText(germanRaw, "en")).toBe(english);
expect(translateUiText(germanRaw, "de")).toBe(germanLocalized);
expect(translateUiText(english, "de")).toBe(germanLocalized);
});
it("translates dynamic queue and availability values", () => {
expect(translateUiText("26/26 online", "en")).toBe("26/26 online");
expect(translateUiText("3 abgebrochen", "en")).toBe("3 cancelled");
@@ -140,6 +274,68 @@ describe("renderer localization", () => {
expect(translateUiText("abc••••\nKlicken zum Kopieren", "en")).toBe("Click to copy abc••••");
});
it.each([
["Pakete:", "Packages:"],
["Links:", "Links:"],
["Downloads übergeben", "Transfer downloads"],
["Linksammler-Filter", "Link collector filters"],
["Suche und Paketdarstellung", "Search and package display"],
["Gesammelte Downloadpakete", "Collected download packages"],
["Analyse läuft im Hintergrund", "Analysis is running in the background"],
["Name, URL oder Hoster", "Name, URL, or host"],
["Teilweise online", "Partially online"],
["Hinzugefügt", "Added"],
["Passe Suche oder Statusfilter an.", "Adjust the search or status filter."],
["Die ersten Links erscheinen sofort nach dem Import.", "The first links appear immediately after import."],
["Links werden vorbereitet", "Links are being prepared"],
["Füge Links hinzu, um Pakete vor dem Download zu prüfen.", "Add links to check packages before downloading."],
["Links erscheinen sofort und werden anschließend im Hintergrund geprüft.", "Links appear immediately and are then checked in the background."],
["Eine URL pro Zeile", "One URL per line"],
["Keine übertragbaren Links ausgewählt", "No transferable links selected"],
["Alle sichtbaren Links auswählen", "Select all visible links"],
["Der Linksammler ist zu groß, um gespeichert zu werden.", "The link collector is too large to save."]
])("translates collector label %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Auswahl übergeben (3)", "Send selection (3)"],
["Alle übergeben (20.000)", "Send all (20,000)"],
["Paket Staffel 1 auswählen", "Select package Staffel 1"],
["2.001 Dateien", "2,001 files"],
["20.000/20.000 online", "20,000/20,000 online"],
["Pakete: 2.001", "Packages: 2,001"],
["Links: 20.000", "Links: 20,000"],
["Ausgewählt: 1.250", "Selected: 1,250"],
["Der Linksammler kann höchstens 2.000 Pakete enthalten.", "The link collector can contain at most 2,000 packages."],
["Der Linksammler kann höchstens 20.000 Links enthalten.", "The link collector can contain at most 20,000 links."],
["Der Queue-Export überschreitet das Collector-Limit von 2.000 Paketen.", "The queue export exceeds the link collector limit of 2,000 packages."],
["Der Queue-Export überschreitet das Collector-Limit von 20.000 Links.", "The queue export exceeds the link collector limit of 20,000 links."],
["1.250", "1,250"]
])("translates dynamic collector text %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it("translates nested collector persistence and import failures", () => {
expect(translateUiText("Links konnten nicht vorbereitet werden: Error: Der Queue-Export ist ungültig.", "en"))
.toBe("Links could not be prepared: Error: The queue export is invalid.");
expect(translateUiText("Linksammler konnte nicht gespeichert werden: Error: disk full", "en"))
.toBe("Link collector could not be saved: Error: disk full");
});
it.each([
["1 Paket(e), 2 Link(s) gesammelt", "1 package(s), 2 link(s) collected"],
["DLC gesammelt: 1 Paket(e), 2 Link(s)", "DLC collected: 1 package(s), 2 link(s)"],
["1 von 2 Link(s) übergeben; Sammlung bleibt erhalten", "1 of 2 link(s) transferred; collection remains"],
["1 Paket(e), 2 Link(s) übergeben", "1 package(s), 2 link(s) transferred"],
["Linksammler-Payload ist ungültig", "Link collector payload is invalid"]
])("translates collector runtime feedback %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Verfügbarkeit", "Availability"],
["Hinzugefügt am", "Added on"],
+30
View File
@@ -44,6 +44,36 @@ describe("link-parser", () => {
expect(result[0]?.links).toEqual(["http://link1", "http://link2", "http://link3"]);
expect(result[0]?.fileNames).toEqual(["one.rar", "two.rar", "three.rar"]);
});
it("merges 20,000 unique links without quadratic duplicate scans", () => {
const prefix = "https://linear.example/";
const links = Array.from({ length: 20_000 }, (_, index) => `${prefix}${index}`);
const originalDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, "includes");
const originalIncludes = Array.prototype.includes;
const maximumScannedSlots = 1_000_000;
let scannedSlots = 0;
let result: ReturnType<typeof mergePackageInputs> = [];
Object.defineProperty(Array.prototype, "includes", {
configurable: true,
writable: true,
value(this: unknown[], searchElement: unknown, fromIndex?: number): boolean {
if (typeof searchElement === "string" && searchElement.startsWith(prefix)) {
scannedSlots += this.length;
if (scannedSlots > maximumScannedSlots) throw new Error("quadratic duplicate scan limit exceeded");
}
return Reflect.apply(originalIncludes, this, [searchElement, fromIndex]) as boolean;
}
});
try {
result = mergePackageInputs([{ name: "Large package", links }]);
} finally {
if (originalDescriptor) Object.defineProperty(Array.prototype, "includes", originalDescriptor);
}
expect(result).toEqual([{ name: "Large package", links }]);
expect(scannedSlots).toBeLessThanOrEqual(maximumScannedSlots);
});
});
describe("parseCollectorInput", () => {
+56
View File
@@ -48,6 +48,7 @@ import {
type DownloadHealthSnapshot
} from "../src/main/download-health-monitor";
import { NotificationOutbox, type NotificationEvent } from "../src/main/notification-outbox";
import { logger } from "../src/main/logger";
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve = () => {};
@@ -73,6 +74,61 @@ afterEach(() => {
});
describe("main shutdown lifecycle", () => {
it("returns a validated collector snapshot through synchronous IPC", async () => {
const main = await import("../src/main/main");
const state = { packages: [], collapsedPackageIds: [] };
const event = { returnValue: null as unknown };
main.getCollectorStateFromSyncIpc({ getCollectorState: () => state }, event);
expect(event.returnValue).toEqual(state);
expect(event.returnValue).not.toBe(state);
});
it("returns null instead of a malformed synchronous collector snapshot", async () => {
const main = await import("../src/main/main");
const warning = vi.spyOn(logger, "warn").mockImplementation(() => {});
const event = { returnValue: { packages: [], collapsedPackageIds: [] } as unknown };
try {
main.getCollectorStateFromSyncIpc({
getCollectorState: () => ({ packages: "invalid", collapsedPackageIds: [] }) as never
}, event);
expect(event.returnValue).toBeNull();
expect(warning).toHaveBeenCalledWith(expect.stringContaining("Synchrones Laden"));
} finally {
warning.mockRestore();
}
});
it("returns success only after the synchronous collector save completes", async () => {
const main = await import("../src/main/main");
const state = { packages: [], collapsedPackageIds: [] };
const saveCollectorStateSync = vi.fn(() => state);
const event = { returnValue: false };
main.saveCollectorStateFromSyncIpc({ saveCollectorStateSync }, event, state);
expect(saveCollectorStateSync).toHaveBeenCalledWith(state);
expect(event.returnValue).toBe(true);
});
it("returns false and logs a synchronous collector write failure", async () => {
const main = await import("../src/main/main");
const warning = vi.spyOn(logger, "warn").mockImplementation(() => {});
const event = { returnValue: true };
try {
expect(() => main.saveCollectorStateFromSyncIpc({
saveCollectorStateSync: () => { throw new Error("collector locked"); }
}, event, { packages: [], collapsedPackageIds: [] })).not.toThrow();
expect(event.returnValue).toBe(false);
expect(warning).toHaveBeenCalledWith(expect.stringContaining("collector locked"));
} finally {
warning.mockRestore();
}
});
it("continues the full shutdown when the collector state cannot be flushed", async () => {
const controller = Object.create(AppController.prototype) as any;
controller.runtimeStatsTimer = null;
+122
View File
@@ -7,6 +7,7 @@ import { NotificationEvent, NotificationOutbox } from "../src/main/notification-
import { buildPackageNotificationEvent } from "../src/main/notification-events";
import { buildNotifyRequest, sendNotification } from "../src/main/notify";
import { finalizePackageResult } from "../src/main/package-telemetry";
import { logger } from "../src/main/logger";
import type { DownloadItem, PackageEntry, PackageResult, PackageTelemetry, RemuxOperationMetric } from "../src/shared/types";
const tempDirs: string[] = [];
@@ -476,6 +477,30 @@ describe("NotificationOutbox", () => {
}
});
it.each(["EPERM", "EACCES", "EBUSY"])("retries a transient Windows rename error %s before succeeding", async (code) => {
const filePath = createOutboxFile();
const renameFile = fsp.rename.bind(fsp);
let renameAttempts = 0;
const rename = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => {
renameAttempts += 1;
if (renameAttempts === 1) {
throw Object.assign(new Error(`temporary ${code}`), { code });
}
await renameFile(oldPath, newPath);
});
try {
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
await outbox.enqueue(event(`transient-${code}`));
expect(renameAttempts).toBe(2);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual([`transient-${code}`]);
} finally {
rename.mockRestore();
}
});
it("persists a cleaned empty legacy file atomically during load", () => {
const filePath = createOutboxFile();
const rename = vi.spyOn(fs, "renameSync");
@@ -508,6 +533,103 @@ describe("NotificationOutbox", () => {
rename.mockRestore();
});
it.each(["EPERM", "EACCES", "EBUSY"])("retries transient Windows sync rename error %s while loading", (code) => {
const filePath = createOutboxFile();
fs.writeFileSync(filePath, JSON.stringify({
version: 1,
events: [event(`sync-${code}`)],
lastSuccessAt: 0,
lastFailureAt: 0
}), "utf8");
const renameFile = fs.renameSync.bind(fs);
let attempts = 0;
const rename = vi.spyOn(fs, "renameSync").mockImplementation((oldPath, newPath) => {
attempts += 1;
if (attempts === 1) {
throw Object.assign(new Error(`temporary ${code}`), { code });
}
return renameFile(oldPath, newPath);
});
try {
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
expect(attempts).toBe(2);
expect(outbox.getStatus().queued).toBe(1);
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual([`sync-${code}`]);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
} finally {
rename.mockRestore();
}
});
it("continues loading and asynchronously recovers after bounded sync rename retries stay blocked", async () => {
vi.useFakeTimers();
const filePath = createOutboxFile();
const originalPayload = JSON.stringify({
version: 1,
events: [event("expired-sync-recovery", { expiresAt: 999 })],
lastSuccessAt: 0,
lastFailureAt: 0
});
fs.writeFileSync(filePath, originalPayload, "utf8");
let syncAttempts = 0;
const syncRename = vi.spyOn(fs, "renameSync").mockImplementation(() => {
syncAttempts += 1;
throw Object.assign(new Error("temporarily locked"), { code: "EBUSY" });
});
const renameFile = fsp.rename.bind(fsp);
let markRecovered = () => {};
const recovered = new Promise<void>((resolve) => { markRecovered = resolve; });
const asyncRename = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => {
await renameFile(oldPath, newPath);
markRecovered();
});
try {
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000, autoDrain: true });
expect(syncAttempts).toBe(4);
expect(outbox.getStatus().queued).toBe(0);
expect(fs.readFileSync(filePath, "utf8")).toBe(originalPayload);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
syncRename.mockRestore();
await vi.advanceTimersByTimeAsync(1000);
await recovered;
expect(persisted(filePath).events).toEqual([]);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
} finally {
syncRename.mockRestore();
asyncRename.mockRestore();
}
});
it("reports a permanent sync persistence failure without replacing the loaded file", () => {
const filePath = createOutboxFile();
const originalPayload = JSON.stringify({
version: 1,
events: [event("permanent-sync-failure")],
lastSuccessAt: 0,
lastFailureAt: 0
});
fs.writeFileSync(filePath, originalPayload, "utf8");
const rename = vi.spyOn(fs, "renameSync").mockImplementation(() => {
throw Object.assign(new Error("disk unavailable"), { code: "ENOSPC" });
});
const errorLog = vi.spyOn(logger, "error").mockImplementation(() => {});
try {
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
expect(outbox.getStatus().queued).toBe(1);
expect(fs.readFileSync(filePath, "utf8")).toBe(originalPayload);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
expect(errorLog).toHaveBeenCalledWith(expect.stringContaining("ENOSPC"));
} finally {
errorLog.mockRestore();
rename.mockRestore();
}
});
it("drops expired events before persisting or sending", async () => {
const filePath = createOutboxFile();
const send = vi.fn().mockResolvedValue(true);
+12
View File
@@ -4,6 +4,18 @@ import { createRendererSettings } from "../src/main/renderer-state";
import { validateRendererSettingsUpdate } from "../src/main/renderer-settings";
describe("renderer settings validation", () => {
it("projects and validates the semantic theme preference independently from the resolved palette", () => {
const current = { ...defaultSettings(), theme: "dark" as const, themePreference: "system" as const };
const projected = createRendererSettings(current);
expect(projected).toEqual(expect.objectContaining({ theme: "dark", themePreference: "system" }));
expect(validateRendererSettingsUpdate({ theme: "light", themePreference: "system" }, current)).toEqual({
theme: "light",
themePreference: "system"
});
expect(() => validateRendererSettingsUpdate({ themePreference: "automatic" }, current)).toThrow("Settings-Payload ist ungültig");
});
it("accepts every editable notification control while keeping the webhook write-only", () => {
const current = { ...defaultSettings(), notifyUrl: "https://notify.example.test/private-hook" };
const update = {
+82 -6
View File
@@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { createRendererSettings, createRendererState } from "../src/main/renderer-state";
import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, buildProviderOrderEntry, createAccountDialogState, createDiscardedSettingsState, createSettingsDraft, resolveSettingsSaveCompletion } from "../src/renderer/App";
import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, buildProviderOrderEntry, captureSettingsSaveIntent, createAccountDialogState, createDiscardedSettingsState, createSettingsDraft, resolveSettingsSaveCompletion } from "../src/renderer/App";
import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit";
import {
buildAccountTogglePatch,
@@ -66,6 +66,26 @@ const settingsCss = readFileSync(
"utf8"
);
describe("settings save intent", () => {
it("captures the clicked draft before waiting for queued account mutations", () => {
const clicked = { outputDir: "C:\\Clicked", maxParallel: 4 };
const intent = captureSettingsSaveIntent(7, clicked);
clicked.outputDir = "C:\\Edited later";
expect(intent).toEqual({ revision: 7, draft: { outputDir: "C:\\Clicked", maxParallel: 4 } });
});
it("keeps edits made after the save click dirty when the older request completes", () => {
const intent = captureSettingsSaveIntent(7, { outputDir: "C:\\Clicked" });
expect(resolveSettingsSaveCompletion(intent.revision, 8)).toEqual({
saveState: "dirty",
applyPersistedTheme: false,
toast: "Zwischenstand gespeichert weitere Änderungen sind ungespeichert"
});
});
});
function sourceBlock(source: string, start: string, end: string): string {
return source.slice(source.indexOf(start), source.indexOf(end, source.indexOf(start)));
}
@@ -268,8 +288,8 @@ function formModel(): SettingsFormViewModel {
label: "Theme",
value: "dark",
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "light", label: "Hell" },
{ value: "dark", label: "Dunkel" },
{ value: "system", label: "System" }
]
},
@@ -840,8 +860,8 @@ describe("settings views", () => {
});
const html = renderToStaticMarkup(form);
expect(html).toContain("Light");
expect(html).toContain("Dark");
expect(html).toContain("Hell");
expect(html).toContain("Dunkel");
expect(html).toContain("System");
expect(html).toContain("role=\"switch\"");
const switchButton = findElement(form, (element) => element.props.role === "switch");
@@ -1412,6 +1432,28 @@ describe("settings App integration", () => {
});
});
it("uses the persisted semantic theme preference as the settings field value", () => {
const form = buildSettingsFormViewModel({
settings: {
...createRendererSettings({ ...defaultSettings(), theme: "dark", themePreference: "system" }),
archivePasswordList: "",
notifyUrl: ""
},
section: "allgemein",
speedLimitInput: "0",
scheduleSpeedInputs: {}
});
expect(form.groups.flatMap((group) => group.fields).find((field) => field.id === "theme")).toEqual(expect.objectContaining({
value: "system",
options: [
{ value: "light", label: "Hell" },
{ value: "dark", label: "Dunkel" },
{ value: "system", label: "System" }
]
}));
});
it("does not apply a stale save result over newer draft changes", () => {
expect(resolveSettingsSaveCompletion(4, 4)).toEqual({
saveState: "saved",
@@ -1432,7 +1474,7 @@ describe("settings App integration", () => {
expect(revealBlock).toContain("window.rd.getArchivePasswordList()");
expect(revealBlock).toContain('settingsSubTab !== "extract"');
expect(appSource).toContain("setSettingsDraft((current) => createSettingsDraft(state.settings, current))");
expect(appSource).toContain("setSettingsDraft((current) => createSettingsDraft({ ...state.settings, theme: resolvedTheme }, current))");
expect(appSource).toContain("setSettingsDraft((current) => createSettingsDraft(next.settings, current))");
expect(applyBlock).toContain("setSettingsDraft((current) => createSettingsDraft(result, preserveWriteOnlyValues ? current : undefined))");
expect(mainSource).toContain("handleTrusted(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST");
@@ -1529,6 +1571,40 @@ describe("settings App integration", () => {
expect(appSource).toContain("settingsThemeChoice");
expect(appSource).toContain("resolveSettingsThemeChoice");
});
it("restores and persists the semantic theme choice through backend settings", () => {
const startupBlock = sourceBlock(appSource, "void window.rd.getSnapshot()", "unsubscribe = window.rd.onStateUpdate");
const saveBlock = sourceBlock(appSource, "const persistDraftSettingsDirect", "const persistDraftSettings =");
expect(appSource).not.toContain("SETTINGS_THEME_CHOICE_STORAGE_KEY");
expect(appSource).not.toContain("window.localStorage");
expect(startupBlock).toContain("state.settings.themePreference");
expect(saveBlock).toContain("themePreference: themeChoiceAtStart");
});
it("tracks operating-system palette changes only while System is selected", () => {
expect(appSource).toContain('addEventListener("change", onSystemThemeChange)');
expect(appSource).toContain('removeEventListener("change", onSystemThemeChange)');
expect(appSource).toContain('settingsThemeChoiceRef.current !== "system"');
});
it("clears the live bandwidth chart only after a confirmed session reset succeeds", () => {
const resetBlock = sourceBlock(appSource, "onResetSession: () => {", "onResetAll: () => {");
const resetSuccessBlock = sourceBlock(resetBlock, "return window.rd.resetSessionStats().then(() => {", "}).catch((error) => {");
expect(resetSuccessBlock).toContain("speedHistoryRef.current = []");
expect(resetBlock.indexOf("window.rd.resetSessionStats()")).toBeLessThan(resetBlock.indexOf("speedHistoryRef.current = []"));
});
it("clears stale history state when loading the persisted history fails", () => {
const loadBlock = sourceBlock(appSource, "const loadHistoryEntries", "useEffect(() => {");
const catchBlock = sourceBlock(loadBlock, "} catch {", "} finally {");
expect(catchBlock).toContain("pendingLiveHistoryEntriesRef.current = []");
expect(catchBlock).toContain("applyHistoryEntries([])");
expect(catchBlock.indexOf("pendingLiveHistoryEntriesRef.current = []")).toBeLessThan(catchBlock.indexOf("setHistoryError"));
expect(catchBlock.indexOf("applyHistoryEntries([])")).toBeLessThan(catchBlock.indexOf("setHistoryError"));
});
});
describe("settings geometry", () => {
+18
View File
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const themeCss = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
const settingsCss = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8");
const historyCss = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8");
describe("sidepanel theme surfaces", () => {
it("defines the shared panel token used by account and history surfaces", () => {
expect(themeCss).toMatch(/--ui-panel:\s*var\(--ui-surface\);/);
expect(settingsCss).toContain("var(--ui-panel)");
expect(historyCss).toContain("var(--ui-panel)");
});
it("removes the standalone settings divider inside the shell sidebar", () => {
expect(settingsCss).toMatch(/\.md-shell-sidebar-scroll\s*>\s*\.settings-sidebar\s*\{[^}]*border-right:\s*0;/s);
});
});
+118 -1
View File
@@ -13,10 +13,13 @@ import {
loadStatisticsLedger,
normalizeStatisticsLedger,
projectStatisticsLedger,
rebaseStatisticsProviderSeedBaseline,
recordStatisticsActiveInterval,
recordStatisticsBytes,
recordStatisticsOutcome,
saveStatisticsLedger
saveStatisticsLedger,
seedStatisticsDayProviderBytes,
suppressStatisticsProviderSeedForDay
} from "../src/main/statistics-ledger";
const roots: string[] = [];
@@ -66,10 +69,21 @@ describe("statistics ledger", () => {
const migrated = normalizeStatisticsLedger(legacy, now);
expect(migrated.version).toBe(2);
expect(migrated.startedAt).toBe(legacy.startedAt);
expect(migrated.minuteTrackingStartedAt).toBe(now);
expect(migrated.days).toEqual(legacy.days);
expect(migrated.minutes).toEqual([]);
});
it("preserves the persisted minute tracking coverage start", () => {
const now = localTime(10);
const minuteTrackingStartedAt = now - (25 * 60 * 60 * 1_000);
const ledger = createStatisticsLedger(now);
expect(ledger.minuteTrackingStartedAt).toBe(now);
expect(normalizeStatisticsLedger({ ...ledger, minuteTrackingStartedAt }, now).minuteTrackingStartedAt).toBe(minuteTrackingStartedAt);
});
it("normalizes, merges, and bounds sparse account minute history", () => {
const now = localTime(10);
const currentMinute = Math.floor(now / 60_000) * 60_000;
@@ -199,6 +213,108 @@ describe("statistics ledger", () => {
expect(month).toMatchObject({ downloadedBytes: 107_000, activeDownloadMs: 3_000 });
});
it("reconciles provider usage unless the day carries an explicit reset marker", () => {
const previousDay = recordStatisticsBytes(createStatisticsLedger(localTime(9)), "debridlink", 200, localTime(9));
const migrated = seedStatisticsDayProviderBytes(previousDay, { realdebrid: 500 }, localTime(10));
expect(migrated.days).toEqual([
expect.objectContaining({ day: "2026-08-09", downloadedBytes: 200 }),
expect.objectContaining({ day: "2026-08-10", downloadedBytes: 500, providers: { realdebrid: expect.objectContaining({ bytes: 500 }) } })
]);
const existingToday = recordStatisticsBytes(createStatisticsLedger(localTime(10)), "realdebrid", 200, localTime(10));
expect(seedStatisticsDayProviderBytes(existingToday, { realdebrid: 500 }, localTime(10)).days[0]).toMatchObject({
downloadedBytes: 500,
providers: { realdebrid: expect.objectContaining({ bytes: 500 }) }
});
const resetDay = suppressStatisticsProviderSeedForDay(createStatisticsLedger(localTime(10)), localTime(10));
const preserved = seedStatisticsDayProviderBytes(resetDay, { realdebrid: 500 }, localTime(10));
expect(preserved.providerSeedSuppressedDay).toBe("2026-08-10");
expect(preserved.days).toEqual([]);
});
it("catches up only provider traffic recorded after a statistics reset baseline", () => {
const now = localTime(10);
const reset = suppressStatisticsProviderSeedForDay(
createStatisticsLedger(now),
now,
{ realdebrid: 500, debridlink: 200 }
);
const staleSavedLedger = recordStatisticsBytes(reset, "realdebrid", 100, now);
const caughtUp = seedStatisticsDayProviderBytes(
staleSavedLedger,
{ realdebrid: 650, debridlink: 200, deepbrid: 50 },
now
);
expect(caughtUp.providerSeedSuppressedDay).toBe("2026-08-10");
expect(caughtUp.providerSeedBaselineBytes).toEqual({ realdebrid: 500, debridlink: 200 });
expect(caughtUp.providerBytesOnlyDays).toEqual(["2026-08-10"]);
expect(caughtUp.days[0]).toMatchObject({
downloadedBytes: 200,
providers: {
realdebrid: { bytes: 150 },
deepbrid: { bytes: 50 }
}
});
expect(caughtUp.days[0].providers.debridlink).toBeUndefined();
});
it("normalizes reset baselines and preserves legacy marker suppression", () => {
const now = localTime(10);
const normalized = normalizeStatisticsLedger({
...createStatisticsLedger(now),
providerSeedSuppressedDay: "2026-08-10",
providerSeedBaselineBytes: { realdebrid: 100.9, deepbrid: -5, unsafe: 999 }
}, now);
expect(normalized.providerSeedBaselineBytes).toEqual({ realdebrid: 100, deepbrid: -5 });
const legacy = normalizeStatisticsLedger({
...createStatisticsLedger(now),
providerSeedSuppressedDay: "2026-08-10"
}, now);
expect(legacy.providerSeedBaselineBytes).toBeUndefined();
expect(seedStatisticsDayProviderBytes(legacy, { realdebrid: 500 }, now).days).toEqual([]);
const nextDay = seedStatisticsDayProviderBytes(normalized, { realdebrid: 25 }, localTime(11));
expect(nextDay.providerSeedSuppressedDay).toBeUndefined();
expect(nextDay.providerSeedBaselineBytes).toBeUndefined();
expect(nextDay.days[0]?.providers.realdebrid?.bytes).toBe(25);
});
it("rebases an active provider quota baseline without losing post-reset statistics", () => {
const now = localTime(10);
const reset = suppressStatisticsProviderSeedForDay(createStatisticsLedger(now), now, {
realdebrid: 500,
debridlink: 200
});
const withPostResetTraffic = recordStatisticsBytes(reset, "realdebrid", 200, now);
const rebased = rebaseStatisticsProviderSeedBaseline(withPostResetTraffic, "realdebrid", 0, now);
const afterMoreTraffic = seedStatisticsDayProviderBytes(rebased, { realdebrid: 100, debridlink: 200 }, now);
expect(rebased.providerSeedBaselineBytes).toEqual({ realdebrid: -200, debridlink: 200 });
expect(rebased.days[0]?.providers.realdebrid?.bytes).toBe(200);
expect(afterMoreTraffic.days[0]?.providers.realdebrid?.bytes).toBe(300);
expect(afterMoreTraffic.days[0]?.downloadedBytes).toBe(300);
});
it("establishes a catch-up baseline when quota usage is reset without a statistics reset", () => {
const now = localTime(10);
const existing = recordStatisticsBytes(createStatisticsLedger(now), "realdebrid", 500, now);
const rebased = rebaseStatisticsProviderSeedBaseline(existing, "realdebrid", 0, now);
const restored = seedStatisticsDayProviderBytes(rebased, { realdebrid: 100 }, now);
expect(rebased.providerSeedSuppressedDay).toBe("2026-08-10");
expect(rebased.providerSeedBaselineBytes).toEqual({ realdebrid: -500 });
expect(restored.days[0]?.providers.realdebrid?.bytes).toBe(600);
expect(restored.days[0]?.downloadedBytes).toBe(600);
});
it("splits active intervals across local calendar days", () => {
const start = new Date(2026, 7, 9, 23, 59, 59, 500).getTime();
const end = new Date(2026, 7, 10, 0, 0, 0, 500).getTime();
@@ -218,6 +334,7 @@ describe("statistics ledger", () => {
"completed",
localTime(10)
);
ledger.providerBytesOnlyDays = ["2026-08-10"];
saveStatisticsLedger(filePath, ledger);
expect(loadStatisticsLedger(filePath, localTime(10))).toEqual(ledger);
+115 -2
View File
@@ -22,6 +22,7 @@ import {
recordStatisticsBytes,
recordStatisticsOutcome
} from "../src/main/statistics-ledger";
import { translateUiText } from "../src/renderer/i18n";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
@@ -117,8 +118,34 @@ function expectUnavailable(metric: StatisticsMetric): void {
expect(metric.sourceLabel.trim()).not.toBe("");
}
describe("statistics model", () => {
it("uses real snapshot session fields and excludes active or waiting downloads from the success denominator", () => {
describe("statistics model", () => {
it("produces range messages that the shared localizer translates completely", () => {
const snapshot = createSnapshot();
snapshot.stats.statistics = {
version: 2,
startedAt: now,
days: [],
minutes: []
};
expect(translateUiText(buildStatisticsViewModel(snapshot, "last24", now).message, "en"))
.toBe("Last 24 hours: values since tracking began.");
expect(translateUiText(buildStatisticsViewModel(snapshot, "today", now).message, "en"))
.toBe("Today's values come from local statistics tracking.");
expect(translateUiText(buildStatisticsViewModel(snapshot, "all", now).message, "en"))
.toBe("Data volume and files come from the total counters; results and averages cover the period since statistics tracking began.");
});
it("uses singular grammar for one recorded statistics day in both languages", () => {
const snapshot = createSnapshot();
snapshot.stats.statistics = recordStatisticsBytes(createStatisticsLedger(now), "realdebrid", 1, now);
const message = buildStatisticsViewModel(snapshot, "week", now).message;
expect(message).toBe("Letzte sieben Tage: 1 erfasster Tag wird bis heute zusammengefasst.");
expect(translateUiText(message, "en")).toBe("Last seven days: 1 recorded day is summarized through today.");
});
it("uses real snapshot session fields and excludes active or waiting downloads from the success denominator", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 600;
snapshot.stats.totalFilesSession = 3;
@@ -213,6 +240,25 @@ describe("statistics model", () => {
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[0, 1], [1, 0]]);
});
it("keeps recovered provider bytes available while marking unrecoverable daily details unavailable", () => {
const snapshot = createSnapshot();
let ledger = createStatisticsLedger(now);
ledger = recordStatisticsBytes(ledger, "realdebrid", 500, now);
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now);
ledger = recordStatisticsActiveInterval(ledger, now - 500, now);
ledger.providerBytesOnlyDays = ["2026-08-10"];
snapshot.stats.statistics = ledger;
const model = buildStatisticsViewModel(snapshot, "today", now);
expect(model.metrics.downloadedBytes).toMatchObject({ value: 500, available: true });
expectUnavailable(model.metrics.files);
expectUnavailable(model.metrics.successRate);
expectUnavailable(model.metrics.averageSpeedBps);
expectUnavailable(model.metrics.errors);
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[null, null]]);
});
it("shows rolling account traffic with unavailable day-scoped metrics", () => {
const snapshot = createSnapshot();
snapshot.stats.statistics = {
@@ -248,11 +294,43 @@ describe("statistics model", () => {
expect(model.message).toContain("seit Beginn der Aufzeichnung");
});
it("does not present incomplete rolling traffic as available after provider-only recovery", () => {
const snapshot = createSnapshot();
snapshot.stats.statistics = {
version: 2,
startedAt: now - (25 * 60 * 60 * 1_000),
minuteTrackingStartedAt: now - (25 * 60 * 60 * 1_000),
providerBytesOnlyDays: ["2026-08-10"],
days: [{
day: "2026-08-10",
downloadedBytes: 125,
measuredBytes: 0,
completedFiles: 0,
failedFiles: 0,
activeDownloadMs: 0,
providers: { realdebrid: { bytes: 125, completed: 0, failed: 0 } }
}],
minutes: []
};
snapshot.stats.rolling24Hours = {
from: now - (24 * 60 * 60 * 1_000),
to: now,
downloadedBytes: 0,
accounts: []
};
const model = buildStatisticsViewModel(snapshot, "last24", now);
expectUnavailable(model.metrics.downloadedBytes);
expect(model.providers).toEqual([]);
});
it("shows a complete rolling description after 24 hours of statistics coverage", () => {
const snapshot = createSnapshot();
snapshot.stats.statistics = {
version: 2,
startedAt: now - (25 * 60 * 60 * 1_000),
minuteTrackingStartedAt: now - (25 * 60 * 60 * 1_000),
days: [],
minutes: []
};
@@ -268,6 +346,21 @@ describe("statistics model", () => {
expect(model.message).toContain("vergangenen 24 Stunden");
expect(model.message).not.toContain("seit Beginn der Aufzeichnung");
});
it("does not infer minute coverage from an old ledger start during migration", () => {
const snapshot = createSnapshot();
snapshot.stats.statistics = {
version: 2,
startedAt: now - (25 * 60 * 60 * 1_000),
days: [],
minutes: []
};
const model = buildStatisticsViewModel(snapshot, "last24", now);
expect(model.message).toContain("seit Beginn der Aufzeichnung");
expect(model.message).not.toContain("vergangenen 24 Stunden");
});
it("treats a stale daily key as a genuine zero today without stale provider rows", () => {
const snapshot = createSnapshot();
@@ -392,6 +485,26 @@ describe("statistics model", () => {
});
describe("statistics view", () => {
it("formats provider outcome counts for the active locale pipeline", () => {
const model = {
...buildStatisticsViewModel(createSnapshot(), "today", now),
providers: [{ id: "realdebrid", label: "Real-Debrid", bytes: 0, completed: 1_250, failed: 2_500 }]
};
const html = renderToStaticMarkup(<StatisticsContent actions={createActions()} chart={<div />} model={model} />);
expect(html).toContain("1.250 fertig · 2.500 Fehler");
});
it("formats the sidebar provider count for the active locale pipeline", () => {
const model = {
...buildStatisticsViewModel(createSnapshot(), "today", now),
providers: Array.from({ length: 1_250 }, (_, index) => ({ id: String(index), label: String(index), bytes: 0, completed: 0, failed: 0 }))
};
const html = renderToStaticMarkup(<StatisticsSidebarStatus model={model} />);
expect(html).toContain("Provider: 1.250");
});
it("keeps large statistic data volumes precise in gigabytes instead of rounding to terabytes", () => {
const snapshot = createSnapshot();
let ledger = createStatisticsLedger(now);
+380 -27
View File
@@ -1,6 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
@@ -9,7 +9,7 @@ import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../s
import { AppSettings } from "../src/shared/types";
import { defaultSettings } from "../src/main/constants";
import { configureCredentialProtector } from "../src/main/credential-protection";
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
import { acquirePersistenceBarrier, addHistoryEntryForRetention, clearHistory, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, replaceHistory, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
const tempDirs: string[] = [];
type SettingsSaveMode = "sync" | "async";
@@ -1472,12 +1472,12 @@ describe("settings storage", () => {
expect(loadHistoryForRetention(paths, "never")).toEqual([]);
});
it("clears persisted history for session retention mode", () => {
it("clears persisted history for session retention mode", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveHistory(paths, [{
saveHistory(paths, [{
id: "hist-2",
name: "kept",
totalBytes: 2048,
@@ -1488,15 +1488,166 @@ describe("settings storage", () => {
durationSeconds: 20,
status: "completed",
outputDir: path.join(dir, "out"),
urls: ["https://example.com/file2.rar"]
}]);
resetHistoryForRetention(paths, "session");
expect(loadHistory(paths)).toEqual([]);
});
it("caps persisted history to the configured maxEntries", () => {
urls: ["https://example.com/file2.rar"]
}]);
saveHistory(paths, [{
id: "hist-3",
name: "newer",
totalBytes: 4096,
downloadedBytes: 4096,
fileCount: 1,
provider: "realdebrid",
completedAt: Date.now(),
durationSeconds: 25,
status: "completed",
outputDir: path.join(dir, "out"),
urls: ["https://example.com/file3.rar"]
}]);
resetHistoryForRetention(paths, "session");
expect(loadHistory(paths)).toEqual([]);
expect(fs.existsSync(`${paths.historyFile}.bak`)).toBe(false);
});
it("recovers history from the last valid backup when the primary file is corrupted", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const entry = {
id: "history-backup",
name: "Backup",
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid" as const,
completedAt: Date.now(),
durationSeconds: 1,
status: "completed" as const,
outputDir: path.join(dir, "out"),
urls: []
};
saveHistory(paths, [entry]);
saveHistory(paths, [{ ...entry, id: "newer", name: "Neuer" }]);
fs.writeFileSync(paths.historyFile, "{broken", "utf8");
expect(loadHistory(paths).map((item) => item.id)).toEqual(["history-backup"]);
expect(loadHistory(paths).map((item) => item.id)).toEqual(["history-backup"]);
});
it("reports an unreadable history instead of silently replacing it with an empty list", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.mkdirSync(paths.baseDir, { recursive: true });
fs.writeFileSync(paths.historyFile, "{broken", "utf8");
fs.writeFileSync(`${paths.historyFile}.bak`, "{also-broken", "utf8");
expect(() => loadHistory(paths)).toThrow("Verlaufsspeicher ist beschädigt");
});
it("returns a valid history backup even when repairing the primary file fails", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const entry = {
id: "readable-backup",
name: "Backup",
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid" as const,
completedAt: Date.now(),
durationSeconds: 1,
status: "completed" as const,
outputDir: path.join(dir, "out"),
urls: []
};
saveHistory(paths, [entry]);
saveHistory(paths, [{ ...entry, id: "newer" }]);
fs.writeFileSync(paths.historyFile, "{broken", "utf8");
const rename = vi.spyOn(fs, "renameSync").mockImplementation(() => {
throw Object.assign(new Error("locked"), { code: "EPERM" });
});
try {
expect(loadHistory(paths).map((item) => item.id)).toEqual(["readable-backup"]);
} finally {
rename.mockRestore();
}
});
it("authoritatively replaces both history copies without retaining previous entries", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveHistory(paths, [{ id: "old", name: "Alt" } as never]);
replaceHistory(paths, [{ id: "new", name: "Neu" } as never]);
expect(loadHistory(paths).map((entry) => entry.id)).toEqual(["new"]);
expect((JSON.parse(fs.readFileSync(`${paths.historyFile}.bak`, "utf8")) as Array<{ id: string }>).map((entry) => entry.id)).toEqual(["new"]);
});
it("rolls back the backup copy when authoritative primary replacement fails", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveHistory(paths, [{ id: "older", name: "Älter" } as never]);
saveHistory(paths, [{ id: "old", name: "Alt" } as never]);
const previousBackup = fs.readFileSync(`${paths.historyFile}.bak`, "utf8");
const renameFile = fs.renameSync.bind(fs);
let failed = false;
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => {
if (!failed && String(destination) === paths.historyFile) {
failed = true;
throw Object.assign(new Error("primary locked"), { code: "EPERM" });
}
return renameFile(source, destination);
});
try {
expect(() => replaceHistory(paths, [{ id: "new", name: "Neu" } as never])).toThrow("primary locked");
} finally {
rename.mockRestore();
}
expect(loadHistory(paths).map((entry) => entry.id)).toEqual(["old"]);
expect(fs.readFileSync(`${paths.historyFile}.bak`, "utf8")).toBe(previousBackup);
});
it("keeps the primary history intact when clearing its backup fails", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const entry = {
id: "clear-protected",
name: "Geschützt",
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid" as const,
completedAt: Date.now(),
durationSeconds: 1,
status: "completed" as const,
outputDir: path.join(dir, "out"),
urls: []
};
saveHistory(paths, [entry]);
saveHistory(paths, [{ ...entry, id: "clear-current" }]);
const removeFile = fs.rmSync.bind(fs);
const remove = vi.spyOn(fs, "rmSync").mockImplementation((filePath, options) => {
if (String(filePath) === `${paths.historyFile}.bak`) throw Object.assign(new Error("busy"), { code: "EBUSY" });
return removeFile(filePath, options);
});
try {
expect(() => clearHistory(paths)).toThrow();
expect(fs.existsSync(paths.historyFile)).toBe(true);
} finally {
remove.mockRestore();
}
});
it("caps persisted history to the configured maxEntries", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
@@ -1734,7 +1885,8 @@ describe("settings storage", () => {
const updated = removeHistoryEntries(paths, ["hist-0", "hist-599"], limits);
expect(renameSpy).toHaveBeenCalledTimes(1);
expect(renameSpy).toHaveBeenCalledTimes(2);
expect(renameSpy.mock.calls.map((call) => call[1])).toEqual([`${paths.historyFile}.bak`, paths.historyFile]);
renameSpy.mockRestore();
expect(updated).toHaveLength(598);
expect(updated.some((entry) => entry.id === "hist-0" || entry.id === "hist-599")).toBe(false);
@@ -1952,7 +2104,7 @@ describe("settings storage", () => {
expect(loaded.items["item-outside"]?.targetPath).toBe("");
});
it("captures async session save payload before later mutations", async () => {
it("captures async session save payload before later mutations", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
@@ -1965,10 +2117,185 @@ describe("settings storage", () => {
await pending;
const persisted = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { summaryText: string };
expect(persisted.summaryText).toBe("before-mutation");
});
it("creates session backup before sync and async session overwrites", async () => {
expect(persisted.summaryText).toBe("before-mutation");
});
it("keeps newer synchronous settings and session saves when older async commits resume", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-sync-wins-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const oldSettings = { ...defaultSettings(), packageName: "old-async" };
const newSettings = { ...defaultSettings(), packageName: "new-sync" };
const oldSession = { ...emptySession(), summaryText: "old-async" };
const newSession = { ...emptySession(), summaryText: "new-sync" };
saveSettings(paths, defaultSettings());
saveSession(paths, emptySession());
let releaseSettingsCommit = () => {};
let releaseSessionCommit = () => {};
let markSettingsCommitStarted = () => {};
let markSessionCommitStarted = () => {};
const settingsCommitStarted = new Promise<void>((resolve) => { markSettingsCommitStarted = resolve; });
const sessionCommitStarted = new Promise<void>((resolve) => { markSessionCommitStarted = resolve; });
const settingsCommitRelease = new Promise<void>((resolve) => { releaseSettingsCommit = resolve; });
const sessionCommitRelease = new Promise<void>((resolve) => { releaseSessionCommit = resolve; });
const renameFile = fs.promises.rename.bind(fs.promises);
const rename = vi.spyOn(fs.promises, "rename").mockImplementation(async (source, destination) => {
const sourcePath = String(source);
if (sourcePath === `${paths.configFile}.settings.tmp`) {
markSettingsCommitStarted();
await settingsCommitRelease;
}
if (sourcePath === `${paths.sessionFile}.async.tmp`) {
markSessionCommitStarted();
await sessionCommitRelease;
}
await renameFile(source, destination);
});
try {
const oldSettingsSave = saveSettingsAsync(paths, oldSettings);
const oldSessionSave = saveSessionAsync(paths, oldSession);
const oldSaves = Promise.all([oldSettingsSave, oldSessionSave]);
const asyncCommitPaused = await Promise.race([
Promise.all([settingsCommitStarted, sessionCommitStarted]).then(() => true),
oldSaves.then(() => false)
]);
saveSettings(paths, newSettings);
saveSession(paths, newSession);
if (asyncCommitPaused) {
releaseSettingsCommit();
releaseSessionCommit();
}
await oldSaves;
expect(loadSettings(paths).packageName).toBe("new-sync");
expect(loadSession(paths).summaryText).toBe("new-sync");
} finally {
releaseSettingsCommit();
releaseSessionCommit();
rename.mockRestore();
}
});
it("persists the async session primary when replacing its backup is temporarily blocked", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-session-backup-blocked-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveSession(paths, { ...emptySession(), summaryText: "previous" });
const renameFile = fs.renameSync.bind(fs);
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => {
if (String(destination) === `${paths.sessionFile}.bak`) {
throw Object.assign(new Error("backup busy"), { code: "EBUSY" });
}
return renameFile(source, destination);
});
try {
await saveSessionAsync(paths, { ...emptySession(), summaryText: "latest" });
} finally {
rename.mockRestore();
}
expect(loadSession(paths).summaryText).toBe("latest");
});
it("waits for in-flight settings and session writes and discards saves blocked by an import barrier", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-barrier-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const oldSettings = { ...defaultSettings(), packageName: "old-settings" };
const importedSettings = { ...defaultSettings(), packageName: "imported-settings" };
const oldSession = { ...emptySession(), summaryText: "old-session" };
const importedSession = { ...emptySession(), summaryText: "imported-session" };
saveSettings(paths, defaultSettings());
saveSession(paths, emptySession());
let releaseSettingsWrite = () => {};
let releaseSessionWrite = () => {};
let markSettingsWriteStarted = () => {};
let markSessionWriteStarted = () => {};
const settingsWriteStarted = new Promise<void>((resolve) => { markSettingsWriteStarted = resolve; });
const sessionWriteStarted = new Promise<void>((resolve) => { markSessionWriteStarted = resolve; });
const settingsWriteRelease = new Promise<void>((resolve) => { releaseSettingsWrite = resolve; });
const sessionWriteRelease = new Promise<void>((resolve) => { releaseSessionWrite = resolve; });
const writeFile = fs.promises.writeFile.bind(fs.promises);
const openFile = fs.promises.open.bind(fs.promises);
const write = vi.spyOn(fs.promises, "writeFile").mockImplementation(async (filePath, data, options) => {
await writeFile(filePath, data, options);
if (String(filePath) === `${paths.configFile}.settings.tmp`) {
markSettingsWriteStarted();
await settingsWriteRelease;
}
});
const open = vi.spyOn(fs.promises, "open").mockImplementation(async (filePath, flags, mode) => {
const handle = await openFile(filePath, flags, mode);
if (String(filePath) === `${paths.sessionFile}.async.tmp`) {
const originalWrite = handle.writeFile.bind(handle);
handle.writeFile = async (...args: Parameters<typeof handle.writeFile>) => {
const result = await originalWrite(...args);
markSessionWriteStarted();
await sessionWriteRelease;
return result;
};
}
return handle;
});
try {
const oldSettingsSave = saveSettingsAsync(paths, oldSettings);
const oldSessionSave = saveSessionAsync(paths, oldSession);
await Promise.all([settingsWriteStarted, sessionWriteStarted]);
let barrierAcquired = false;
const barrierPromise = acquirePersistenceBarrier().then((barrier) => {
barrierAcquired = true;
return barrier;
});
await Promise.resolve();
expect(barrierAcquired).toBe(false);
releaseSettingsWrite();
releaseSessionWrite();
const barrier = await barrierPromise;
await Promise.all([oldSettingsSave, oldSessionSave]);
const blockedSettingsSave = saveSettingsAsync(paths, oldSettings);
const blockedSessionSave = saveSessionAsync(paths, oldSession);
saveSettings(paths, importedSettings);
saveSession(paths, importedSession);
await barrier.release({ replayBlocked: false });
await Promise.all([blockedSettingsSave, blockedSessionSave]);
expect(loadSettings(paths).packageName).toBe("imported-settings");
expect(loadSession(paths).summaryText).toBe("imported-session");
} finally {
releaseSettingsWrite();
releaseSessionWrite();
write.mockRestore();
open.mockRestore();
}
});
it("replays the latest saves blocked by a failed import and releases the barrier", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-barrier-replay-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const barrier = await acquirePersistenceBarrier();
const settingsSave = saveSettingsAsync(paths, { ...defaultSettings(), packageName: "replayed-settings" });
const sessionSave = saveSessionAsync(paths, { ...emptySession(), summaryText: "replayed-session" });
await barrier.release({ replayBlocked: true });
await Promise.all([settingsSave, sessionSave]);
expect(loadSettings(paths).packageName).toBe("replayed-settings");
expect(loadSession(paths).summaryText).toBe("replayed-session");
const nextBarrier = await acquirePersistenceBarrier();
await nextBarrier.release({ replayBlocked: false });
});
it("creates session backup before sync and async session overwrites", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
@@ -1994,7 +2321,7 @@ describe("settings storage", () => {
expect(primaryAfterAsync.summaryText).toBe("third");
});
it("applies defaults for missing fields when loading old config", () => {
it("applies defaults for missing fields when loading old config", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
@@ -2024,8 +2351,34 @@ describe("settings storage", () => {
expect(loaded.retryLimit).toBe(defaults.retryLimit);
expect(loaded.collectMkvToLibrary).toBe(defaults.collectMkvToLibrary);
expect(loaded.mkvLibraryDir).toBe(defaults.mkvLibraryDir);
expect(loaded.theme).toBe(defaults.theme);
expect(loaded.bandwidthSchedules).toEqual(defaults.bandwidthSchedules);
expect(loaded.updateRepo).toBe(defaults.updateRepo);
});
});
expect(loaded.theme).toBe(defaults.theme);
expect(loaded.themePreference).toBe(defaults.themePreference);
expect(loaded.bandwidthSchedules).toEqual(defaults.bandwidthSchedules);
expect(loaded.updateRepo).toBe(defaults.updateRepo);
});
it("persists the semantic theme preference and migrates legacy fixed themes", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const legacy = { ...defaultSettings(), theme: "light" as const } as Partial<AppSettings>;
delete legacy.themePreference;
fs.mkdirSync(path.dirname(paths.configFile), { recursive: true });
fs.writeFileSync(paths.configFile, JSON.stringify(legacy), "utf8");
expect(loadSettings(paths).themePreference).toBe("light");
saveSettings(paths, { ...defaultSettings(), theme: "dark", themePreference: "system" });
expect(loadSettings(paths)).toEqual(expect.objectContaining({
theme: "dark",
themePreference: "system"
}));
expect(normalizeSettings({
...defaultSettings(),
theme: "light",
themePreference: "invalid" as AppSettings["themePreference"]
}).themePreference).toBe("light");
});
});
+1
View File
@@ -122,6 +122,7 @@ function createSettings(): AppSettings {
clipboardWatch: true,
minimizeToTray: false,
theme: "dark",
themePreference: "dark",
logStorageLocation: "appdata",
collapseNewPackages: false,
animatePackageDisclosure: true,
+2 -1
View File
@@ -64,8 +64,9 @@ export function createVisualElectronApi(
enrichCollectorPackages: async (request) => ({ packages: clone(request.packages), invalidCount: 0, duplicateCount: 0 }),
onCollectorEnrichmentProgress: () => () => {},
getCollectorState: async () => ({ packages: [], collapsedPackageIds: [] }),
getCollectorStateSync: () => ({ packages: [], collapsedPackageIds: [] }),
saveCollectorState: async (state) => clone(state),
saveCollectorStateSync: () => {},
saveCollectorStateSync: () => true,
getPathForDroppedFile: () => "",
getStartConflicts: async () => [],
resolveStartConflict: async (_packageId, policy) => ({