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"]); + }); +});