From 30bedd17ddc7649a276b80291dee8b1244b897dd Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 10:02:29 +0200 Subject: [PATCH] Add recurring daily schedule controls Replace the one-time renderer schedule form with recurring local time and start-day settings while preserving legacy schedule visibility. Keep the toolbar slot mounted and inaccessible while closed, animate width, opacity, and horizontal position through the global motion preference, and add localized labels. Surface activation and cancellation failures, reconcile the authoritative snapshot after failed writes, and cover the behavior with focused red-green tests. --- src/renderer/App.tsx | 149 +++++++++++++++--- src/renderer/i18n.ts | 4 +- .../views/downloads/DownloadsView.tsx | 39 +++-- src/renderer/views/downloads/downloads.css | 34 +++- tests/downloads-view.test.tsx | 69 +++++++- tests/renderer-settings-form.test.tsx | 82 ++++++++++ 6 files changed, 336 insertions(+), 41 deletions(-) create mode 100644 tests/renderer-settings-form.test.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 69b9b9c..430db28 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -111,6 +111,7 @@ import { DownloadsSidebar, DownloadsSidebarStatus, DownloadsToolbar, + type DailyScheduleStartDay, type DownloadsViewActions, type DownloadsViewModel } from "./views/downloads/DownloadsView"; @@ -1493,6 +1494,67 @@ export async function runLatestUpdateCheck( await apply(result, generation); } +interface DailySchedulePersistenceDependencies { + updateSettings: (update: RendererSettingsUpdate) => Promise; + getSnapshot: () => Promise; + applySettings: (settings: RendererSettings) => void; + applySnapshot: (snapshot: UiSnapshot) => void; + showError: (message: string) => void; +} + +function formatDailyScheduleLocalDate(date: Date): string { + const year = String(date.getFullYear()).padStart(4, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function formatDailyScheduleTime(minuteOfDay: number): string { + const minute = Math.max(0, Math.min(1_439, Math.floor(minuteOfDay))); + return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`; +} + +export function buildDailyScheduleSettingsUpdate( + time: string, + startDay: DailyScheduleStartDay, + now = new Date() +): RendererSettingsUpdate | null { + const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(time); + if (!match) { + return null; + } + const firstDate = new Date(now); + if (startDay === "tomorrow") { + firstDate.setDate(firstDate.getDate() + 1); + } + return { + dailyStartEnabled: true, + dailyStartMinuteOfDay: Number(match[1]) * 60 + Number(match[2]), + dailyStartFirstLocalDate: formatDailyScheduleLocalDate(firstDate) + }; +} + +export async function persistDailyScheduleSettingsUpdate( + update: RendererSettingsUpdate, + operation: "activate" | "cancel", + dependencies: DailySchedulePersistenceDependencies +): Promise { + try { + const settings = await dependencies.updateSettings(update); + dependencies.applySettings(settings); + return true; + } catch (error) { + const action = operation === "activate" ? "aktiviert" : "abgebrochen"; + dependencies.showError(`Zeitplan konnte nicht ${action} werden: ${String(error)}`); + try { + dependencies.applySnapshot(await dependencies.getSnapshot()); + } catch (snapshotError) { + dependencies.showError(`Zeitplan konnte nicht abgeglichen werden: ${String(snapshotError)}`); + } + return false; + } +} + export function App(): ReactElement { const [snapshot, setSnapshot] = useState(emptySnapshot); const [appVersion, setAppVersion] = useState(""); @@ -1507,9 +1569,10 @@ export function App(): ReactElement { const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState>({}); const [settingsDirty, setSettingsDirty] = useState(false); const [settingsSaveState, setSettingsSaveState] = useState("clean"); - const [schedulePickerOpen, setSchedulePickerOpen] = useState(false); - const [scheduleTimeInput, setScheduleTimeInput] = useState(""); - const [scheduleCountdown, setScheduleCountdown] = useState(""); + const [schedulePickerOpen, setSchedulePickerOpen] = useState(false); + const [scheduleTimeInput, setScheduleTimeInput] = useState(""); + const [scheduleStartDay, setScheduleStartDay] = useState("today"); + const [scheduleCountdown, setScheduleCountdown] = useState(""); const [runtimeNow, setRuntimeNow] = useState(() => Date.now()); const updateCheckGenerationRef = useRef(0); const dismissedUpdateTagRef = useRef(""); @@ -1729,9 +1792,11 @@ export function App(): ReactElement { setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); }, [settingsDraft.speedLimitKbps]); - useEffect(() => { - const schedMs = snapshot.settings.scheduledStartEpochMs || 0; - if (schedMs <= 0) { setScheduleCountdown(""); return; } + useEffect(() => { + const schedMs = snapshot.settings.dailyStartEnabled + ? snapshot.settings.nextDailyStartEpochMs + : snapshot.settings.scheduledStartEpochMs || 0; + if (schedMs <= 0) { setScheduleCountdown(""); return; } const update = (): void => { const remaining = schedMs - Date.now(); if (remaining <= 0) { setScheduleCountdown(""); return; } @@ -1744,7 +1809,7 @@ export function App(): ReactElement { update(); const timer = setInterval(update, 1000); return () => clearInterval(timer); - }, [snapshot.settings.scheduledStartEpochMs]); + }, [snapshot.settings.dailyStartEnabled, snapshot.settings.nextDailyStartEpochMs, snapshot.settings.scheduledStartEpochMs]); useEffect(() => { const timer = setInterval(() => setRuntimeNow(Date.now()), 1000); @@ -4677,16 +4742,43 @@ export function App(): ReactElement { }); }, [setClipboardWatcherActive, showToast]); + const applyDailyScheduleSettings = useCallback((settings: RendererSettings): void => { + const apply = (current: UiSnapshot): UiSnapshot => ({ ...current, settings }); + const next = apply(snapshotRef.current); + snapshotRef.current = next; + masterSnapshotRef.current = masterSnapshotRef.current ? apply(masterSnapshotRef.current) : next; + if (latestStateRef.current) { + latestStateRef.current = apply(latestStateRef.current); + } + setSnapshot(next); + }, []); + + const applyAuthoritativeDailyScheduleSnapshot = useCallback((state: UiSnapshot): void => { + masterSnapshotRef.current = state; + latestStateRef.current = null; + snapshotRef.current = state; + setSnapshot(state); + }, []); + + const persistDownloadSchedule = useCallback((update: RendererSettingsUpdate, operation: "activate" | "cancel"): Promise => ( + persistDailyScheduleSettingsUpdate(update, operation, { + updateSettings: (value) => window.rd.updateSettings(value), + getSnapshot: () => window.rd.getSnapshot(), + applySettings: applyDailyScheduleSettings, + applySnapshot: applyAuthoritativeDailyScheduleSnapshot, + showError: (message) => showToast(message, 3200) + }) + ), [applyAuthoritativeDailyScheduleSnapshot, applyDailyScheduleSettings, showToast]); + const activateDownloadSchedule = useCallback((): void => { - if (!scheduleTimeInput) return; - const [hours, minutes] = scheduleTimeInput.split(":").map(Number); - const now = new Date(); - const target = new Date(now); - target.setHours(hours, minutes, 0, 0); - if (target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1); - void window.rd.updateSettings({ scheduledStartEpochMs: target.getTime() }).catch(() => {}); - setSchedulePickerOpen(false); - }, [scheduleTimeInput]); + const update = buildDailyScheduleSettingsUpdate(scheduleTimeInput, scheduleStartDay); + if (!update) return; + void persistDownloadSchedule(update, "activate").then((persisted) => { + if (persisted) { + setSchedulePickerOpen(false); + } + }); + }, [persistDownloadSchedule, scheduleStartDay, scheduleTimeInput]); const removeActionableDownloads = useCallback((): void => { const ids = new Set(downloadsViewCore.actionableSelectedIds); @@ -4717,10 +4809,15 @@ export function App(): ReactElement { reconnectSeconds: snapshot.reconnectSeconds, reconnectReason: snapshot.session.reconnectReason, clipboardWatcher: snapshot.clipboardActive, - scheduleActive: snapshot.settings.scheduledStartEpochMs > 0, + scheduleActive: snapshot.settings.dailyStartEnabled || snapshot.settings.scheduledStartEpochMs > 0, scheduleOpen: schedulePickerOpen, scheduleTime: scheduleTimeInput, - scheduleLabel: scheduleCountdown || (snapshot.settings.scheduledStartEpochMs > 0 ? new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""), + scheduleStartDay, + scheduleLabel: scheduleCountdown || (snapshot.settings.dailyStartEnabled + ? formatDailyScheduleTime(snapshot.settings.dailyStartMinuteOfDay) + : snapshot.settings.scheduledStartEpochMs > 0 + ? new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + : ""), packageSpeedBps: downloadPackageSpeeds, editingPackageId, editingName, @@ -4744,7 +4841,7 @@ export function App(): ReactElement { speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s", eta: snapshot.etaText } - }), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]); + }), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]); const resetColumnLayout = useCallback((): void => { if (columnDragSettleTimerRef.current !== null) { @@ -4785,12 +4882,20 @@ export function App(): ReactElement { }, onStopDownloads: () => { void performQuickAction(() => window.rd.stop()); }, onToggleSchedule: () => { - setSchedulePickerOpen((current) => !current); - setScheduleTimeInput(""); + if (!schedulePickerOpen) { + const now = new Date(); + const minuteOfDay = snapshot.settings.dailyStartEnabled + ? snapshot.settings.dailyStartMinuteOfDay + : now.getHours() * 60 + now.getMinutes(); + setScheduleTimeInput(formatDailyScheduleTime(minuteOfDay)); + setScheduleStartDay("today"); + } + setSchedulePickerOpen(!schedulePickerOpen); }, onScheduleTimeChange: setScheduleTimeInput, + onScheduleStartDayChange: setScheduleStartDay, onActivateSchedule: activateDownloadSchedule, - onCancelSchedule: () => { void window.rd.updateSettings({ scheduledStartEpochMs: 0 }).catch(() => {}); }, + onCancelSchedule: () => { void persistDownloadSchedule({ dailyStartEnabled: false }, "cancel"); }, onMoveSelectionUp: () => moveSelectedPackages("up", downloadsViewCore.actionableSelectedIds), onMoveSelectionDown: () => moveSelectedPackages("down", downloadsViewCore.actionableSelectedIds), onRenameSelection: () => { diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 308f667..973569a 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -63,7 +63,7 @@ const pairs = [ ["Abgeschlossene und gelöschte Pakete erscheinen hier.", "Completed and deleted packages appear here."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Öffne die Ansicht erneut, um es noch einmal zu versuchen.", "Open the view again to retry."], ["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"], ["Sichtbar:", "Visible:"], ["pro Seite", "per page"], - ["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"], + ["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"], ["Starttag", "Start day"], ["Ab heute", "Starting today"], ["Ab morgen", "Starting tomorrow"], ["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"], ["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"], @@ -215,7 +215,7 @@ const prefixedPairs = [ ["Sicherung laden fehlgeschlagen: ", "Loading backup failed: "], ["Support-Bundle fehlgeschlagen: ", "Support bundle failed: "], ["Support-Trace fehlgeschlagen: ", "Support trace failed: "], ["Debug-Setup-Check fehlgeschlagen: ", "Debug setup check failed: "], ["Fehler-Ansicht fehlgeschlagen: ", "Error view failed: "], ["Token-Rotation fehlgeschlagen: ", "Token rotation failed: "], ["Ferndiagnose-Status fehlgeschlagen: ", "Remote diagnostics status failed: "], ["Aktivieren fehlgeschlagen: ", "Enabling failed: "], ["Deaktivieren fehlgeschlagen: ", "Disabling failed: "], - ["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "] + ["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "], ["Zeitplan konnte nicht aktiviert werden: ", "Schedule could not be activated: "], ["Zeitplan konnte nicht abgebrochen werden: ", "Schedule could not be cancelled: "], ["Zeitplan konnte nicht abgeglichen werden: ", "Schedule could not be reconciled: "] ] as const; export function normalizeLanguage(value: unknown): AppLanguage { diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index c573afa..870c86a 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -11,6 +11,8 @@ import { VirtualizedDownloadsBody } from "./VirtualizedDownloadsBody"; import "./downloads.css"; const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 }); + +export type DailyScheduleStartDay = "today" | "tomorrow"; export interface DownloadsStatusModel { packages: number; @@ -38,9 +40,10 @@ export interface DownloadsViewModel extends DownloadsViewModelCore { reconnectReason: string; clipboardWatcher: boolean; scheduleActive: boolean; - scheduleOpen: boolean; - scheduleTime: string; - scheduleLabel: string; + scheduleOpen: boolean; + scheduleTime: string; + scheduleStartDay: DailyScheduleStartDay; + scheduleLabel: string; packageSpeedBps: Record; editingPackageId: string | null; editingName: string; @@ -63,9 +66,10 @@ export interface DownloadsViewActions extends DownloadsTableActions { onStartDownloads: () => void; onPauseDownloads: () => void; onStopDownloads: () => void; - onToggleSchedule: () => void; - onScheduleTimeChange: (value: string) => void; - onActivateSchedule: () => void; + onToggleSchedule: () => void; + onScheduleTimeChange: (value: string) => void; + onScheduleStartDayChange: (value: DailyScheduleStartDay) => void; + onActivateSchedule: () => void; onCancelSchedule: () => void; onMoveSelectionUp: () => void; onMoveSelectionDown: () => void; @@ -121,18 +125,25 @@ export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel }) })}
Geschwindigkeit{speed}
ETA{eta}
; } -export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement { - const hasSelection = model.actionableSelectedIds.length > 0; - const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0; - const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1; - return ( +export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement { + const hasSelection = model.actionableSelectedIds.length > 0; + const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0; + const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1; + const scheduleSlotOpen = model.scheduleActive || model.scheduleOpen; + const scheduleSlotClass = `downloads-schedule-slot ${scheduleSlotOpen ? "is-open" : "is-closed"}${model.animationsEnabled ? "" : " is-motion-disabled"}`; + return (
- {model.scheduleActive - ? Geplant: {model.scheduleLabel} - : <>{model.scheduleOpen ? actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /> : null}} + {!model.scheduleActive ? : null} + + + {model.scheduleActive + ? <>Geplant: {model.scheduleLabel} + : <> actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} />} + + diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index e5eee17..5fbffbd 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -237,13 +237,45 @@ background: var(--ui-border); } +.downloads-schedule-slot { + display: grid; + grid-template-columns: 0fr; + min-width: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + transition: grid-template-columns 180ms ease, opacity 140ms ease; +} + +.downloads-schedule-slot.is-open { + grid-template-columns: 1fr; + opacity: 1; + pointer-events: auto; +} + .downloads-schedule-controls { display: flex; align-items: center; gap: 5px; + min-width: 0; + width: max-content; + overflow: hidden; + white-space: nowrap; + transform: translateX(-10px); + transition: transform 180ms ease; } -.downloads-schedule-controls input { +.downloads-schedule-slot.is-open .downloads-schedule-controls { + transform: translateX(0); +} + +.downloads-schedule-slot.is-motion-disabled, +.downloads-schedule-slot.is-motion-disabled .downloads-schedule-controls { + transition: none !important; +} + +.downloads-schedule-controls input, +.downloads-schedule-controls select { height: 36px; } diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 2243bad..08324e3 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -42,7 +42,8 @@ import { DownloadsSidebarStatus, DownloadsToolbar, DownloadsView, - type DownloadsViewActions + type DownloadsViewActions, + type DownloadsViewModel } from "../src/renderer/views/downloads/DownloadsView"; import { DownloadsTableHeader, @@ -711,6 +712,7 @@ function createActions(overrides: Partial = {}): Downloads onStopDownloads: () => {}, onToggleSchedule: () => {}, onScheduleTimeChange: () => {}, + onScheduleStartDayChange: () => {}, onActivateSchedule: () => {}, onCancelSchedule: () => {}, onMoveSelectionUp: () => {}, @@ -774,7 +776,7 @@ function findButton(node: ReactNode, label: string): ReactElement { return findElement(node, (element) => element.type === "button" && element.props.children === label); } -function withRuntime(input: DownloadsModelInput, overrides: Record = {}) { +function withRuntime(input: DownloadsModelInput, overrides: Partial = {}): DownloadsViewModel { return { ...buildDownloadsViewModel(input), running: true, @@ -789,6 +791,7 @@ function withRuntime(input: DownloadsModelInput, overrides: Record { expect(findButton(toolbar, "Abbrechen").props.disabled).toBe(false); }); + it("keeps closed schedule controls mounted, hidden and unreachable", () => { + const toolbar = DownloadsToolbar({ + actions: createActions(), + model: withRuntime(createInput(), { scheduleActive: false, scheduleOpen: false }) + }); + const slot = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-slot")); + const controls = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-controls")); + const timeInput = findElement(toolbar, (element) => element.type === "input" && element.props["aria-label"] === "Startzeit"); + const daySelect = findElement(toolbar, (element) => element.type === "select" && element.props["aria-label"] === "Starttag"); + + expect(findButton(toolbar, "Zeitplan").props["aria-expanded"]).toBe(false); + expect(slot.props.className).toContain("is-closed"); + expect(controls.props["aria-hidden"]).toBe(true); + expect(controls.props.inert).toBe("true"); + expect(renderToStaticMarkup(toolbar)).toContain('inert="true"'); + expect(timeInput.props.disabled).toBe(true); + expect(daySelect.props.disabled).toBe(true); + expect(findButton(toolbar, "Planen").props.disabled).toBe(true); + }); + + it("collects local time and the selected first start day from the open schedule slot", () => { + const calls: string[] = []; + const toolbar = DownloadsToolbar({ + actions: createActions({ + onScheduleTimeChange: (value) => calls.push(`time:${value}`), + onScheduleStartDayChange: (value) => calls.push(`day:${value}`) + }), + model: withRuntime(createInput(), { scheduleOpen: true, scheduleStartDay: "tomorrow", scheduleTime: "08:15" }) + }); + const controls = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-controls")); + const timeInput = findElement(toolbar, (element) => element.type === "input" && element.props["aria-label"] === "Startzeit"); + const daySelect = findElement(toolbar, (element) => element.type === "select" && element.props["aria-label"] === "Starttag"); + + timeInput.props.onChange({ target: { value: "09:45" } }); + daySelect.props.onChange({ target: { value: "today" } }); + + expect(findButton(toolbar, "Zeitplan").props["aria-expanded"]).toBe(true); + expect(controls.props["aria-hidden"]).toBe(false); + expect(controls.props.inert).toBeUndefined(); + expect(timeInput.props.value).toBe("08:15"); + expect(daySelect.props.value).toBe("tomorrow"); + expect(renderToStaticMarkup(toolbar)).toContain("Ab heute"); + expect(renderToStaticMarkup(toolbar)).toContain("Ab morgen"); + expect(calls).toEqual(["time:09:45", "day:today"]); + }); + + it("animates the persistent schedule slot horizontally and disables it through the global motion setting", () => { + const closed = renderToStaticMarkup(); + const open = renderToStaticMarkup(); + const motionDisabled = renderToStaticMarkup(); + const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8"); + + expect(closed).toContain("downloads-schedule-slot is-closed"); + expect(open).toContain("downloads-schedule-slot is-open"); + expect(motionDisabled).toContain("downloads-schedule-slot is-open is-motion-disabled"); + expect(css).toMatch(/\.downloads-schedule-slot\s*\{[^}]*grid-template-columns:\s*0fr;[^}]*opacity:\s*0;[^}]*transition:/s); + expect(css).toMatch(/\.downloads-schedule-slot\.is-open\s*\{[^}]*grid-template-columns:\s*1fr;[^}]*opacity:\s*1;/s); + expect(css).toMatch(/\.downloads-schedule-controls\s*\{[^}]*transform:\s*translateX\(-\d+px\);[^}]*transition:\s*transform/s); + expect(css).toMatch(/\.downloads-schedule-slot\.is-open \.downloads-schedule-controls\s*\{[^}]*transform:\s*translateX\(0\);/s); + expect(css).toMatch(/\.downloads-schedule-slot\.is-motion-disabled[^\{]*\{[^}]*transition:\s*none !important;/s); + }); + it("keeps the table header and all rows in one horizontal scroll context with exact dense geometry", () => { const html = renderToStaticMarkup(); const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8"); diff --git a/tests/renderer-settings-form.test.tsx b/tests/renderer-settings-form.test.tsx new file mode 100644 index 0000000..2830e6b --- /dev/null +++ b/tests/renderer-settings-form.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RendererSettings, UiSnapshot } from "../src/shared/types"; +import { + buildDailyScheduleSettingsUpdate, + persistDailyScheduleSettingsUpdate +} from "../src/renderer/App"; + +describe("daily schedule settings form", () => { + it("maps local time and the chosen start day to the recurring schedule settings", () => { + const now = new Date(2026, 7, 22, 18, 30, 0, 0); + + expect(buildDailyScheduleSettingsUpdate("08:15", "today", now)).toEqual({ + dailyStartEnabled: true, + dailyStartMinuteOfDay: 8 * 60 + 15, + dailyStartFirstLocalDate: "2026-08-22" + }); + expect(buildDailyScheduleSettingsUpdate("23:45", "tomorrow", now)).toEqual({ + dailyStartEnabled: true, + dailyStartMinuteOfDay: 23 * 60 + 45, + dailyStartFirstLocalDate: "2026-08-23" + }); + }); + + it("applies persisted settings after a successful activation", async () => { + const persisted = { dailyStartEnabled: true } as RendererSettings; + const updateSettings = vi.fn().mockResolvedValue(persisted); + const applySettings = vi.fn(); + const getSnapshot = vi.fn(); + const applySnapshot = vi.fn(); + const showError = vi.fn(); + const update = { + dailyStartEnabled: true, + dailyStartMinuteOfDay: 495, + dailyStartFirstLocalDate: "2026-08-23" + }; + + await expect(persistDailyScheduleSettingsUpdate(update, "activate", { + updateSettings, + getSnapshot, + applySettings, + applySnapshot, + showError + })).resolves.toBe(true); + + expect(updateSettings).toHaveBeenCalledWith(update); + expect(applySettings).toHaveBeenCalledWith(persisted); + expect(getSnapshot).not.toHaveBeenCalled(); + expect(showError).not.toHaveBeenCalled(); + }); + + it.each([ + ["activate" as const, "Zeitplan konnte nicht aktiviert werden: Error: Speichern fehlgeschlagen"], + ["cancel" as const, "Zeitplan konnte nicht abgebrochen werden: Error: Speichern fehlgeschlagen"] + ])("shows %s failures before reconciling the authoritative snapshot", async (operation, expectedMessage) => { + const authoritative = { settings: { dailyStartEnabled: false } } as UiSnapshot; + const sequence: string[] = []; + const updateSettings = vi.fn().mockRejectedValue(new Error("Speichern fehlgeschlagen")); + const getSnapshot = vi.fn(async () => { + sequence.push("snapshot"); + return authoritative; + }); + const applySnapshot = vi.fn((snapshot: UiSnapshot) => { + sequence.push(`apply:${String(snapshot.settings.dailyStartEnabled)}`); + }); + const showError = vi.fn((message: string) => { + sequence.push(`error:${message}`); + }); + + await expect(persistDailyScheduleSettingsUpdate({ dailyStartEnabled: false }, operation, { + updateSettings, + getSnapshot, + applySettings: vi.fn(), + applySnapshot, + showError + })).resolves.toBe(false); + + expect(showError).toHaveBeenCalledWith(expectedMessage); + expect(getSnapshot).toHaveBeenCalledTimes(1); + expect(applySnapshot).toHaveBeenCalledWith(authoritative); + expect(sequence).toEqual([`error:${expectedMessage}`, "snapshot", "apply:false"]); + }); +});