Validate daily schedule start times
Disable schedule activation until the local time input matches a strict HH:MM value. Keep a defensive activation guard that surfaces a localized validation error and skips persistence when invoked with invalid state. Add red-green coverage for empty, malformed, out-of-range hour, and out-of-range minute values.
This commit is contained in:
+23
-4
@@ -1534,6 +1534,21 @@ export function buildDailyScheduleSettingsUpdate(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function activateDailyScheduleSettings(
|
||||||
|
time: string,
|
||||||
|
startDay: DailyScheduleStartDay,
|
||||||
|
persist: (update: RendererSettingsUpdate) => Promise<boolean>,
|
||||||
|
showError: (message: string) => void,
|
||||||
|
now = new Date()
|
||||||
|
): Promise<boolean> {
|
||||||
|
const update = buildDailyScheduleSettingsUpdate(time, startDay, now);
|
||||||
|
if (!update) {
|
||||||
|
showError("Bitte eine gültige Startzeit auswählen.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return persist(update);
|
||||||
|
}
|
||||||
|
|
||||||
export async function persistDailyScheduleSettingsUpdate(
|
export async function persistDailyScheduleSettingsUpdate(
|
||||||
update: RendererSettingsUpdate,
|
update: RendererSettingsUpdate,
|
||||||
operation: "activate" | "cancel",
|
operation: "activate" | "cancel",
|
||||||
@@ -4771,14 +4786,17 @@ export function App(): ReactElement {
|
|||||||
), [applyAuthoritativeDailyScheduleSnapshot, applyDailyScheduleSettings, showToast]);
|
), [applyAuthoritativeDailyScheduleSnapshot, applyDailyScheduleSettings, showToast]);
|
||||||
|
|
||||||
const activateDownloadSchedule = useCallback((): void => {
|
const activateDownloadSchedule = useCallback((): void => {
|
||||||
const update = buildDailyScheduleSettingsUpdate(scheduleTimeInput, scheduleStartDay);
|
void activateDailyScheduleSettings(
|
||||||
if (!update) return;
|
scheduleTimeInput,
|
||||||
void persistDownloadSchedule(update, "activate").then((persisted) => {
|
scheduleStartDay,
|
||||||
|
(update) => persistDownloadSchedule(update, "activate"),
|
||||||
|
(message) => showToast(message, 2800)
|
||||||
|
).then((persisted) => {
|
||||||
if (persisted) {
|
if (persisted) {
|
||||||
setSchedulePickerOpen(false);
|
setSchedulePickerOpen(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [persistDownloadSchedule, scheduleStartDay, scheduleTimeInput]);
|
}, [persistDownloadSchedule, scheduleStartDay, scheduleTimeInput, showToast]);
|
||||||
|
|
||||||
const removeActionableDownloads = useCallback((): void => {
|
const removeActionableDownloads = useCallback((): void => {
|
||||||
const ids = new Set(downloadsViewCore.actionableSelectedIds);
|
const ids = new Set(downloadsViewCore.actionableSelectedIds);
|
||||||
@@ -4812,6 +4830,7 @@ export function App(): ReactElement {
|
|||||||
scheduleActive: snapshot.settings.dailyStartEnabled || snapshot.settings.scheduledStartEpochMs > 0,
|
scheduleActive: snapshot.settings.dailyStartEnabled || snapshot.settings.scheduledStartEpochMs > 0,
|
||||||
scheduleOpen: schedulePickerOpen,
|
scheduleOpen: schedulePickerOpen,
|
||||||
scheduleTime: scheduleTimeInput,
|
scheduleTime: scheduleTimeInput,
|
||||||
|
scheduleTimeValid: buildDailyScheduleSettingsUpdate(scheduleTimeInput, scheduleStartDay) !== null,
|
||||||
scheduleStartDay,
|
scheduleStartDay,
|
||||||
scheduleLabel: scheduleCountdown || (snapshot.settings.dailyStartEnabled
|
scheduleLabel: scheduleCountdown || (snapshot.settings.dailyStartEnabled
|
||||||
? formatDailyScheduleTime(snapshot.settings.dailyStartMinuteOfDay)
|
? formatDailyScheduleTime(snapshot.settings.dailyStartMinuteOfDay)
|
||||||
|
|||||||
@@ -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."],
|
["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"],
|
["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"],
|
||||||
["Sichtbar:", "Visible:"], ["pro Seite", "per page"],
|
["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"], ["Starttag", "Start day"], ["Ab heute", "Starting today"], ["Ab morgen", "Starting tomorrow"],
|
["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"], ["Bitte eine gültige Startzeit auswählen.", "Select a valid start time."],
|
||||||
["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 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"],
|
["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"],
|
["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"],
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
|
|||||||
reconnectSeconds: number;
|
reconnectSeconds: number;
|
||||||
reconnectReason: string;
|
reconnectReason: string;
|
||||||
clipboardWatcher: boolean;
|
clipboardWatcher: boolean;
|
||||||
scheduleActive: boolean;
|
scheduleActive: boolean;
|
||||||
scheduleOpen: boolean;
|
scheduleOpen: boolean;
|
||||||
scheduleTime: string;
|
scheduleTime: string;
|
||||||
|
scheduleTimeValid: boolean;
|
||||||
scheduleStartDay: DailyScheduleStartDay;
|
scheduleStartDay: DailyScheduleStartDay;
|
||||||
scheduleLabel: string;
|
scheduleLabel: string;
|
||||||
packageSpeedBps: Record<string, number>;
|
packageSpeedBps: Record<string, number>;
|
||||||
@@ -141,7 +142,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
|
|||||||
<span {...(!scheduleSlotOpen ? { inert: "true" } : {})} aria-hidden={!scheduleSlotOpen} className="downloads-schedule-controls">
|
<span {...(!scheduleSlotOpen ? { inert: "true" } : {})} aria-hidden={!scheduleSlotOpen} className="downloads-schedule-controls">
|
||||||
{model.scheduleActive
|
{model.scheduleActive
|
||||||
? <><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></>
|
? <><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></>
|
||||||
: <><input aria-label="Startzeit" disabled={!scheduleSlotOpen} onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><select aria-label="Starttag" disabled={!scheduleSlotOpen} onChange={(event) => actions.onScheduleStartDayChange(event.target.value as DailyScheduleStartDay)} value={model.scheduleStartDay}><option value="today">Ab heute</option><option value="tomorrow">Ab morgen</option></select><button disabled={!scheduleSlotOpen} onClick={actions.onActivateSchedule} type="button">Planen</button></>}
|
: <><input aria-label="Startzeit" disabled={!scheduleSlotOpen} onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><select aria-label="Starttag" disabled={!scheduleSlotOpen} onChange={(event) => actions.onScheduleStartDayChange(event.target.value as DailyScheduleStartDay)} value={model.scheduleStartDay}><option value="today">Ab heute</option><option value="tomorrow">Ab morgen</option></select><button disabled={!scheduleSlotOpen || !model.scheduleTimeValid} onClick={actions.onActivateSchedule} type="button">Planen</button></>}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="downloads-toolbar-divider" />
|
<span className="downloads-toolbar-divider" />
|
||||||
|
|||||||
@@ -791,6 +791,7 @@ function withRuntime(input: DownloadsModelInput, overrides: Partial<DownloadsVie
|
|||||||
scheduleActive: false,
|
scheduleActive: false,
|
||||||
scheduleOpen: false,
|
scheduleOpen: false,
|
||||||
scheduleTime: "23:30",
|
scheduleTime: "23:30",
|
||||||
|
scheduleTimeValid: true,
|
||||||
scheduleStartDay: "today",
|
scheduleStartDay: "today",
|
||||||
scheduleLabel: "",
|
scheduleLabel: "",
|
||||||
packageSpeedBps: { "package-a": 12_000_000 },
|
packageSpeedBps: { "package-a": 12_000_000 },
|
||||||
@@ -1235,6 +1236,15 @@ describe("downloads view", () => {
|
|||||||
expect(calls).toEqual(["time:09:45", "day:today"]);
|
expect(calls).toEqual(["time:09:45", "day:today"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["", "8:15", "24:00", "12:60"])("disables schedule activation for the invalid time %j", (scheduleTime) => {
|
||||||
|
const toolbar = DownloadsToolbar({
|
||||||
|
actions: createActions(),
|
||||||
|
model: withRuntime(createInput(), { scheduleOpen: true, scheduleTime, scheduleTimeValid: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findButton(toolbar, "Planen").props.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("animates the persistent schedule slot horizontally and disables it through the global motion setting", () => {
|
it("animates the persistent schedule slot horizontally and disables it through the global motion setting", () => {
|
||||||
const closed = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput())} />);
|
const closed = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput())} />);
|
||||||
const open = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput(), { scheduleOpen: true })} />);
|
const open = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput(), { scheduleOpen: true })} />);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { RendererSettings, UiSnapshot } from "../src/shared/types";
|
import type { RendererSettings, UiSnapshot } from "../src/shared/types";
|
||||||
import {
|
import {
|
||||||
|
activateDailyScheduleSettings,
|
||||||
buildDailyScheduleSettingsUpdate,
|
buildDailyScheduleSettingsUpdate,
|
||||||
persistDailyScheduleSettingsUpdate
|
persistDailyScheduleSettingsUpdate
|
||||||
} from "../src/renderer/App";
|
} from "../src/renderer/App";
|
||||||
@@ -21,6 +22,22 @@ describe("daily schedule settings form", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["", "8:15", "24:00", "12:60"])("reports the invalid time %j when activation is invoked directly", async (time) => {
|
||||||
|
const persist = vi.fn();
|
||||||
|
const showError = vi.fn();
|
||||||
|
|
||||||
|
await expect(activateDailyScheduleSettings(
|
||||||
|
time,
|
||||||
|
"today",
|
||||||
|
persist,
|
||||||
|
showError,
|
||||||
|
new Date(2026, 7, 22, 18, 30, 0, 0)
|
||||||
|
)).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(showError).toHaveBeenCalledWith("Bitte eine gültige Startzeit auswählen.");
|
||||||
|
expect(persist).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("applies persisted settings after a successful activation", async () => {
|
it("applies persisted settings after a successful activation", async () => {
|
||||||
const persisted = { dailyStartEnabled: true } as RendererSettings;
|
const persisted = { dailyStartEnabled: true } as RendererSettings;
|
||||||
const updateSettings = vi.fn().mockResolvedValue(persisted);
|
const updateSettings = vi.fn().mockResolvedValue(persisted);
|
||||||
|
|||||||
Reference in New Issue
Block a user