fix(history): remove selections atomically
This commit is contained in:
@@ -6,6 +6,7 @@ import type { HistoryEntry } from "../src/shared/types";
|
||||
const electron = vi.hoisted(() => ({
|
||||
api: undefined as ElectronApi | undefined,
|
||||
listeners: new Map<string, (...args: unknown[]) => void>(),
|
||||
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined),
|
||||
on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => {
|
||||
electron.listeners.set(channel, listener);
|
||||
}),
|
||||
@@ -23,7 +24,7 @@ vi.mock("electron", () => ({
|
||||
}
|
||||
},
|
||||
ipcRenderer: {
|
||||
invoke: vi.fn(),
|
||||
invoke: electron.invoke,
|
||||
on: electron.on,
|
||||
removeListener: electron.removeListener,
|
||||
send: vi.fn()
|
||||
@@ -48,4 +49,13 @@ describe("history preload contract", () => {
|
||||
unsubscribe?.();
|
||||
expect(electron.removeListener).toHaveBeenCalledWith(IPC_CHANNELS.HISTORY_ENTRY_ADDED, listener);
|
||||
});
|
||||
|
||||
it("forwards a history selection through one bulk removal channel", async () => {
|
||||
electron.invoke.mockClear();
|
||||
|
||||
await electron.api?.removeHistoryEntries(["history-a", "history-b"]);
|
||||
|
||||
expect(electron.invoke).toHaveBeenCalledTimes(1);
|
||||
expect(electron.invoke).toHaveBeenCalledWith(IPC_CHANNELS.REMOVE_HISTORY_ENTRIES, ["history-a", "history-b"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -639,6 +639,17 @@ describe("visual history states", () => {
|
||||
expect(listener).toContain("mergeLiveHistoryEntry");
|
||||
expect(listener).not.toContain("getHistory()");
|
||||
});
|
||||
|
||||
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");
|
||||
const removalEnd = source.indexOf("const clearHistoryEntries", removalStart);
|
||||
const removal = source.slice(removalStart, removalEnd);
|
||||
|
||||
expect(removal).toContain("window.rd.removeHistoryEntries(ids)");
|
||||
expect(removal).not.toContain("Promise.allSettled");
|
||||
expect(removal).not.toContain("window.rd.removeHistoryEntry(");
|
||||
});
|
||||
|
||||
it("keeps bootstrap deterministic before exposing loading and error responses to the opened history view", async () => {
|
||||
const loadingApi = createVisualElectronApi(createVisualFixture("dense"), "?history-state=loading");
|
||||
|
||||
+37
-5
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
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";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
@@ -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, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
||||
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
type SettingsSaveMode = "sync" | "async";
|
||||
@@ -30,8 +30,9 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -938,7 +939,7 @@ describe("settings storage", () => {
|
||||
expect(normalized.historyRetentionMode).toBe("permanent");
|
||||
});
|
||||
|
||||
it("skips adding persisted history entries when history retention is never", () => {
|
||||
it("skips adding persisted history entries when history retention is never", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
@@ -1200,6 +1201,37 @@ describe("settings storage", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("removes a history selection in one pass without applying the default 500-entry limit", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const limits = { maxEntries: 1_000, maxAgeDays: 0 };
|
||||
const entries = Array.from({ length: 600 }, (_, index) => ({
|
||||
id: `hist-${index}`,
|
||||
name: `Paket ${index}`,
|
||||
totalBytes: 1024,
|
||||
downloadedBytes: 1024,
|
||||
fileCount: 1,
|
||||
provider: "realdebrid" as const,
|
||||
completedAt: Date.now() - index,
|
||||
durationSeconds: 12,
|
||||
status: "completed" as const,
|
||||
outputDir: path.join(dir, `out-${index}`),
|
||||
urls: [`https://example.com/file-${index}.rar`]
|
||||
}));
|
||||
saveHistory(paths, entries, limits);
|
||||
const renameSpy = vi.spyOn(fs, "renameSync");
|
||||
renameSpy.mockClear();
|
||||
|
||||
const updated = removeHistoryEntries(paths, ["hist-0", "hist-599"], limits);
|
||||
|
||||
expect(renameSpy).toHaveBeenCalledTimes(1);
|
||||
renameSpy.mockRestore();
|
||||
expect(updated).toHaveLength(598);
|
||||
expect(updated.some((entry) => entry.id === "hist-0" || entry.id === "hist-599")).toBe(false);
|
||||
expect(loadHistory(paths, limits)).toHaveLength(598);
|
||||
});
|
||||
|
||||
it("returns empty session when session file contains invalid JSON", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
@@ -388,6 +388,11 @@ export function createVisualElectronApi(
|
||||
fixture.history.splice(index, 1);
|
||||
}
|
||||
},
|
||||
removeHistoryEntries: async (entryIds) => {
|
||||
const removed = new Set(entryIds);
|
||||
const retained = fixture.history.filter((entry) => !removed.has(entry.id));
|
||||
fixture.history.splice(0, fixture.history.length, ...retained);
|
||||
},
|
||||
revealHistoryEntry: async (entryId) => fixture.history.some((entry) => entry.id === entryId)
|
||||
? { ok: true }
|
||||
: { ok: false, reason: "entry-not-found" },
|
||||
|
||||
Reference in New Issue
Block a user