feat(settings): discard unsaved changes

This commit is contained in:
Sucukdeluxe
2026-08-25 04:53:53 +02:00
parent 5a04a62945
commit bd2808a763
6 changed files with 244 additions and 43 deletions
+4
View File
@@ -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");
+75 -2
View File
@@ -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", () => {