From 278b6760c038b9a359bbce7b10da34efb58aacc0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 17:01:55 +0200 Subject: [PATCH 1/2] fix(web): switching the import ceiling on applies the box's last value (#1674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Grid dialog posted the ceiling from the browser's localStorage when the toggle went on. Göran's browser held 3,000 W from a test, so the box applied 3,000 W instead of the 7,500 W he meant and his car stopped. The box now keeps the last ceiling that was on and serves it in /api/status as peak_import_ceiling_last_w. The page shows and applies that value. A box that never had a ceiling on applies nothing until Save, and Save in the dialog now looks off while disabled. Co-Authored-By: Claude Opus 5.5 --- .changeset/import-ceiling-on-box.md | 5 + go/internal/api/api.go | 39 ++++- .../api/api_peak_import_ceiling_test.go | 108 ++++++++++++ web/app.css | 5 + web/app.js | 52 +++--- web/peak-import-ceiling.test.mjs | 157 ++++++++++++++++++ 6 files changed, 337 insertions(+), 29 deletions(-) create mode 100644 .changeset/import-ceiling-on-box.md create mode 100644 go/internal/api/api_peak_import_ceiling_test.go create mode 100644 web/peak-import-ceiling.test.mjs diff --git a/.changeset/import-ceiling-on-box.md b/.changeset/import-ceiling-on-box.md new file mode 100644 index 000000000..ab3a50f5d --- /dev/null +++ b/.changeset/import-ceiling-on-box.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Switching the peak import limit on now applies the last limit the box had on, not a value one browser kept from earlier. The Grid dialog shows that value while the limit is off, and `/api/status` serves it as `peak_import_ceiling_last_w`. If the box has never had a limit on, switching on applies nothing until you pick a value and press Save, and Save now looks off while there is nothing to apply. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 7baaac9c2..cef04ad8d 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1251,6 +1251,9 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { "control_feedback": s.controlFeedback(time.Now()), "slot_delivery_stats": ctrl.SlotDeliveryStats, } + // The ceiling that switching on applies, so a client shows the box's + // value instead of keeping its own (#1674). Null until one was on. + resp["peak_import_ceiling_last_w"] = s.peakImportCeilingLastW(ctrl.PeakImportCeilingW) // The operator's cap for storing live PV surplus, 0 when unset. Dispatch // prefers it to the plan's per-slot cap, so the Plan card needs it to say // where extra sun goes. @@ -1830,14 +1833,48 @@ func (s *Server) handleSetPeakImportCeiling(w http.ResponseWriter, r *http.Reque return } s.deps.CtrlMu.Lock() + prevW := s.deps.Ctrl.PeakImportCeilingW s.deps.Ctrl.PeakImportCeilingW = req.PeakImportCeilingW s.deps.CtrlMu.Unlock() if s.deps.State != nil { - _ = s.deps.State.SaveConfig("peak_import_ceiling_w", strconv.FormatFloat(req.PeakImportCeilingW, 'f', 1, 64)) + values := map[string]string{"peak_import_ceiling_w": strconv.FormatFloat(req.PeakImportCeilingW, 'f', 1, 64)} + // Switching off keeps the ceiling that was on, so switching back on + // restores the box's tariff, not a value a browser kept (#1674). + lastW := req.PeakImportCeilingW + if lastW == 0 { + lastW = prevW + } + if lastW > 0 { + values[peakImportCeilingLastKey] = strconv.FormatFloat(lastW, 'f', 1, 64) + } + _ = s.deps.State.SaveConfigValues(values) } writeJSON(w, 200, map[string]any{"status": "ok", "peak_import_ceiling_w": req.PeakImportCeilingW}) } +// peakImportCeilingLastKey holds the last import ceiling that was on. +const peakImportCeilingLastKey = "peak_import_ceiling_last_w" + +// peakImportCeilingLastW is the ceiling switching on applies: the current one +// while on, else the last one that was on. Nil when none ever was. +func (s *Server) peakImportCeilingLastW(currentW float64) *float64 { + if currentW > 0 { + return ¤tW + } + if s.deps.State == nil { + return nil + } + v, ok := s.deps.State.LoadConfig(peakImportCeilingLastKey) + if !ok { + return nil + } + f, err := strconv.ParseFloat(v, 64) + if err != nil || f <= 0 { + return nil + } + return &f +} + // ---- /api/ev_charging ---- func (s *Server) handleSetEVCharging(w http.ResponseWriter, r *http.Request) { diff --git a/go/internal/api/api_peak_import_ceiling_test.go b/go/internal/api/api_peak_import_ceiling_test.go new file mode 100644 index 000000000..8a52afb65 --- /dev/null +++ b/go/internal/api/api_peak_import_ceiling_test.go @@ -0,0 +1,108 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// #1674: switching the import ceiling on applied 3,000 W that one browser +// had kept from a test, not the 7,500 W the owner meant. The box keeps the +// last ceiling that was on and serves it, so a client never supplies it. + +func newCeilingServer(t *testing.T, st *state.Store, ctrl *control.State) *Server { + t.Helper() + return New(&Deps{State: st, Tel: telemetry.NewStore(), Ctrl: ctrl, CtrlMu: &sync.Mutex{}, CapMu: &sync.RWMutex{}, CfgMu: &sync.RWMutex{}, Cfg: &config.Config{}}) +} + +func postCeiling(t *testing.T, srv *Server, w string) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/peak_import_ceiling", strings.NewReader(`{"peak_import_ceiling_w":`+w+`}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("POST %s W: status %d, body %s", w, rr.Code, rr.Body.String()) + } +} + +// ceilingStatus returns the ceiling and the last one that was on, as +// /api/status serves them; nil stands for JSON null. +func ceilingStatus(t *testing.T, srv *Server) (any, any) { + t.Helper() + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + var body map[string]any + if rr.Code != http.StatusOK || json.Unmarshal(rr.Body.Bytes(), &body) != nil { + t.Fatalf("status = %d, body: %s", rr.Code, rr.Body.String()) + } + last, ok := body["peak_import_ceiling_last_w"] + if !ok { + t.Fatal("status has no peak_import_ceiling_last_w") + } + return body["peak_import_ceiling_w"], last +} + +func TestPeakImportCeilingKeepsTheLastOneThatWasOn(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + st, err := state.Open(path) + if err != nil { + t.Fatal(err) + } + srv := newCeilingServer(t, st, &control.State{}) + + if now, last := ceilingStatus(t, srv); now != 0.0 || last != nil { + t.Fatalf("fresh box: ceiling %v, last %v; want 0 and null", now, last) + } + postCeiling(t, srv, "7500") + if now, last := ceilingStatus(t, srv); now != 7500.0 || last != 7500.0 { + t.Fatalf("on: ceiling %v, last %v; want 7,500 each", now, last) + } + postCeiling(t, srv, "0") + postCeiling(t, srv, "0") + if now, last := ceilingStatus(t, srv); now != 0.0 || last != 7500.0 { + t.Fatalf("off: ceiling %v, last %v; want 0 and 7,500", now, last) + } + + // The record outlives a restart, as the ceiling itself does. + if err := st.Close(); err != nil { + t.Fatal(err) + } + st, err = state.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + srv = newCeilingServer(t, st, &control.State{}) + if now, last := ceilingStatus(t, srv); now != 0.0 || last != 7500.0 { + t.Fatalf("after restart: ceiling %v, last %v; want 0 and 7,500", now, last) + } + if v, ok := st.LoadConfig("peak_import_ceiling_w"); !ok || v != "0.0" { + t.Fatalf("stored ceiling %q (%v), want 0.0", v, ok) + } +} + +// A ceiling switched on before this release was stored without a last +// value. Switching it off still keeps it. +func TestPeakImportCeilingOffKeepsACeilingSetBeforeTheRecord(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + srv := newCeilingServer(t, st, &control.State{PeakImportCeilingW: 6000}) + + postCeiling(t, srv, "0") + if now, last := ceilingStatus(t, srv); now != 0.0 || last != 6000.0 { + t.Fatalf("off: ceiling %v, last %v; want 0 and 6,000", now, last) + } +} diff --git a/web/app.css b/web/app.css index c7eddb646..9ba175b66 100644 --- a/web/app.css +++ b/web/app.css @@ -1295,6 +1295,11 @@ body.ftw-app .slider-group-compact input[type="range"]:disabled { body.ftw-app .slider-group-compact:has(input[type="range"]:disabled) .slider-current { color: var(--fg-muted); } +/* Save lights up only when there is a value to apply. */ +body.ftw-app .slider-group-compact .btn-send:disabled { + cursor: not-allowed; + opacity: 0.45; +} body.ftw-app .slider-current { font-family: var(--mono); font-size: 12px; diff --git a/web/app.js b/web/app.js index 589250457..441279459 100644 --- a/web/app.js +++ b/web/app.js @@ -290,19 +290,10 @@ // visual cue that there's work to commit. Cleared on successful POST. let peakLimitDirty = false; let gridTargetDirty = false; - // Last non-zero peak ceiling, remembered so unchecking + rechecking - // the enable toggle restores the previous value instead of resetting - // to the default. Keyed by localStorage so it survives reloads. - function readLastPeakLimitW() { - try { - const v = localStorage.getItem("ftw-peak-import-ceiling-w"); - const n = v == null ? null : Number(v); - return Number.isFinite(n) && n > 0 ? n : 5000; - } catch (e) { return 5000; } - } - function writeLastPeakLimitW(w) { - try { localStorage.setItem("ftw-peak-import-ceiling-w", String(w)); } catch (e) {} - } + // The last peak ceiling that was on, as the box serves it: what switching + // the toggle back on applies. A copy kept in the browser once applied an + // old test value (#1674), so only the box keeps it. + let peakLimitLastW = null; const fuseUse = $("fuse-use"); const fuseFill = $("fuse-fill"); const fusePhases = $("fuse-phases"); @@ -871,6 +862,7 @@ const peakSrcW = data.peak_import_ceiling_w != null ? data.peak_import_ceiling_w : data.peak_limit_w; + peakLimitLastW = data.peak_import_ceiling_last_w > 0 ? data.peak_import_ceiling_last_w : null; if (peakLimitSlider && !peakLimitDirty && peakSrcW != null) { const enabled = peakSrcW > 0; if (peakLimitEnableToggle) { @@ -878,13 +870,13 @@ if (peakLimitEnableLabel) peakLimitEnableLabel.textContent = enabled ? "On" : "Off"; } peakLimitSlider.disabled = !enabled; - const display = enabled ? peakSrcW : readLastPeakLimitW(); + // Off, the slider shows what switching on applies. + const display = enabled ? peakSrcW : peakLimitLastW || Number(peakLimitSlider.defaultValue); peakLimitSlider.value = display; // Don't decorate the value with " (off)" — the toggle is the // single source of truth for enabled/disabled, doubling that // signal is the kind of redundancy the shared design system warns about. peakLimitValue.textContent = formatW(display); - if (enabled) writeLastPeakLimitW(peakSrcW); if (peakLimitSend) peakLimitSend.disabled = true; // pristine } // Energy today @@ -2665,9 +2657,9 @@ } // Peak slider: same dirty pattern, plus an enable/disable checkbox. - // Off → POST 0 (backend reads 0 = disabled). On → POST slider value. - // The slider is disabled when the toggle is off so the operator can't - // accidentally drag a dead control. + // Off → POST 0 (backend reads 0 = disabled). On → POST the box's last + // ceiling. The slider is disabled when the toggle is off so the operator + // can't accidentally drag a dead control. if (peakLimitSlider) { peakLimitSlider.addEventListener("input", function () { const w = Number(peakLimitSlider.value); @@ -2681,15 +2673,20 @@ const enabled = peakLimitEnableToggle.checked; if (peakLimitEnableLabel) peakLimitEnableLabel.textContent = enabled ? "On" : "Off"; if (peakLimitSlider) peakLimitSlider.disabled = !enabled; - // Show the value the toggle is about to enable (last known) - // without committing — the operator still has to press Save - // unless the toggle is being switched OFF, which is a destructive - // change worth one extra click confirmation. Wait — the spec - // calls for the toggle itself to be the on/off control, so flip - // straight through: post immediately on toggle change. - const w = enabled ? Number(peakLimitSlider.value) || readLastPeakLimitW() : 0; - if (enabled && peakLimitSlider) peakLimitSlider.value = w; - if (peakLimitValue) peakLimitValue.textContent = formatW(w); + if (enabled && !peakLimitLastW) { + // The box has never had a ceiling on, so there is none to restore: + // the owner picks one and Save applies it. + peakLimitDirty = true; + if (peakLimitSend) peakLimitSend.disabled = false; + return; + } + // On applies the ceiling the box last had on, shown as it applies; + // off applies 0. The toggle is the switch, so neither waits for Save. + const w = enabled ? peakLimitLastW : 0; + if (enabled) { + if (peakLimitSlider) peakLimitSlider.value = w; + if (peakLimitValue) peakLimitValue.textContent = formatW(w); + } peakLimitEnableToggle.disabled = true; setPeakImportCeiling(w) .then(function () { peakLimitDirty = false; if (peakLimitSend) peakLimitSend.disabled = true; }) @@ -2700,7 +2697,6 @@ if (peakLimitSend) { peakLimitSend.addEventListener("click", function () { const w = Number(peakLimitSlider.value); - writeLastPeakLimitW(w); peakLimitSend.disabled = true; setPeakImportCeiling(w) .then(function () { peakLimitDirty = false; }) diff --git a/web/peak-import-ceiling.test.mjs b/web/peak-import-ceiling.test.mjs new file mode 100644 index 000000000..c13b3c80b --- /dev/null +++ b/web/peak-import-ceiling.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import vm from "node:vm"; + +// #1674: switching the peak import ceiling on applied 3,000 W that the +// browser kept from a test, not the 7,500 W the owner meant. These run the +// real app.js against a fake DOM: switching on must apply the box's last +// ceiling and nothing this browser stored. + +const source = readFileSync(new URL("./app.js", import.meta.url), "utf8"); + +function element(id) { + const listeners = new Map(); + const context = new Proxy({}, { + get(target, key) { + if (!(key in target)) target[key] = function () {}; + return target[key]; + }, + set(target, key, value) { target[key] = value; return true; }, + }); + return { + id, listeners, + classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } }, + dataset: {}, style: {}, children: [], childNodes: [], + parentNode: { insertBefore() {} }, + textContent: "", innerHTML: "", value: "", defaultValue: "", checked: false, disabled: false, hidden: false, + addEventListener(type, fn) { listeners.set(type, fn); }, + removeEventListener() {}, appendChild() {}, append() {}, + replaceChildren() {}, remove() {}, focus() {}, click() {}, + setAttribute() {}, removeAttribute() {}, getAttribute() { return null; }, + querySelector() { return null; }, querySelectorAll() { return []; }, + closest() { return null; }, matches() { return false; }, + getContext() { return context; }, + getBoundingClientRect() { return { width: 800, height: 400, left: 0, top: 0 }; }, + }; +} + +function rig(status) { + const elements = new Map(); + const byId = (id) => { + if (!elements.has(id)) elements.set(id, element(id)); + return elements.get(id); + }; + // As index.html ships the control: off, slider disabled at 5,000 W. + Object.assign(byId("peak-limit-slider"), { value: "5000", defaultValue: "5000", disabled: true }); + Object.assign(byId("peak-limit-send"), { disabled: true }); + + const posts = []; + const document = { + hidden: false, + body: element("body"), + documentElement: element("html"), + getElementById: byId, + querySelector() { return element(""); }, + querySelectorAll() { return []; }, + createElement() { return element(""); }, + createTextNode(text) { return { textContent: text }; }, + createDocumentFragment() { return element(""); }, + addEventListener() {}, + }; + const window = { + innerWidth: 1024, innerHeight: 768, devicePixelRatio: 1, + addEventListener() {}, dispatchEvent() {}, + customElements: { whenDefined() { return new Promise(() => {}); } }, + }; + // Göran's browser: a 3,000 W test value left in storage. + const kept = new Map([["ftw-peak-import-ceiling-w", "3000"]]); + const storage = { + getItem(key) { return kept.has(key) ? kept.get(key) : null; }, + setItem(key, value) { kept.set(key, String(value)); }, + removeItem(key) { kept.delete(key); }, + }; + const json = (body) => Promise.resolve({ ok: true, status: 200, json() { return Promise.resolve(body); } }); + const sandbox = { + window, document, localStorage: storage, sessionStorage: storage, + customElements: window.customElements, + AbortController: class { constructor() { this.signal = { aborted: false, addEventListener() {} }; } abort() {} }, + fetch(path, opts = {}) { + path = String(path); + if (opts.method === "POST") { + posts.push({ path, body: JSON.parse(opts.body) }); + return json({ status: "ok" }); + } + if (path === "/api/status") return json(status); + if (path === "/api/loadpoints") return json({ loadpoints: [] }); + if (path.startsWith("/api/history?")) return json({ items: [] }); + return json({}); + }, + setInterval() { return 1; }, clearInterval() {}, + setTimeout() { return 1; }, clearTimeout() {}, + requestAnimationFrame() { return 0; }, cancelAnimationFrame() {}, + getComputedStyle() { return { getPropertyValue() { return ""; } }; }, + ResizeObserver: class { observe() {} disconnect() {} }, + MutationObserver: class { observe() {} disconnect() {} }, + console, Date, Math, JSON, Promise, Map, Set, URL, URLSearchParams, + }; + window.window = window; + window.document = document; + window.localStorage = storage; + sandbox.globalThis = sandbox; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + + const settle = () => new Promise((resolve) => setImmediate(resolve)); + return { + posts, kept, + el: byId, + settle, + async toggle(on) { + const t = byId("peak-limit-enabled-toggle"); + t.checked = on; + t.listeners.get("change")(); + await settle(); + }, + }; +} + +const ceilingPosts = (app) => app.posts.filter((p) => p.path === "/api/peak_import_ceiling"); + +test("switching on applies the box's last ceiling, not the browser's", async () => { + const app = rig({ peak_import_ceiling_w: 0, peak_import_ceiling_last_w: 7500 }); + await app.settle(); + assert.equal(Number(app.el("peak-limit-slider").value), 7500, "off, the slider shows what switching on applies"); + assert.equal(app.el("peak-limit-value").textContent, "7.5 kW"); + + await app.toggle(true); + assert.deepEqual(ceilingPosts(app).map((p) => p.body), [{ peak_import_ceiling_w: 7500 }]); + assert.equal(app.el("peak-limit-value").textContent, "7.5 kW"); + assert.equal(app.kept.get("ftw-peak-import-ceiling-w"), "3000", "the page neither reads nor writes its old copy"); +}); + +test("with no ceiling ever on, switching on waits for Save", async () => { + const app = rig({ peak_import_ceiling_w: 0, peak_import_ceiling_last_w: null }); + await app.settle(); + assert.equal(Number(app.el("peak-limit-slider").value), 5000); + + await app.toggle(true); + assert.equal(ceilingPosts(app).length, 0, "nothing applies before the owner picks a value"); + assert.equal(app.el("peak-limit-slider").disabled, false); + assert.equal(app.el("peak-limit-send").disabled, false); + + app.el("peak-limit-slider").value = "7500"; + app.el("peak-limit-slider").listeners.get("input")(); + app.el("peak-limit-send").listeners.get("click")(); + await app.settle(); + assert.deepEqual(ceilingPosts(app).map((p) => p.body), [{ peak_import_ceiling_w: 7500 }]); +}); + +test("switching off applies 0", async () => { + const app = rig({ peak_import_ceiling_w: 7500, peak_import_ceiling_last_w: 7500 }); + await app.settle(); + assert.equal(app.el("peak-limit-enabled-toggle").checked, true); + + await app.toggle(false); + assert.deepEqual(ceilingPosts(app).map((p) => p.body), [{ peak_import_ceiling_w: 0 }]); +}); From 1a1d4b0bda1ad9cc6925f2d074fc4bf5838b2502 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 17:25:51 +0200 Subject: [PATCH 2/2] fix(api): a ceiling the box cannot save is not applied (#1674) POST /api/peak_import_ceiling changed the live ceiling and answered 200 even when saving it failed, so a restart could bring back the old one. It now saves first and answers 500, leaving the ceiling as it was. Co-Authored-By: Claude Opus 5.5 --- go/internal/api/api.go | 12 +++++++-- .../api/api_peak_import_ceiling_test.go | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 78e56b2ce..10479f1b1 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1840,7 +1840,6 @@ func (s *Server) handleSetPeakImportCeiling(w http.ResponseWriter, r *http.Reque } s.deps.CtrlMu.Lock() prevW := s.deps.Ctrl.PeakImportCeilingW - s.deps.Ctrl.PeakImportCeilingW = req.PeakImportCeilingW s.deps.CtrlMu.Unlock() if s.deps.State != nil { values := map[string]string{"peak_import_ceiling_w": strconv.FormatFloat(req.PeakImportCeilingW, 'f', 1, 64)} @@ -1853,8 +1852,17 @@ func (s *Server) handleSetPeakImportCeiling(w http.ResponseWriter, r *http.Reque if lastW > 0 { values[peakImportCeilingLastKey] = strconv.FormatFloat(lastW, 'f', 1, 64) } - _ = s.deps.State.SaveConfigValues(values) + // Save before applying, so the box never runs a ceiling that a + // restart would undo. + if err := s.deps.State.SaveConfigValues(values); err != nil { + slog.Warn("failed to persist peak_import_ceiling_w", "err", err) + writeJSON(w, 500, map[string]string{"error": "save failed: " + err.Error()}) + return + } } + s.deps.CtrlMu.Lock() + s.deps.Ctrl.PeakImportCeilingW = req.PeakImportCeilingW + s.deps.CtrlMu.Unlock() writeJSON(w, 200, map[string]any{"status": "ok", "peak_import_ceiling_w": req.PeakImportCeilingW}) } diff --git a/go/internal/api/api_peak_import_ceiling_test.go b/go/internal/api/api_peak_import_ceiling_test.go index 8a52afb65..a956628d3 100644 --- a/go/internal/api/api_peak_import_ceiling_test.go +++ b/go/internal/api/api_peak_import_ceiling_test.go @@ -106,3 +106,28 @@ func TestPeakImportCeilingOffKeepsACeilingSetBeforeTheRecord(t *testing.T) { t.Fatalf("off: ceiling %v, last %v; want 0 and 6,000", now, last) } } + +// A ceiling the box cannot save is not applied: a restart would bring the +// old one back, and the caller has to learn that the change did not hold. +func TestPeakImportCeilingNotAppliedWhenTheSaveFails(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + ctrl := &control.State{PeakImportCeilingW: 6000} + srv := newCeilingServer(t, st, ctrl) + if err := st.Close(); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/peak_import_ceiling", strings.NewReader(`{"peak_import_ceiling_w":0}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%s)", rr.Code, rr.Body.String()) + } + if ctrl.PeakImportCeilingW != 6000 { + t.Fatalf("ceiling = %.0f W after a failed save, want 6,000", ctrl.PeakImportCeilingW) + } +}