feat(settings): discard unsaved changes
This commit is contained in:
+123
-18
@@ -339,6 +339,33 @@ export function createSettingsDraft(settings: RendererSettings, current?: Render
|
||||
};
|
||||
}
|
||||
|
||||
export function createDiscardedSettingsState(settings: RendererSettings, themeChoice: SettingsThemeChoice = settings.theme): {
|
||||
draft: RendererSettingsDraft;
|
||||
themeChoice: SettingsThemeChoice;
|
||||
speedLimitInput: string;
|
||||
scheduleSpeedInputs: Record<string, string>;
|
||||
} {
|
||||
return {
|
||||
draft: createSettingsDraft(settings),
|
||||
themeChoice,
|
||||
speedLimitInput: formatMbpsInputFromKbps(settings.speedLimitKbps),
|
||||
scheduleSpeedInputs: Object.fromEntries(
|
||||
(settings.bandwidthSchedules || []).map((schedule) => [schedule.id, formatMbpsInputFromKbps(schedule.speedLimitKbps)])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSettingsSaveCompletion(revisionAtStart: number, currentRevision: number): {
|
||||
saveState: Extract<SettingsSaveState, "saved" | "dirty">;
|
||||
applyPersistedTheme: boolean;
|
||||
toast: string;
|
||||
} {
|
||||
const unchanged = revisionAtStart === currentRevision;
|
||||
return unchanged
|
||||
? { saveState: "saved", applyPersistedTheme: true, toast: "Einstellungen gespeichert" }
|
||||
: { saveState: "dirty", applyPersistedTheme: false, toast: "Zwischenstand gespeichert – weitere Änderungen sind ungespeichert" };
|
||||
}
|
||||
|
||||
function settingsValueEqual(left: unknown, right: unknown): boolean {
|
||||
return Object.is(left, right) || JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
@@ -1670,6 +1697,7 @@ export function App(): ReactElement {
|
||||
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
|
||||
const [settingsDirty, setSettingsDirty] = useState(false);
|
||||
const [settingsSaveState, setSettingsSaveState] = useState<SettingsSaveState>("clean");
|
||||
const [settingsSaveInFlight, setSettingsSaveInFlight] = useState(false);
|
||||
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
||||
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
|
||||
const [scheduleStartDay, setScheduleStartDay] = useState<DailyScheduleStartDay>("today");
|
||||
@@ -1678,6 +1706,7 @@ export function App(): ReactElement {
|
||||
const updateCheckGenerationRef = useRef(0);
|
||||
const dismissedUpdateTagRef = useRef("");
|
||||
const settingsDirtyRef = useRef(false);
|
||||
const settingsSaveInFlightRef = useRef(false);
|
||||
const writeOnlySettingsDirtyRef = useRef(new Set<"archivePasswordList" | "notifyUrl">());
|
||||
const archivePasswordLoadGenerationRef = useRef(0);
|
||||
const settingsDraftRevisionRef = useRef(0);
|
||||
@@ -1690,7 +1719,11 @@ export function App(): ReactElement {
|
||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
||||
const snapshotRef = useRef(snapshot);
|
||||
const persistedSettingsRef = useRef<RendererSettings>(emptySnapshot().settings);
|
||||
const settingsThemeChoiceRef = useRef<SettingsThemeChoice>(settingsThemeChoice);
|
||||
const persistedThemeChoiceRef = useRef<SettingsThemeChoice>(emptySnapshot().settings.theme);
|
||||
snapshotRef.current = snapshot;
|
||||
settingsThemeChoiceRef.current = settingsThemeChoice;
|
||||
const tabRef = useRef(tab);
|
||||
tabRef.current = tab;
|
||||
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -2129,6 +2162,8 @@ export function App(): ReactElement {
|
||||
return;
|
||||
}
|
||||
masterSnapshotRef.current = state;
|
||||
persistedSettingsRef.current = state.settings;
|
||||
persistedThemeChoiceRef.current = state.settings.theme;
|
||||
setSnapshot(state);
|
||||
if (state.settings.columnOrder?.length > 0) {
|
||||
columnOrderPersistenceRef.current?.applyAuthoritative(state.settings.columnOrder);
|
||||
@@ -2140,6 +2175,7 @@ export function App(): ReactElement {
|
||||
setSettingsDirty(false);
|
||||
setSettingsSaveState("clean");
|
||||
setSettingsThemeChoice(state.settings.theme);
|
||||
settingsThemeChoiceRef.current = state.settings.theme;
|
||||
applyTheme(state.settings.theme);
|
||||
if (state.settings.autoUpdateCheck) {
|
||||
void runLatestUpdateCheck(
|
||||
@@ -2175,6 +2211,7 @@ export function App(): ReactElement {
|
||||
merged = wireState;
|
||||
}
|
||||
masterSnapshotRef.current = merged;
|
||||
persistedSettingsRef.current = merged.settings;
|
||||
latestStateRef.current = merged;
|
||||
if (stateFlushTimerRef.current) { return; }
|
||||
|
||||
@@ -2809,16 +2846,57 @@ export function App(): ReactElement {
|
||||
return;
|
||||
}
|
||||
const revisionAtStart = settingsDraftRevisionRef.current;
|
||||
const themeChoiceAtStart = settingsThemeChoiceRef.current;
|
||||
settingsSaveInFlightRef.current = true;
|
||||
setSettingsSaveInFlight(true);
|
||||
setSettingsSaveState("saving");
|
||||
await performQuickAction(async () => {
|
||||
const result = await persistDraftSettings();
|
||||
applyTheme(result.theme);
|
||||
setSettingsSaveState(settingsDraftRevisionRef.current === revisionAtStart ? "saved" : "dirty");
|
||||
showToast("Einstellungen gespeichert", 1800);
|
||||
}, (error) => {
|
||||
setSettingsSaveState("error");
|
||||
showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800);
|
||||
});
|
||||
try {
|
||||
await performQuickAction(async () => {
|
||||
const result = await persistDraftSettings(themeChoiceAtStart);
|
||||
const completion = resolveSettingsSaveCompletion(revisionAtStart, settingsDraftRevisionRef.current);
|
||||
if (completion.applyPersistedTheme) {
|
||||
applyTheme(result.theme);
|
||||
}
|
||||
setSettingsSaveState(completion.saveState);
|
||||
showToast(completion.toast, 1800);
|
||||
}, (error) => {
|
||||
setSettingsSaveState("error");
|
||||
showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800);
|
||||
});
|
||||
} finally {
|
||||
settingsSaveInFlightRef.current = false;
|
||||
setSettingsSaveInFlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
const discardSettingsChanges = (): void => {
|
||||
if (settingsSaveInFlightRef.current || actionBusyRef.current || settingsSaveState === "saving" || !settingsDirtyRef.current) {
|
||||
return;
|
||||
}
|
||||
const restored = createDiscardedSettingsState(persistedSettingsRef.current, persistedThemeChoiceRef.current);
|
||||
settingsDraftRevisionRef.current += 1;
|
||||
archivePasswordLoadGenerationRef.current += 1;
|
||||
panelDirtyRevisionRef.current = 0;
|
||||
writeOnlySettingsDirtyRef.current.clear();
|
||||
settingsDirtyRef.current = false;
|
||||
setSettingsDirty(false);
|
||||
setSettingsSaveState("clean");
|
||||
setSettingsDraft(restored.draft);
|
||||
setSettingsThemeChoice(restored.themeChoice);
|
||||
settingsThemeChoiceRef.current = restored.themeChoice;
|
||||
setSpeedLimitInput(restored.speedLimitInput);
|
||||
setScheduleSpeedInputs(restored.scheduleSpeedInputs);
|
||||
applyTheme(restored.draft.theme);
|
||||
showToast("Ungespeicherte Änderungen verworfen", 1800);
|
||||
if (settingsSubTab === "extract") {
|
||||
const generation = archivePasswordLoadGenerationRef.current;
|
||||
void window.rd.getArchivePasswordList().then(({ passwords }) => {
|
||||
if (archivePasswordLoadGenerationRef.current !== generation || writeOnlySettingsDirtyRef.current.has("archivePasswordList")) {
|
||||
return;
|
||||
}
|
||||
setSettingsDraft((current) => ({ ...current, archivePasswordList: passwords }));
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenRealDebridLogin = async (): Promise<void> => {
|
||||
@@ -2855,7 +2933,13 @@ export function App(): ReactElement {
|
||||
});
|
||||
};
|
||||
|
||||
const applyPersistedSettings = (result: RendererSettings, preserveWriteOnlyValues = true): void => {
|
||||
const applyPersistedSettings = (
|
||||
result: RendererSettings,
|
||||
preserveWriteOnlyValues = true,
|
||||
themeChoice: SettingsThemeChoice = settingsThemeChoiceRef.current === "system" ? "system" : result.theme
|
||||
): void => {
|
||||
persistedSettingsRef.current = result;
|
||||
persistedThemeChoiceRef.current = themeChoice;
|
||||
if (!preserveWriteOnlyValues) {
|
||||
archivePasswordLoadGenerationRef.current += 1;
|
||||
}
|
||||
@@ -2868,11 +2952,13 @@ export function App(): ReactElement {
|
||||
panelDirtyRevisionRef.current = 0;
|
||||
setSettingsDirty(false);
|
||||
setSettingsSaveState("clean");
|
||||
setSettingsThemeChoice((current) => current === "system" ? current : result.theme);
|
||||
setSettingsThemeChoice(themeChoice);
|
||||
settingsThemeChoiceRef.current = themeChoice;
|
||||
applyTheme(result.theme);
|
||||
};
|
||||
|
||||
const syncLiveProviderUsageSettings = (result: RendererSettings): void => {
|
||||
persistedSettingsRef.current = result;
|
||||
setSnapshot((prev) => ({ ...prev, settings: result }));
|
||||
if (!settingsDirtyRef.current) {
|
||||
applyPersistedSettings(result);
|
||||
@@ -2891,9 +2977,15 @@ export function App(): ReactElement {
|
||||
}));
|
||||
};
|
||||
|
||||
const persistSpecificSettings = async (nextDraft: RendererSettingsDraft): Promise<RendererSettings> => {
|
||||
const revisionAtStart = settingsDraftRevisionRef.current;
|
||||
const draftAtStart = settingsDraft;
|
||||
const persistSpecificSettings = async (
|
||||
nextDraft: RendererSettingsDraft,
|
||||
persistenceContext = {
|
||||
revisionAtStart: settingsDraftRevisionRef.current,
|
||||
draftAtStart: settingsDraft,
|
||||
themeChoiceAtStart: settingsThemeChoiceRef.current
|
||||
}
|
||||
): Promise<RendererSettings> => {
|
||||
const { revisionAtStart, draftAtStart, themeChoiceAtStart } = persistenceContext;
|
||||
const normalizedDraft = {
|
||||
...nextDraft,
|
||||
...normalizeProviderSelectionForSettings(nextDraft)
|
||||
@@ -2902,8 +2994,10 @@ export function App(): ReactElement {
|
||||
if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList;
|
||||
if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl;
|
||||
const result = await window.rd.updateSettings(update);
|
||||
persistedSettingsRef.current = result;
|
||||
persistedThemeChoiceRef.current = themeChoiceAtStart;
|
||||
if (settingsDraftRevisionRef.current === revisionAtStart) {
|
||||
applyPersistedSettings(result);
|
||||
applyPersistedSettings(result, true, themeChoiceAtStart);
|
||||
} else {
|
||||
setSettingsDraft((current) => mergeConcurrentSpecificSettings(draftAtStart, normalizedDraft, result, current));
|
||||
settingsDirtyRef.current = true;
|
||||
@@ -3103,7 +3197,13 @@ export function App(): ReactElement {
|
||||
const previousDraft = settingsDraft;
|
||||
const previousDirty = settingsDirtyRef.current;
|
||||
const previousSaveState = settingsSaveState;
|
||||
const themeChoiceAtStart = settingsThemeChoiceRef.current;
|
||||
const revision = ++settingsDraftRevisionRef.current;
|
||||
const persistenceContext = {
|
||||
revisionAtStart: revision,
|
||||
draftAtStart: previousDraft,
|
||||
themeChoiceAtStart
|
||||
};
|
||||
return runOptimisticAccountUpdate(
|
||||
() => {
|
||||
settingsDirtyRef.current = true;
|
||||
@@ -3113,7 +3213,7 @@ export function App(): ReactElement {
|
||||
},
|
||||
() => runAccountEnableRefresh(
|
||||
refreshBeforePersist,
|
||||
() => persistSpecificSettings(nextDraft)
|
||||
() => persistSpecificSettings(nextDraft, persistenceContext)
|
||||
),
|
||||
() => {
|
||||
if (settingsDraftRevisionRef.current !== revision) return;
|
||||
@@ -3351,14 +3451,16 @@ export function App(): ReactElement {
|
||||
});
|
||||
};
|
||||
|
||||
const persistDraftSettings = async (): Promise<RendererSettings> => {
|
||||
const persistDraftSettings = async (themeChoiceAtStart: SettingsThemeChoice = settingsThemeChoiceRef.current): Promise<RendererSettings> => {
|
||||
const revisionAtStart = settingsDraftRevisionRef.current;
|
||||
const update: RendererSettingsUpdate = { ...normalizedSettingsDraft };
|
||||
if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList;
|
||||
if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl;
|
||||
const result = await window.rd.updateSettings(update);
|
||||
persistedSettingsRef.current = result;
|
||||
persistedThemeChoiceRef.current = themeChoiceAtStart;
|
||||
if (settingsDraftRevisionRef.current === revisionAtStart) {
|
||||
applyPersistedSettings(result);
|
||||
applyPersistedSettings(result, true, themeChoiceAtStart);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -4959,6 +5061,7 @@ export function App(): ReactElement {
|
||||
|
||||
const applyAuthoritativeDailyScheduleSnapshot = useCallback((state: UiSnapshot): void => {
|
||||
masterSnapshotRef.current = state;
|
||||
persistedSettingsRef.current = state.settings;
|
||||
latestStateRef.current = null;
|
||||
snapshotRef.current = state;
|
||||
setSnapshot(state);
|
||||
@@ -5717,11 +5820,13 @@ export function App(): ReactElement {
|
||||
const settingsViewModel: SettingsViewModel = {
|
||||
section: settingsSubTab,
|
||||
saveState: settingsDirty && settingsSaveState === "clean" ? "dirty" : settingsSaveState,
|
||||
saveInFlight: settingsSaveInFlight,
|
||||
form: settingsFormModel,
|
||||
accounts: accountWorkspaceModel
|
||||
};
|
||||
const settingsViewActions: SettingsViewActions = {
|
||||
onSectionChange: setSettingsSubTab,
|
||||
onDiscard: discardSettingsChanges,
|
||||
onSave: () => { void onSaveSettings(); },
|
||||
form: settingsFormActions,
|
||||
accounts: accountWorkspaceActions
|
||||
|
||||
@@ -6,7 +6,7 @@ const pairs = [
|
||||
["Hauptnavigation", "Main navigation"], ["Globale Aktionen", "Global actions"], ["Anwendungsmenü", "Application menu"], ["Seitenleiste einklappen", "Collapse sidebar"], ["Seitenleiste ausklappen", "Expand sidebar"],
|
||||
["Aktuelle Download-Geschwindigkeit (geglättet)", "Current download speed (smoothed)"], ["Einstellungsbereich", "Settings area"],
|
||||
["Entpacken", "Extraction"], ["Geschwindigkeit", "Speed"], ["Bereinigung", "Cleanup"], ["Updates", "Updates"],
|
||||
["Einstellungen speichern", "Save settings"], ["Gespeichert", "Saved"], ["Ungespeicherte Änderungen", "Unsaved changes"], ["Wird gespeichert…", "Saving…"], ["Speichern fehlgeschlagen", "Save failed"],
|
||||
["Einstellungen speichern", "Save settings"], ["Änderungen verwerfen", "Discard changes"], ["Stellt den letzten gespeicherten Stand wieder her.", "Restores the last saved settings."], ["Ungespeicherte Änderungen verworfen", "Unsaved changes discarded"], ["Zwischenstand gespeichert – weitere Änderungen sind ungespeichert", "Progress saved – additional changes remain unsaved"], ["Gespeichert", "Saved"], ["Ungespeicherte Änderungen", "Unsaved changes"], ["Wird gespeichert…", "Saving…"], ["Speichern fehlgeschlagen", "Save failed"],
|
||||
["Sprache", "Language"], ["Speicherort", "Storage location"], ["Download-Verhalten", "Download behavior"], ["Oberfläche und Bedienung", "Interface and controls"], ["Discord-Benachrichtigungen", "Discord notifications"],
|
||||
["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."],
|
||||
["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"],
|
||||
|
||||
@@ -20,12 +20,14 @@ export type SettingsViewRegion = "all" | "sidebar" | "content";
|
||||
export interface SettingsViewModel {
|
||||
section: SettingsSection;
|
||||
saveState: SettingsSaveState;
|
||||
saveInFlight: boolean;
|
||||
form: SettingsFormViewModel;
|
||||
accounts: AccountWorkspaceViewModel;
|
||||
}
|
||||
|
||||
export interface SettingsViewActions {
|
||||
onSectionChange: (section: SettingsSection) => void;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
form: SettingsFormActions;
|
||||
accounts: AccountWorkspaceActions;
|
||||
@@ -62,7 +64,8 @@ export function SettingsSidebar({ model, actions }: SettingsViewProps): ReactEle
|
||||
|
||||
export function SettingsContent({ model, actions }: SettingsViewProps): ReactElement {
|
||||
const saveLabel = getSettingsSaveLabel(model.saveState);
|
||||
const saveDisabled = model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving";
|
||||
const saveDisabled = model.saveInFlight || model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving";
|
||||
const discardDisabled = model.saveInFlight || model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving";
|
||||
return (
|
||||
<section aria-label="Einstellungsbereich" className="settings-content settings-static">
|
||||
<header className="settings-content-header">
|
||||
@@ -70,12 +73,21 @@ export function SettingsContent({ model, actions }: SettingsViewProps): ReactEle
|
||||
<h1>Einstellungen</h1>
|
||||
<span aria-live="polite" className={`settings-save-state is-${model.saveState}`} role="status">{saveLabel}</span>
|
||||
</div>
|
||||
<button
|
||||
className="settings-button settings-button-primary settings-save-button"
|
||||
disabled={saveDisabled}
|
||||
onClick={actions.onSave}
|
||||
type="button"
|
||||
>Einstellungen speichern</button>
|
||||
<div className="settings-content-actions">
|
||||
<button
|
||||
className="settings-button settings-button-secondary settings-discard-button"
|
||||
disabled={discardDisabled}
|
||||
onClick={actions.onDiscard}
|
||||
title="Stellt den letzten gespeicherten Stand wieder her."
|
||||
type="button"
|
||||
>Änderungen verwerfen</button>
|
||||
<button
|
||||
className="settings-button settings-button-primary settings-save-button"
|
||||
disabled={saveDisabled}
|
||||
onClick={actions.onSave}
|
||||
type="button"
|
||||
>Einstellungen speichern</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className={`settings-content-body${model.section === "accounts" ? " is-accounts" : ""}`}>
|
||||
{model.section === "accounts"
|
||||
|
||||
@@ -98,6 +98,13 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-content-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-content-header h1,
|
||||
.settings-form-heading h2,
|
||||
.settings-account-heading h2,
|
||||
|
||||
@@ -11,6 +11,10 @@ describe("renderer localization", () => {
|
||||
it("translates exact interface labels in both directions", () => {
|
||||
expect(translateUiText("Einstellungen speichern", "en")).toBe("Save settings");
|
||||
expect(translateUiText("Save settings", "de")).toBe("Einstellungen speichern");
|
||||
expect(translateUiText("Änderungen verwerfen", "en")).toBe("Discard changes");
|
||||
expect(translateUiText("Discard changes", "de")).toBe("Änderungen verwerfen");
|
||||
expect(translateUiText("Ungespeicherte Änderungen verworfen", "en")).toBe("Unsaved changes discarded");
|
||||
expect(translateUiText("Zwischenstand gespeichert – weitere Änderungen sind ungespeichert", "en")).toBe("Progress saved – additional changes remain unsaved");
|
||||
expect(translateUiText("Passwort/Zugang", "en")).toBe("Password/access");
|
||||
expect(translateUiText("Animationen", "en")).toBe("Animations");
|
||||
expect(translateUiText("Animations", "de")).toBe("Animationen");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createRendererSettings, createRendererState } from "../src/main/renderer-state";
|
||||
import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, createAccountDialogState, createSettingsDraft } from "../src/renderer/App";
|
||||
import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, createAccountDialogState, createDiscardedSettingsState, createSettingsDraft, resolveSettingsSaveCompletion } from "../src/renderer/App";
|
||||
import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit";
|
||||
import {
|
||||
buildScopedAccountEnabledState,
|
||||
@@ -342,10 +342,11 @@ function workspaceActions(overrides: Partial<AccountWorkspaceActions> = {}): Acc
|
||||
};
|
||||
}
|
||||
|
||||
function viewModel(saveState: SettingsViewModel["saveState"] = "clean"): SettingsViewModel {
|
||||
function viewModel(saveState: SettingsViewModel["saveState"] = "clean", saveInFlight = false): SettingsViewModel {
|
||||
return {
|
||||
section: "accounts",
|
||||
saveState,
|
||||
saveInFlight,
|
||||
form: formModel(),
|
||||
accounts: workspaceModel()
|
||||
};
|
||||
@@ -354,6 +355,7 @@ function viewModel(saveState: SettingsViewModel["saveState"] = "clean"): Setting
|
||||
function viewActions(): SettingsViewActions {
|
||||
return {
|
||||
onSectionChange: () => {},
|
||||
onDiscard: () => {},
|
||||
onSave: () => {},
|
||||
form: { onChange: () => {}, onAction: () => {} },
|
||||
accounts: workspaceActions()
|
||||
@@ -778,6 +780,37 @@ describe("settings views", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("enables discarding only for unsaved or failed settings drafts", () => {
|
||||
let discarded = 0;
|
||||
const actions = { ...viewActions(), onDiscard: () => { discarded += 1; } };
|
||||
const findDiscard = (content: ReactElement): ReactElement<{ disabled?: boolean; onClick: () => void }> => {
|
||||
let result: ReactElement<{ disabled?: boolean; onClick: () => void }> | null = null;
|
||||
visitElements(content, (element) => {
|
||||
if (element.type === "button" && element.props.children === "Änderungen verwerfen") {
|
||||
result = element as ReactElement<{ disabled?: boolean; onClick: () => void }>;
|
||||
}
|
||||
});
|
||||
if (!result) throw new Error("Discard button missing");
|
||||
return result;
|
||||
};
|
||||
|
||||
const dirty = findDiscard(SettingsContent({ actions, model: viewModel("dirty") }));
|
||||
const clean = findDiscard(SettingsContent({ actions, model: viewModel("clean") }));
|
||||
const saving = findDiscard(SettingsContent({ actions, model: viewModel("saving") }));
|
||||
const saved = findDiscard(SettingsContent({ actions, model: viewModel("saved") }));
|
||||
const error = findDiscard(SettingsContent({ actions, model: viewModel("error") }));
|
||||
const dirtyWhileSaving = findDiscard(SettingsContent({ actions, model: viewModel("dirty", true) }));
|
||||
|
||||
expect(dirty.props.disabled).toBe(false);
|
||||
expect(clean.props.disabled).toBe(true);
|
||||
expect(saving.props.disabled).toBe(true);
|
||||
expect(saved.props.disabled).toBe(true);
|
||||
expect(error.props.disabled).toBe(false);
|
||||
expect(dirtyWhileSaving.props.disabled).toBe(true);
|
||||
dirty.props.onClick();
|
||||
expect(discarded).toBe(1);
|
||||
});
|
||||
|
||||
it("renders the settings save action with a green background and black text", () => {
|
||||
const html = renderToStaticMarkup(<SettingsContent actions={viewActions()} model={viewModel("dirty")} />);
|
||||
const css = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8");
|
||||
@@ -1323,6 +1356,35 @@ describe("settings App integration", () => {
|
||||
expect(appSource).toContain("applyPersistedSettings(fresh.settings, false)");
|
||||
});
|
||||
|
||||
it("rebuilds every local settings input from the last persisted state", () => {
|
||||
const persisted = createRendererSettings({
|
||||
...defaultSettings(),
|
||||
theme: "light",
|
||||
speedLimitKbps: 12 * 1024,
|
||||
bandwidthSchedules: [{ id: "night", startHour: 22, endHour: 6, speedLimitKbps: 8 * 1024, enabled: true }]
|
||||
});
|
||||
|
||||
expect(createDiscardedSettingsState(persisted, "system")).toEqual({
|
||||
draft: { ...persisted, archivePasswordList: "", notifyUrl: "" },
|
||||
themeChoice: "system",
|
||||
speedLimitInput: "12",
|
||||
scheduleSpeedInputs: { night: "8" }
|
||||
});
|
||||
});
|
||||
|
||||
it("does not apply a stale save result over newer draft changes", () => {
|
||||
expect(resolveSettingsSaveCompletion(4, 4)).toEqual({
|
||||
saveState: "saved",
|
||||
applyPersistedTheme: true,
|
||||
toast: "Einstellungen gespeichert"
|
||||
});
|
||||
expect(resolveSettingsSaveCompletion(4, 5)).toEqual({
|
||||
saveState: "dirty",
|
||||
applyPersistedTheme: false,
|
||||
toast: "Zwischenstand gespeichert – weitere Änderungen sind ungespeichert"
|
||||
});
|
||||
});
|
||||
|
||||
it("loads and preserves the stored archive password list in the extraction section", () => {
|
||||
const revealBlock = sourceBlock(appSource, "const showToast", "const clearImportQueueFocusListener");
|
||||
const applyBlock = sourceBlock(appSource, "const applyPersistedSettings", "const syncLiveProviderUsageSettings");
|
||||
@@ -1429,9 +1491,20 @@ describe("settings App integration", () => {
|
||||
|
||||
it("keeps specific persistence revision-safe when the draft changes in flight", () => {
|
||||
const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction");
|
||||
const toggleBlock = sourceBlock(appSource, "const persistAccountToggle", "const onToggleDebridLinkApiKeyEnabled");
|
||||
expect(block).toContain("revisionAtStart");
|
||||
expect(block).toContain("mergeConcurrentSpecificSettings");
|
||||
expect(block).toContain('setSettingsSaveState("dirty")');
|
||||
expect(block).toContain("persistedSettingsRef.current = result");
|
||||
expect(block.indexOf("persistedSettingsRef.current = result")).toBeLessThan(block.indexOf("if (settingsDraftRevisionRef.current === revisionAtStart)"));
|
||||
expect(block).toContain("themeChoiceAtStart: settingsThemeChoiceRef.current");
|
||||
expect(block).toContain("const { revisionAtStart, draftAtStart, themeChoiceAtStart } = persistenceContext");
|
||||
expect(block).toContain("persistedThemeChoiceRef.current = themeChoiceAtStart");
|
||||
expect(block.indexOf("persistedThemeChoiceRef.current = themeChoiceAtStart")).toBeLessThan(block.indexOf("if (settingsDraftRevisionRef.current === revisionAtStart)"));
|
||||
expect(toggleBlock).toContain("const themeChoiceAtStart = settingsThemeChoiceRef.current");
|
||||
expect(toggleBlock).toContain("const persistenceContext = {");
|
||||
expect(toggleBlock.indexOf("const persistenceContext = {")).toBeLessThan(toggleBlock.indexOf("runAccountEnableRefresh("));
|
||||
expect(toggleBlock).toContain("persistSpecificSettings(nextDraft, persistenceContext)");
|
||||
});
|
||||
|
||||
it("keeps unchecked single accounts honest without a positive status", () => {
|
||||
|
||||
Reference in New Issue
Block a user