diff --git a/.changeset/preserve-unchanged-settings.md b/.changeset/preserve-unchanged-settings.md new file mode 100644 index 00000000..3166fb79 --- /dev/null +++ b/.changeset/preserve-unchanged-settings.md @@ -0,0 +1,8 @@ +--- +"ftw": patch +--- + +Keep unchanged form defaults out of saved settings. Opening Planner and saving +now preserves an unset SoC limit, including when saving from another tab. +Show the planner's 95% default maximum instead of 90%. +Keep an untouched driver profile absent when its choices arrive after the form. diff --git a/web/settings-shell.test.mjs b/web/settings-shell.test.mjs index 1a4b8c6e..d231c39d 100644 --- a/web/settings-shell.test.mjs +++ b/web/settings-shell.test.mjs @@ -39,6 +39,7 @@ function loadShell(saveResponse, ok = true) { for (const id of ELEMENT_IDS) elements[id] = stubElement(); const requests = []; + const responses = {}; const sandbox = { window: { FTWSettings: { tabs: {} } }, document: { @@ -47,7 +48,7 @@ function loadShell(saveResponse, ok = true) { }, fetch(path, opts) { requests.push({ path, opts }); - return Promise.resolve({ ok, status: ok ? 200 : 400, json: () => Promise.resolve(saveResponse) }); + return Promise.resolve({ ok, status: ok ? 200 : 400, headers: { get: () => '"config-7"' }, json: () => Promise.resolve(responses[path] ?? saveResponse) }); }, // The shell only uses timers to clear the "Saved" status and to poll after // a restart; neither is what these tests are about. @@ -57,12 +58,137 @@ function loadShell(saveResponse, ok = true) { sandbox.globalThis = sandbox; vm.createContext(sandbox); vm.runInContext(source, sandbox); - return { elements, requests, tabs: sandbox.window.FTWSettings.tabs }; + return { elements, requests, responses, tabs: sandbox.window.FTWSettings.tabs, + loadTab: file => vm.runInContext(readFileSync(new URL(file, import.meta.url), "utf8"), sandbox) }; } // One turn of the event loop, which is all the save chain needs to settle. const settled = () => new Promise((resolve) => setImmediate(resolve)); +async function formShell(original = { site: { name: "Home" }, planner: { enabled: true }, hidden: { keep: 17 } }) { + const rig = loadShell(structuredClone(original)); + const field = (path, type, value) => Object.assign(stubElement(), { dataset: { path }, type, value }); + const fields = { + min: field("planner.soc_min", "number", "0.1"), + max: field("planner.soc_max", "number", "0.95"), + engine: field("planner.engine", "select-one", ""), + checkbox: Object.assign(stubElement(), { dataset: { checkboxPath: "optional.enabled" }, checked: false }), + name: field("site.name", "text", "Home"), + }; + let visible = [], context; + rig.elements["settings-body"].querySelectorAll = selector => visible.filter(input => + selector === "[data-path]" ? input.dataset.path : selector === "[data-checkbox-path]" && input.dataset.checkboxPath); + rig.tabs.control = { render: ctx => { context = ctx; visible = [fields.name]; return ""; } }; + rig.tabs.planner = { render: () => { visible = [fields.min, fields.max, fields.engine, fields.checkbox]; return ""; } }; + rig.elements["settings-btn"].handlers.click(); + await settled(); + return { ...rig, original, fields, context, addField: input => visible.push(input), saved: () => JSON.parse(rig.requests.filter(r => r.opts?.method === "POST").at(-1).opts.body) }; +} + +describe("unchanged settings fields", () => { + for (const visitPlanner of [false, true]) { + it("preserves absent values on save, visit Planner=" + visitPlanner, async () => { + const rig = await formShell(); + if (visitPlanner) rig.context.navigateTab("planner"); + await rig.context.saveConfig(); + assert.deepEqual(rig.saved(), rig.original); + assert.equal(rig.requests.at(-1).opts.headers["If-Match"], '"config-7"'); + }); + } + + it("keeps Planner defaults absent after visiting it and saving another tab", async () => { + const rig = await formShell(); + rig.context.navigateTab("planner"); + rig.context.navigateTab("control"); + rig.fields.name.value = "New name"; + await rig.context.saveConfig(); + assert.deepEqual(rig.saved(), { ...rig.original, site: { name: "New name" } }); + }); + + it("saves explicit number, select and checkbox edits and a later return to the rendered value", async () => { + const rig = await formShell(); + rig.context.navigateTab("planner"); + rig.fields.max.value = "0.8"; + rig.fields.engine.value = "core"; + rig.fields.checkbox.checked = true; + await rig.context.saveConfig(); + assert.deepEqual(rig.saved().planner, { enabled: true, soc_max: 0.8, engine: "core" }); + assert.deepEqual(rig.saved().optional, { enabled: true }); + rig.fields.max.value = "0.95"; + await rig.context.saveConfig(); + assert.equal(rig.saved().planner.soc_max, 0.95); + }); + + it("preserves a late secret input until its value changes", async () => { + const rig = await formShell(); + const secret = Object.assign(stubElement(), { dataset: { path: "device.secret" }, type: "password", defaultValue: "", value: "" }); + rig.addField(secret); + await rig.context.saveConfig(); + assert.deepEqual(rig.saved(), rig.original); + secret.value = "local-test-value"; + await rig.context.saveConfig(); + assert.equal(rig.saved().device.secret, "local-test-value"); + }); +}); + +// Run the real asynchronous Devices catalog callback. The shim parses the +// select it inserts; like HTMLSelectElement, it has no defaultValue property. +async function profileShell() { + const original = { site: { name: "Home" }, drivers: [{ name: "GoodWe", lua: "drivers/goodwe.lua", + capabilities: { modbus: { unit_id: 1 } }, config: {} }] }; + const rig = await formShell(original); + rig.loadTab("./settings/tabs/devices.js"); + rig.responses["/api/drivers/catalog"] = { entries: [{ id: "goodwe", path: "drivers/goodwe.lua", version: "1.0.2" }] }; + const unit = Object.assign(stubElement(), { dataset: { path: "drivers.0.capabilities.modbus.unit_id" }, + type: "number", defaultValue: "1", value: "1" }); + rig.addField(unit); + let select; + const slot = { + getAttribute: () => "0", + set innerHTML(html) { + const options = [...html.matchAll(/