diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index dcde135..b8da079 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -8,6 +8,10 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält ## Zuletzt verifizierter Stand +- 6. September 2026, noch nicht veröffentlicht: `switchToCollectorOnClipboard` macht den automatischen Tabwechsel bei erkannten Zwischenablage-Links optional. Standard und Migration vorhandener Konfigurationen ohne Feld sind `false`; nur explizites `true` aktiviert ihn. Schalter unter Einstellungen → Allgemein → Download-Verhalten, deutsch/englisch übersetzt. Zwischenablage-Import sammelt und speichert weiterhin im Hintergrund und verändert bei ausgeschalteter Option weder Tab noch Linksammlerfilter. Manuelles Einfügen, Drag-and-drop und DLC-Import behalten ihre bisherige Navigation. Die Entscheidung liest nach der asynchronen Vorbereitung die aktuell bestätigte Einstellung, damit Ausschalten während eines Imports berücksichtigt wird. +- Verifiziert: TypeScript, Main-/Renderer-Build und 522 gezielte Tests für Einstellungen, Persistenz/Migration, Renderer-Validierung, Übersetzung, Collector und Backups. Isolierte Electron-Browserprobe `tests/visual/collector-navigation.html` verwendet ausschließlich Testdaten und bestätigt Hintergrundimporte aus Downloads/Einstellungen, Opt-in sowie Opt-out während eines laufenden Imports; alle vier Pakete bleiben gespeichert. Keine produktive Konfiguration geändert, kein Serverneustart und kein neues Release. +- Planungsauftrag Account-Reihenfolge (noch nicht implementieren): Unter Verwendungsregeln die Provider aufklappbar machen, darin nur deren Accounts mit Name, API/Web-Modus, Prioritätsnummer und aktuellem Status anzeigen; Reihenfolge per Drag-and-drop und Pfeiltasten ändern. Pro Provider Wahl zwischen bisheriger automatischer Verteilung (Migrationsstandard) und fester Priorität. Feste Priorität nutzt für neue Auflösungen den ersten tatsächlich nutzbaren Account; deaktivierte Accounts, Tageslimits, passende Cooldowns und ausgeschöpfte Account-Kapazität werden übersprungen. Nach Erholung hat Account 1 wieder Vorrang; laufende Downloads werden nicht umgeschaltet. Erst nach Ausschöpfen der Accounts greift die bestehende Provider-Fallbackregel. Link-/providerweite Fehler dürfen keine sinnlose Rotation durch alle Accounts auslösen. +- Technische Basis des Account-Plans: `AccountWorkspace.tsx` rendert aktuell nur Provider-Zeilen. Debrid-Link iteriert in `debrid.ts` bereits in Key-Reihenfolge mit Disabled-/Limit-/Cooldown-Prüfungen; Real-Debrid verwendet geringste In-flight-Auslastung plus Sticky-/Rotationszustand, Mega-Debrid Cursor-Reihenfolge plus In-flight-Sortierung. Deshalb ist eine reine UI-Umsortierung unzureichend. Umsetzung in drei überprüfbaren Schritten: (1) separates Prioritätsmodell pro Provider mit stabilen Account-IDs, Normalisierung, Speichern/Backup sowie Referenzpflege beim Ersetzen/Löschen und Anhängen neuer Accounts; (2) aufklappbare Bedienung und Moduswahl ohne Geheimnisse; (3) priorisierte Auswahl in den drei Engines unter Erhalt von Hoster-/Account-Limits und Reservierungen. Tests müssen 1→2→3 bei Sperren, Rückkehr zu 1 nach Cooldown, Parallelität, Provider-Fallback an/aus, Neustart/Import, Accountänderungen und unveränderten Automatikmodus abdecken. Mittlerer Funktionsumfang, kein bloßer Listenumbau; Freigabe der konkreten Semantik vor Implementierung. - Release `v2.0.89` nach ausdrücklichem „release!“ auf GitHub und Forgejo veröffentlicht: Drei-Klick-Verfügbarkeitssortierung mit Part-Anzahl bei Gleichstand und korrigierte Hot-Dev-Startskripte. TypeScript, Node-Self-Check, Main-/Renderer-Build und 16 Backup-API-Tests erfolgreich. Finaler Clientlauf mit vier Workern: 2.748 Tests erfolgreich, vier optionale JVM-Tests übersprungen; separat 22 Release-Metadatentests erfolgreich, zwei bekannte Windows-Symlink-Fixtures ausgenommen (insgesamt 2.770 erfolgreiche Client-Tests). Ein bestehender Updater-Timeout im ersten stark parallelen Lauf bestand isoliert und im vollständigen Wiederholungslauf. - Veröffentlichungsprüfung `v2.0.89`: Tag auf beiden Remotes exakt `e18dfa12044e775ff30739029f07f30f9931fced`; beide Plattformen führen denselben Titel und Tag als neuesten stabilen Release, GitHub-Text Englisch und Forgejo-Text Deutsch. Alle zwölf öffentlichen Assets erneut heruntergeladen und Größe sowie SHA-256 mit den lokalen Originalen verglichen. Beide `latest.yml` bestätigen Version und Installer-SHA-512. Setup-SHA-256 `830856d02b41a37a938552b1d828062617fb7feb131791f1eaed1bc9e34c644c`, Portable-SHA-256 `97f04e83397866da50b7750f55f517d5872d52280432ac348251b0088677af43`. Installer-/Portable-Inhalte mit `verify_public_release.mjs --verify-archives` geprüft; Quellarchiv mit 7-Zip geprüft, entspricht dem Tag-Commit. Lokale Originale unter `release/staging-v2.0.89`. Keine produktive Installation und kein Serverneustart. - Availability-Sortierung nach `v2.0.88`: Klickfolge online/viele Parts zuerst → offline/wenige Parts zuerst → ursprüngliche Reihenfolge ohne aktiven Sortierpfeil. Online-/Offline-Anteile bleiben vorrangig; Part-Anzahl entscheidet nur bei identischer Verfügbarkeit. Neue Pakete bleiben beim Ausschalten hinten erhalten, gelöschte werden nicht wieder eingefügt. Wechsel auf eine andere Sortierspalte setzt den Availability-Zyklus zurück. Bestehender Pending-Snapshot-Schutz und sofortiger Sortierwechsel bleiben erhalten. TypeScript und gezielte Sortier-/Ansichtstests erfolgreich; isolierte Electron-Browserprobe mit Testdaten prüft zwei vollständige Zyklen samt verzögerten Snapshots, Sortierindikatoren und DOM-Reihenfolge ohne falsche Zwischenstände. Kein neues Release. diff --git a/src/main/constants.ts b/src/main/constants.ts index 7ec0936..992b073 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -114,7 +114,8 @@ export function defaultSettings(): AppSettings { proxyConnectionsPerDownload: 32, updateRepo: DEFAULT_UPDATE_REPO, autoUpdateCheck: true, - clipboardWatch: false, + clipboardWatch: false, + switchToCollectorOnClipboard: false, minimizeToTray: false, theme: "dark" as const, themePreference: "dark" as const, diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 96cff45..79bb436 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -213,6 +213,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings updateRepo: settings.updateRepo, autoUpdateCheck: settings.autoUpdateCheck, clipboardWatch: settings.clipboardWatch, + switchToCollectorOnClipboard: settings.switchToCollectorOnClipboard, minimizeToTray: settings.minimizeToTray, theme: settings.theme, themePreference: settings.themePreference, diff --git a/src/main/storage.ts b/src/main/storage.ts index d02ffd4..0b3de89 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -622,7 +622,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { proxyConnectionsPerDownload: clampNumber(settings.proxyConnectionsPerDownload, defaults.proxyConnectionsPerDownload, 2, 80), autoUpdateCheck: Boolean(settings.autoUpdateCheck), updateRepo: migrateUpdateRepo(asText(settings.updateRepo), defaults.updateRepo), - clipboardWatch: Boolean(settings.clipboardWatch), + clipboardWatch: Boolean(settings.clipboardWatch), + switchToCollectorOnClipboard: settings.switchToCollectorOnClipboard === true, minimizeToTray: Boolean(settings.minimizeToTray), logStorageLocation: VALID_LOG_STORAGE_LOCATIONS.has(settings.logStorageLocation) ? settings.logStorageLocation diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 463691b..794c113 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -41,6 +41,7 @@ import { } from "../shared/provider-daily-limits"; import { createAvailabilitySortCycle, preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order"; import { createPackageOrderState } from "./package-order-state"; +import { shouldFocusCollectorImport, type CollectorImportSource } from "./collector-navigation"; import { getPackagesWithOfflineLinks } from "../shared/offline-packages"; import type { OfflineSkipScope } from "../shared/types"; import { OfflineRemovalScopeChoice } from "./views/downloads/OfflineRemovalScopeChoice"; @@ -959,7 +960,7 @@ const emptySnapshot = (): UiSnapshot => ({ autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, offlineSkipScope: "archive", speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", proxyDownloadEnabled: false, proxyListPath: "", proxyApiProxyIndex: 1, proxyConnectionsPerDownload: 32, - updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, + updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, switchToCollectorOnClipboard: false, minimizeToTray: false, theme: "dark", themePreference: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, notifyPackageSuccessMode: "digest", notifyOnRemainingBelow: false, notifyRemainingThresholdGb: 50, @@ -1884,7 +1885,7 @@ export function App(): ReactElement { const collectorPersistenceBudgetRef = useRef(null); const collectorEnrichmentGenerationsRef = useRef(new Map()); const collectorEnrichmentRequestsRef = useRef(new Map>()); - const importCollectorTextRef = useRef<(rawText: string) => Promise>(() => Promise.resolve()); + const importCollectorTextRef = useRef<(rawText: string, source?: CollectorImportSource) => Promise>(() => Promise.resolve()); const activeTabRef = useRef(tab); const packageOrderRef = useRef([]); const packageOrderStateRef = useRef(createPackageOrderState()); @@ -2401,7 +2402,7 @@ export function App(): ReactElement { loadInitialSnapshot(); unsubClipboard = window.rd.onClipboardDetected((links) => { showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000); - void importCollectorTextRef.current(links.join("\n")); + void importCollectorTextRef.current(links.join("\n"), "clipboard"); }); unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => { if (!mountedRef.current) { @@ -4033,7 +4034,7 @@ export function App(): ReactElement { }); }, [collapsedCollectorPackageIds, collectorPackages]); - const importCollectorText = async (rawText: string): Promise => { + const importCollectorText = async (rawText: string, source: CollectorImportSource = "manual"): Promise => { if (!rawText.trim()) { showToast("Keine Links eingegeben", 2200); return; @@ -4049,8 +4050,10 @@ export function App(): ReactElement { return; } if (!mergeCollectorResult(prepared)) return; - setCollectorFilter("all"); - setTab("collector"); + if (shouldFocusCollectorImport(source, snapshotRef.current.settings.switchToCollectorOnClipboard)) { + setCollectorFilter("all"); + setTab("collector"); + } const linkCount = prepared.packages.reduce((sum, pkg) => sum + pkg.links.length, 0); showToast(`${prepared.packages.length} Paket(e), ${linkCount} Link(s) gesammelt`); enrichCollectorResult(prepared.packages); diff --git a/src/renderer/collector-navigation.ts b/src/renderer/collector-navigation.ts new file mode 100644 index 0000000..0d27799 --- /dev/null +++ b/src/renderer/collector-navigation.ts @@ -0,0 +1,5 @@ +export type CollectorImportSource = "manual" | "clipboard"; + +export function shouldFocusCollectorImport(source: CollectorImportSource, switchToCollectorOnClipboard: boolean | undefined): boolean { + return source === "manual" || switchToCollectorOnClipboard === true; +} diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index be1dfde..dc3f937 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -13,6 +13,8 @@ const pairs = [ ["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."], ["Fehlende Arbeitsordner beim Start anlegen", "Create missing work folders on startup"], ["Erstellt fehlende Download-, Entpack- und Videosammelordner sofort beim Speichern sowie bei jedem Programmstart. Optionale Ordner werden nur bei aktiver Funktion angelegt; vorhandene Inhalte bleiben unverändert.", "Creates missing download, extraction, and video library folders immediately when settings are saved and on every application start. Optional folders are created only when their feature is enabled; existing contents remain unchanged."], ["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"], ["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"], ["Animationen", "Animations"], + ["Bei erkannten Zwischenablage-Links zum Linksammler wechseln", "Switch to Link Collector when clipboard links are detected"], + ["Standardmäßig aus: Links werden im Hintergrund gesammelt, ohne den aktuellen Tab zu wechseln. Gilt nur bei aktivierter Zwischenablage-Überwachung.", "Off by default: links are collected in the background without switching tabs. Applies only when clipboard monitoring is enabled."], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"], ["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"], ["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"], diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index 05f1814..4a2d70f 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -623,7 +623,12 @@ export function buildSettingsFormViewModel({ help: "Beim Archivsatz werden alle noch offenen Teile desselben Mehrteil-Archivs übersprungen. Bereits abgeschlossene Downloads bleiben erhalten." }, { id: "autoResumeOnStart", kind: "switch", label: "Beim Start automatisch fortsetzen", value: settings.autoResumeOnStart }, - { id: "clipboardWatch", kind: "switch", label: "Zwischenablage überwachen", value: settings.clipboardWatch } + { id: "clipboardWatch", kind: "switch", label: "Zwischenablage überwachen", value: settings.clipboardWatch }, + { + id: "switchToCollectorOnClipboard", kind: "switch", label: "Bei erkannten Zwischenablage-Links zum Linksammler wechseln", + value: settings.switchToCollectorOnClipboard, + help: "Standardmäßig aus: Links werden im Hintergrund gesammelt, ohne den aktuellen Tab zu wechseln. Gilt nur bei aktivierter Zwischenablage-Überwachung." + } ] }, { diff --git a/src/shared/types.ts b/src/shared/types.ts index 1b3234c..d05e79a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -211,11 +211,12 @@ export interface AppSettings extends DailyStartSettings, ProxyDownloadSettings { offlineSkipScope: OfflineSkipScope; speedLimitEnabled: boolean; speedLimitKbps: number; - speedLimitMode: SpeedMode; - updateRepo: string; - autoUpdateCheck: boolean; - clipboardWatch: boolean; - minimizeToTray: boolean; + speedLimitMode: SpeedMode; + updateRepo: string; + autoUpdateCheck: boolean; + clipboardWatch: boolean; + switchToCollectorOnClipboard: boolean; + minimizeToTray: boolean; theme: AppTheme; themePreference: ThemePreference; logStorageLocation: LogStorageLocation; @@ -352,6 +353,7 @@ export interface RendererSettings extends DailyStartSettings, ProxyDownloadSetti updateRepo: string; autoUpdateCheck: boolean; clipboardWatch: boolean; + switchToCollectorOnClipboard: boolean; minimizeToTray: boolean; theme: AppTheme; themePreference: ThemePreference; diff --git a/tests/collector-navigation.test.ts b/tests/collector-navigation.test.ts new file mode 100644 index 0000000..ecac026 --- /dev/null +++ b/tests/collector-navigation.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { shouldFocusCollectorImport } from "../src/renderer/collector-navigation"; + +describe("collector import navigation", () => { + it("keeps automatic clipboard imports in the background unless explicitly enabled", () => { + expect(shouldFocusCollectorImport("clipboard", undefined)).toBe(false); + expect(shouldFocusCollectorImport("clipboard", false)).toBe(false); + expect(shouldFocusCollectorImport("clipboard", true)).toBe(true); + }); + + it("keeps explicit manual imports focused regardless of the automatic setting", () => { + for (const enabled of [undefined, false, true]) { + expect(shouldFocusCollectorImport("manual", enabled)).toBe(true); + } + }); +}); diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index 477f1f4..eab235c 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from "vitest"; import { normalizeLanguage, translateUiText } from "../src/renderer/i18n"; describe("renderer localization", () => { + it("translates the optional clipboard navigation setting", () => { + expect(translateUiText("Bei erkannten Zwischenablage-Links zum Linksammler wechseln", "en")).toBe("Switch to Link Collector when clipboard links are detected"); + expect(translateUiText("Switch to Link Collector when clipboard links are detected", "de")).toBe("Bei erkannten Zwischenablage-Links zum Linksammler wechseln"); + expect(translateUiText("Standardmäßig aus: Links werden im Hintergrund gesammelt, ohne den aktuellen Tab zu wechseln. Gilt nur bei aktivierter Zwischenablage-Überwachung.", "en")).toBe("Off by default: links are collected in the background without switching tabs. Applies only when clipboard monitoring is enabled."); + }); it("falls back to English", () => { expect(normalizeLanguage(undefined)).toBe("en"); expect(normalizeLanguage("fr")).toBe("en"); diff --git a/tests/renderer-settings.test.ts b/tests/renderer-settings.test.ts index 86d9497..b4ec394 100644 --- a/tests/renderer-settings.test.ts +++ b/tests/renderer-settings.test.ts @@ -4,6 +4,15 @@ import { createRendererSettings } from "../src/main/renderer-state"; import { validateRendererSettingsUpdate } from "../src/main/renderer-settings"; describe("renderer settings validation", () => { + it("projects and validates the optional clipboard tab switch as a boolean", () => { + const current = defaultSettings(); + expect(createRendererSettings(current).switchToCollectorOnClipboard).toBe(false); + for (const enabled of [true, false]) { + expect(validateRendererSettingsUpdate({ switchToCollectorOnClipboard: enabled }, current)).toEqual({ switchToCollectorOnClipboard: enabled }); + expect(createRendererSettings({ ...current, switchToCollectorOnClipboard: enabled }).switchToCollectorOnClipboard).toBe(enabled); + } + expect(() => validateRendererSettingsUpdate({ switchToCollectorOnClipboard: "true" }, current)).toThrow("Settings-Payload ist ungültig"); + }); it("projects and validates startup work-directory creation", () => { const current = { ...defaultSettings(), createWorkDirectoriesOnStartup: true }; const projected = createRendererSettings(current); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index ad5c9b6..255ef12 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -589,6 +589,8 @@ describe("settings views", () => { scheduleSpeedInputs: {} }); const language = form.groups.flatMap((group) => group.fields).find((field) => field.id === "language"); + const clipboardSwitch = form.groups.flatMap((group) => group.fields).find((field) => field.id === "switchToCollectorOnClipboard"); + expect(clipboardSwitch).toMatchObject({ kind: "switch", value: false, label: "Bei erkannten Zwischenablage-Links zum Linksammler wechseln" }); expect(language).toEqual({ id: "language", diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 1629139..a448264 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -12,6 +12,23 @@ import { configureCredentialProtector } from "../src/main/credential-protection" 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[] = []; +describe("automatic collector navigation settings", () => { + it("defaults new and existing installations to background collection", () => { + expect(defaultSettings().switchToCollectorOnClipboard).toBe(false); + expect(loadSettingsFrom({ clipboardWatch: true }).switchToCollectorOnClipboard).toBe(false); + expect(loadSettingsFrom({ switchToCollectorOnClipboard: "false" }).switchToCollectorOnClipboard).toBe(false); + }); + + it("persists opt-in and subsequent opt-out across reloads", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-collector-navigation-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + for (const enabled of [true, false]) { + saveSettings(paths, { ...defaultSettings(), switchToCollectorOnClipboard: enabled }); + expect(loadSettings(paths).switchToCollectorOnClipboard).toBe(enabled); + } + }); +}); type SettingsSaveMode = "sync" | "async"; async function saveSettingsInMode(mode: SettingsSaveMode, paths: ReturnType, settings: AppSettings): Promise { diff --git a/tests/visual/collector-navigation.html b/tests/visual/collector-navigation.html new file mode 100644 index 0000000..068a629 --- /dev/null +++ b/tests/visual/collector-navigation.html @@ -0,0 +1,5 @@ + + + Linksammler-Navigationsprüfung +
+ diff --git a/tests/visual/collector-navigation.tsx b/tests/visual/collector-navigation.tsx new file mode 100644 index 0000000..93d1ddd --- /dev/null +++ b/tests/visual/collector-navigation.tsx @@ -0,0 +1,81 @@ +import { createRoot } from "react-dom/client"; +import { App } from "../../src/renderer/App"; +import type { ElectronApi } from "../../src/shared/preload-api"; +import type { CollectorPersistenceState } from "../../src/shared/collector"; +import { createVisualFixture } from "./fixtures"; +import { createVisualElectronApi } from "./mock-electron-api"; +import "../../src/renderer/theme.css"; +import "../../src/renderer/styles.css"; + +const fixture = createVisualFixture("dense"); +fixture.snapshot.settings.switchToCollectorOnClipboard = false; +let clipboardListener: ((links: string[]) => void) | undefined; +const stateListeners = new Set[0]>(); +let saved: CollectorPersistenceState = { packages: [], collapsedPackageIds: [] }; +let preparedCount = 0; +let releasePreparation: (() => void) | undefined; +let delayPreparation = false; +window.rd = { + ...createVisualElectronApi(fixture), + onClipboardDetected: (listener) => { clipboardListener = listener; return () => { clipboardListener = undefined; }; }, + onStateUpdate: (listener) => { stateListeners.add(listener); return () => { stateListeners.delete(listener); }; }, + updateSettings: async (settings) => { + Object.assign(fixture.snapshot.settings, settings); + fixture.snapshot.snapshotRevision = (fixture.snapshot.snapshotRevision ?? 0) + 1; + for (const listener of stateListeners) listener(structuredClone(fixture.snapshot)); + return structuredClone(fixture.snapshot.settings); + }, + prepareCollectorText: async ({ rawText, addedAt }) => { + const id = `navigation-${++preparedCount}`; + if (delayPreparation) await new Promise((resolve) => { releasePreparation = resolve; }); + return { + packages: [{ id, name: id, nameSource: "explicit", addedAt, links: [{ id: `${id}-link`, url: rawText, fileName: `${id}.rar`, fileSizeBytes: null, hoster: "example.test", availability: "unknown", status: "unknown", addedAt }] }], + invalidCount: 0, duplicateCount: 0 + }; + }, + saveCollectorState: async (state) => { saved = structuredClone(state); return structuredClone(state); } +}; + +const report = document.createElement("output"); +document.body.append(report); +createRoot(document.getElementById("root")!).render(); +const waitFor = async (condition: () => boolean): Promise => { + const deadline = Date.now() + 10000; + while (!condition()) { + if (Date.now() > deadline) throw new Error("Navigation check timed out"); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +}; +const activeView = (): string | undefined => document.querySelector("[data-visual-active-view]")?.dataset.visualActiveView; +const navigate = async (view: string): Promise => { + document.querySelector(`[data-main-view="${view}"]`)!.click(); + await waitFor(() => activeView() === view); +}; +const check = (condition: boolean, message: string): void => { if (!condition) throw new Error(message); }; + +async function verify(): Promise { + await waitFor(() => Boolean(clipboardListener) && activeView() === "downloads"); + clipboardListener!(["https://example.test/background.rar"]); + await waitFor(() => saved.packages.length === 1); + check(activeView() === "downloads", "Default changed the downloads tab"); + await navigate("settings"); + clipboardListener!(["https://example.test/settings.rar"]); + await waitFor(() => saved.packages.length === 2); + check(activeView() === "settings", "Background import interrupted settings"); + await window.rd.updateSettings({ switchToCollectorOnClipboard: true }); + await new Promise((resolve) => setTimeout(resolve, 900)); + clipboardListener!(["https://example.test/enabled.rar"]); + await waitFor(() => saved.packages.length === 3 && activeView() === "collector"); + await navigate("downloads"); + delayPreparation = true; + clipboardListener!(["https://example.test/disabled-during-import.rar"]); + await waitFor(() => Boolean(releasePreparation)); + await window.rd.updateSettings({ switchToCollectorOnClipboard: false }); + await new Promise((resolve) => setTimeout(resolve, 900)); + releasePreparation!(); + await waitFor(() => saved.packages.length === 4); + check(activeView() === "downloads", "An in-flight import ignored disabling the setting"); + report.textContent = JSON.stringify({ passed: true, checks: ["default keeps downloads", "background preserves settings", "opt-in opens collector", "opt-out during import prevents switch"], savedPackages: saved.packages.length }); +} + +void verify().catch((error) => { report.textContent = String(error); }); diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 07f73fa..4ff94cc 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -126,6 +126,7 @@ function createSettings(): AppSettings { updateRepo: "Sucukdeluxe/Multi-Debrid-Downloader", autoUpdateCheck: true, clipboardWatch: true, + switchToCollectorOnClipboard: false, minimizeToTray: false, theme: "dark", themePreference: "dark",