From f1819d0af9302a9fa53a08503efc6074a688c070 Mon Sep 17 00:00:00 2001 From: Alkly Date: Sat, 25 Apr 2026 23:07:30 +0200 Subject: [PATCH] chore(release): 0.1.17 --- ...5-1c-chart-legende-und-update-indikator.md | 2 +- .../5-1d-live-betrieb-klartext-diagnose.md | 8 +- .../5-1e-settings-link-im-running-footer.md | 6 +- addon/config.yaml | 2 +- backend/src/solalex/api/routes/control.py | 6 +- .../unit/test_api_devices_patch_config.py | 4 +- .../unit/test_controller_multi_export.py | 37 ++++++--- .../unit/test_controller_policy_export.py | 9 +- backend/tests/unit/test_migrate.py | 30 +++++++ frontend/src/lib/api/client.test.ts | 83 ++++++++++++++++++- frontend/src/routes/Running.test.ts | 12 +++ 11 files changed, 173 insertions(+), 26 deletions(-) diff --git a/_bmad-output/implementation-artifacts/5-1c-chart-legende-und-update-indikator.md b/_bmad-output/implementation-artifacts/5-1c-chart-legende-und-update-indikator.md index 8bc7cc1..e4a9fa9 100644 --- a/_bmad-output/implementation-artifacts/5-1c-chart-legende-und-update-indikator.md +++ b/_bmad-output/implementation-artifacts/5-1c-chart-legende-und-update-indikator.md @@ -379,7 +379,7 @@ claude-opus-4-7[1m] (Claude Code, bmad-dev-story-Skill) ### Decision-Needed -- [ ] [Review][Decision] **Wall-clock `setInterval` STOP-Signal-Verletzung formal abnicken oder härten** — `Running.svelte:254-265` setzt einen 1 s-`setInterval`, was die in der Story unter „Anti-Pattern" gelistete Regel „kein eigenes Tick-System" verletzt. Dev-Note in den Completion-Notes erkennt das an („sonst ist `isStale` design-bedingt nie testbar"). Optionen: (a) als bewusste Spec-Abweichung dokumentieren und Anti-Pattern-Liste in der Story korrigieren; (b) `visibilitychange`-Pause + cleanup-discipline zusätzlich härten (siehe defer W6); (c) auf reine `polling.error`-basierte Stale-Detection umstellen (kein Wall-Clock nötig, aber ohne Polling-Hang-Detection). +- [ ] [Review][Decision-OFFEN] **Wall-clock `setInterval` STOP-Signal-Verletzung formal abnicken oder härten** (cosmetic; nicht beta-blocking, kann in v1.5-Polish-Story entschieden werden) — `Running.svelte:254-265` setzt einen 1 s-`setInterval`, was die in der Story unter „Anti-Pattern" gelistete Regel „kein eigenes Tick-System" verletzt. Dev-Note in den Completion-Notes erkennt das an („sonst ist `isStale` design-bedingt nie testbar"). Optionen: (a) als bewusste Spec-Abweichung dokumentieren und Anti-Pattern-Liste in der Story korrigieren; (b) `visibilitychange`-Pause + cleanup-discipline zusätzlich härten (siehe defer W6); (c) auf reine `polling.error`-basierte Stale-Detection umstellen (kein Wall-Clock nötig, aber ohne Polling-Hang-Detection). ### Patches diff --git a/_bmad-output/implementation-artifacts/5-1d-live-betrieb-klartext-diagnose.md b/_bmad-output/implementation-artifacts/5-1d-live-betrieb-klartext-diagnose.md index d862b31..3a6bca4 100644 --- a/_bmad-output/implementation-artifacts/5-1d-live-betrieb-klartext-diagnose.md +++ b/_bmad-output/implementation-artifacts/5-1d-live-betrieb-klartext-diagnose.md @@ -380,12 +380,14 @@ Keine Debug-Log-Referenzen — alle Validation-Gates sofort grün durchgelaufen. ### Decision-Needed -- [ ] [Review][Decision] **MULTI-Mode Reason-Verhalten — wer „gewinnt" wenn Speicher und Drossel beide Reasons setzen?** — `_dispatch_by_mode` (controller.py:564) reset `_last_policy_noop_reason = None` einmal vor Dispatch. `_policy_multi` ruft (Spec) Speicher zuerst und Drossel als Fallback bei Cap+Einspeisung. Beide setzen Reasons → letzter writer gewinnt. Der User sieht im Cycle-Header nur einen Grund. Optionen: (a) Last-write-wins akzeptieren (zeigt warum die FINALE Branch nicht regelte) — current behavior; (b) First-write-wins (zeigt die ursprünglich blockierende Branch); (c) Concat zu `multi: speicher=…, drossel=…` mit `;`-Trenner. Risiko `(a)`: bei Speicher-Cap+Drossel-Deadband zeigt UI „Im Toleranzbereich" — verschluckt das eigentliche „Akku voll"-Signal. -- [ ] [Review][Decision] **`_effective_value_w` für non-grid_meter Rolle: `None` (Spec AC 14) oder raw value (aktueller Code)?** [backend/src/solalex/api/routes/control.py:247-248] — Spec sagt explizit „bei anderen Rollen `None` und der `state`-Wert ist die Wahrheit". Code returnt aber den raw `value` für alle non-grid_meter-Rollen. Test wurde an Code angepasst (`test_entity_snapshot_non_grid_meter_passes_state_through` asserts `effective_value_w == 800.0`). Optionen: (a) Code an Spec anpassen (return `None`) und Test umdrehen; (b) Spec an Code anpassen (raw-passthrough für alle Rollen, dokumentieren als „grid_meter ist die einzige Rolle, wo Sign-Invert greift, aber `effective_value_w` ist immer der UI-fertige Wert"). Frontend liest `effective_value_w` derzeit für Grid-Tile — wenn (b), kann Frontend für alle Tiles auf `effective_value_w` umgestellt werden (DRY). -- [ ] [Review][Decision] **`StateSnapshot.ha_ws_connected` defaults to `True` — Pre-Connect-Window zeigt fälschlich „Verbunden"** [backend/src/solalex/state_cache.py:818] — Code-Kommentar rechtfertigt das mit „Tests / dev without HA-WS supervisor". Aber Production-Risiko: wenn der Reconnect-Hook (main.py-Verkabelung) je bricht, zeigt UI dauerhaft „Verbunden" obwohl gar keine Verbindung besteht — silent failure. Optionen: (a) Default auf `False` umstellen, Test-Fixtures explizit auf `True` setzen wenn nötig; (b) `Optional[bool]` mit `None`=„unbekannt, wir warten auf erste Transition" → Frontend zeigt „Verbindung wird geprüft …"; (c) wie aktuell, plus startup-Lifespan-Hook im `main.py`, der explizit `False` setzt bevor der Reconnect-Loop startet. +- [x] [Review][Decision] **MULTI-Mode Reason-Verhalten** — Resolved (2026-04-25): Option (a) Last-write-wins akzeptieren. Aktuelles Verhalten bleibt — keine Code-Änderung nötig. Bei Speicher-Cap+Drossel-Deadband zeigt UI „Im Toleranzbereich"; das ist das gewollte Verhalten („zeige warum die finale Branch nicht regelte"). +- [x] [Review][Decision] **`_effective_value_w` für non-grid_meter Rolle** — Resolved (2026-04-25): Option (b) Spec an Code anpassen. `effective_value_w` ist UI-fertiger Wert für alle Rollen; nur grid_meter wendet Sign-Invert an. Wird zu Patch P-D3 (Spec-Update AC 14). Eröffnet Folge-Patch: Frontend kann auf `entity.effective_value_w` für alle Tiles umstellen (DRY mit P16, sowie P2 Chart-Konsistenz). +- [x] [Review][Decision] **`StateSnapshot.ha_ws_connected` Default** — Resolved (2026-04-25): Option (a) Default auf `False` umstellen. Test-Fixtures explizit auf `True` setzen wo nötig. Wird zu Patch P-D4. ### Patches +- [ ] [Review][Patch] **P-D3: Spec AC 14 korrigieren — `effective_value_w` ist UI-fertiger Wert für alle Rollen, Sign-Invert nur bei grid_meter** [_bmad-output/implementation-artifacts/5-1d-live-betrieb-klartext-diagnose.md AC 14] — Doc-Patch: Spec-Wortlaut „bei anderen Rollen `None`" durch „raw passthrough für non-grid_meter; nur grid_meter wendet `invert_sign` an" ersetzen. Code bleibt wie ist; Test bleibt wie ist. +- [ ] [Review][Patch] **P-D4: `StateSnapshot.ha_ws_connected` Default auf `False`** [backend/src/solalex/state_cache.py:818] — Init-Wert von `True` → `False` umstellen. Test-Fixtures, die heute den Default nutzen und auf `True` rechnen, explizit auf `True` setzen. Reconnect-Hook flippt beim ersten erfolgreichen Connect ohnehin auf `True` (transition-detection), also keine Race im Production-Pfad. Frontend-Verhalten: in der Pre-Connect-Lücke (typisch < 2 s nach Start) zeigt Tile „Getrennt" statt fälschlich „Verbunden" — semantisch korrekt. - [ ] [Review][Patch] **4 Backend-Reasons fehlen im Frontend `formatCycleStatus`-Mapper — fallen alle in `default → 'Beobachtung'`** [frontend/src/routes/Running.svelte:60-101] — Backend produziert `noop: adapter_unbekannt` (controller.py:644, 1269), `noop: kein_device_id` (controller.py:651, 1259), `noop: pool_alle_offline` (controller.py:1424), `noop: soc_member_inkonsistent` (controller.py:1435). Keine `startsWith`-Branches → User sieht „Beobachtung" für echte Misskonfigurationen. Fix: 4 Mappings ergänzen UND Reason-Vokabular-Tabelle in dieser Story (Zeilen 254-269) erweitern (Single-Source-Vertrag). - [ ] [Review][Patch] **Reason-Vokabular-Tabelle in Story-Spec ergänzen** [_bmad-output/implementation-artifacts/5-1d-live-betrieb-klartext-diagnose.md:254-269] — Tabelle erfasst aktuell nur 11 Reasons; Code erzeugt mindestens 15. Beide Single-Sources (Backend-Emitter + Frontend-Mapper + Spec-Tabelle) müssen synchron sein. - [ ] [Review][Patch] **`update_ha_ws_connection` mutiert `ha_ws_connected` und `ha_ws_disconnected_since` ohne Lock — Race vs. `snapshot()`-Reader** [backend/src/solalex/state_cache.py:822-843] — `snapshot()` hält `self._lock` beim Lesen; `update_ha_ws_connection` ist die einzige neue Methode, die schreibt OHNE `self._lock`. Kurz-Race-Window: Reader sieht `connected=True` mit nicht-`None` `disconnected_since` (oder umgekehrt). Fix: Methode als `async def` (oder mit `with self._lock:` wenn der Lock sync ist) wrap analog zu `update()`. diff --git a/_bmad-output/implementation-artifacts/5-1e-settings-link-im-running-footer.md b/_bmad-output/implementation-artifacts/5-1e-settings-link-im-running-footer.md index 2d18cce..d4bce49 100644 --- a/_bmad-output/implementation-artifacts/5-1e-settings-link-im-running-footer.md +++ b/_bmad-output/implementation-artifacts/5-1e-settings-link-im-running-footer.md @@ -235,7 +235,11 @@ Keine weiteren Files. ### Decision-Needed -- [ ] [Review][Decision] **`--color-text-primary` Token existiert nicht in `app.css` — Hover/Focus-Visible auf `.settings-link` ist sichtbar broken** — Spec AC 3 schreibt: „Hover-Zustand wechselt nur die Farbe auf `var(--color-text-primary)`". Der Token ist aber nirgends definiert (`grep --color-text-primary frontend/src/app.css` → leer). `app.css` definiert nur `--color-text` (Hauptfarbe = `var(--color-brand-ink)`) und `--color-text-secondary`. Resultat: `.settings-link:hover { color: var(--color-text-primary); }` resolved zur CSS-Default „kein Color-Override" — also kein sichtbarer Effekt im Hover. Die Spec selbst schrieb den falschen Token-Namen, der Dev folgte ihr 1:1. Optionen: (a) `var(--color-text)` verwenden (existierende Hauptfarbe — semantisch korrekt für „dunkler werden bei Hover"); (b) neuen Token `--color-text-primary` in `app.css` ergänzen (gleicher Wert wie `--color-text`, aber als Synonym); (c) `var(--color-accent-primary)` (Teal-Akzent — schöner Hover, aber lauter als die UX-Anweisung „dezent" erlaubt). Empfehlung: (a) — Single-Source bleibt `--color-text`, Spec auf den existierenden Token korrigieren. +- [x] [Review][Decision] **`--color-text-primary` Token existiert nicht in `app.css`** — Resolved (2026-04-25): Option (b) — neuen Token `--color-text-primary` in `app.css` als Synonym für `--color-text` ergänzen. Wird zu Patch P-D1 (siehe unten). + +### Patches (resolved Decisions) + +- [ ] [Review][Patch] **`--color-text-primary` Token in `app.css` ergänzen** [frontend/src/app.css:46-47] — Im `:root`-Block direkt nach `--color-text:` neue Zeile `--color-text-primary: var(--color-text);` einfügen. Dadurch wird `.settings-link:hover` (und alle künftigen Konsumenten desselben Tokens) sichtbar. Spec-AC 3 dieser Story bleibt unverändert (Token ist jetzt definiert). ### Patches diff --git a/addon/config.yaml b/addon/config.yaml index 8aeb9b8..9064c3e 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: Solalex by ALKLY -version: "0.1.15" +version: "0.1.17" # Minimum HA Core version — inkompatible Versionen erhalten Install-Warning im Add-on-Store. homeassistant: "2026.4.0" slug: solalex diff --git a/backend/src/solalex/api/routes/control.py b/backend/src/solalex/api/routes/control.py index 5eafb71..54a91d6 100644 --- a/backend/src/solalex/api/routes/control.py +++ b/backend/src/solalex/api/routes/control.py @@ -172,7 +172,7 @@ async def get_control_mode(request: Request) -> ControlModeResponse: controller = _require_controller(request) return ControlModeResponse( forced_mode=cast("ForcedMode | None", _mode_or_none(controller.forced_mode)), - active_mode=cast("ForcedMode", controller.current_mode.value), + active_mode=controller.current_mode.value, baseline_mode=cast( "Literal['drossel','speicher','multi']", controller.mode_baseline.value ), @@ -213,7 +213,9 @@ async def put_control_mode( return ControlModeResponse( forced_mode=cast("ForcedMode | None", _mode_or_none(controller.forced_mode)), active_mode=controller.current_mode.value, - baseline_mode=controller.mode_baseline.value, + baseline_mode=cast( + "Literal['drossel','speicher','multi']", controller.mode_baseline.value + ), ) diff --git a/backend/tests/unit/test_api_devices_patch_config.py b/backend/tests/unit/test_api_devices_patch_config.py index cfdd973..4a62057 100644 --- a/backend/tests/unit/test_api_devices_patch_config.py +++ b/backend/tests/unit/test_api_devices_patch_config.py @@ -69,7 +69,9 @@ def _read_config(device_id: int) -> dict[str, object]: finally: conn.close() assert row is not None - return json.loads(row[0]) + parsed = json.loads(row[0]) + assert isinstance(parsed, dict) + return parsed # ----- AC 19: Surplus ohne Max-Limit → 422 -------------------------------- diff --git a/backend/tests/unit/test_controller_multi_export.py b/backend/tests/unit/test_controller_multi_export.py index 00ac69a..da1739d 100644 --- a/backend/tests/unit/test_controller_multi_export.py +++ b/backend/tests/unit/test_controller_multi_export.py @@ -146,8 +146,8 @@ def _build_controller( def _drive_until_decision( controller: Controller, device: DeviceRecord, sample: float, repeats: int = 6 -) -> list: - last: list = [] +) -> list[Any]: + last: list[Any] = [] for _ in range(repeats): last = controller._policy_multi(device, sensor_value_w=sample) if last: @@ -197,22 +197,39 @@ async def test_multi_cap_branch_calls_drossel_when_toggle_off( assert decisions[0].command_kind == "set_limit" -# ----- AC 10: Cap-Branch + Bezug → [] ------------------------------------ +# ----- AC 10: Cap-Branch + kein Feed-in → kein Export-Aufruf ------------- @pytest.mark.asyncio async def test_multi_cap_branch_no_decision_on_load_when_pool_full( - tmp_path: Path, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Bezug bei vollem Akku ist kein Surplus-Export-Szenario — Pool entlädt - sich auf der nächsten Tick-Runde, Cap-Branch erzeugt kein Drossel UND - kein Export. + """Bezug-Symmetrie: Cap-Branch ruft weder Drossel noch Export, wenn kein + Feed-in vorliegt. + + Spiegelt das Stale-Cap-Flag-Pattern aus dem 3.5-Test + ``test_multi_max_soc_small_feed_in_inside_deadband_skips_drossel``: Cap- + Flag steht aus einem früheren Tick, die aktuelle geglättete Stichprobe + liegt im Deadband. Speicher liefert deshalb [], der Cap-Branch sieht + kein Feed-in → MULTI returns []. Der Export-Spy pinnt die zentrale + AC-10-Forderung: EXPORT wird nicht aufgerufen, auch nicht bei aktivem + Toggle. """ db = tmp_path / "test.db" seeds = await _seed_multi_setup(db) cache = StateCache() controller = _build_controller(db, seeds, cache, soc_pct=98.0) - decisions = _drive_until_decision( - controller, seeds["grid_meter"], sample=200.0 - ) + controller._speicher_max_soc_capped = True + + export_calls = [0] + real_export = controller._policy_export + + def _export_spy(device: DeviceRecord, sensor_value_w: float | None) -> Any: + export_calls[0] += 1 + return real_export(device, sensor_value_w) + + monkeypatch.setattr(controller, "_policy_export", _export_spy) + + decisions = controller._policy_multi(seeds["grid_meter"], sensor_value_w=10.0) assert decisions == [] + assert export_calls[0] == 0 diff --git a/backend/tests/unit/test_controller_policy_export.py b/backend/tests/unit/test_controller_policy_export.py index 316dc8a..e3c2b3b 100644 --- a/backend/tests/unit/test_controller_policy_export.py +++ b/backend/tests/unit/test_controller_policy_export.py @@ -5,7 +5,7 @@ import logging from datetime import UTC, datetime from pathlib import Path -from typing import cast +from typing import Any, cast import pytest @@ -29,7 +29,7 @@ async def _seed_export_setup( *, wr_limit_config: str = '{"max_limit_w": 600, "allow_surplus_export": true}', with_wr_limit: bool = True, -) -> dict[str, DeviceRecord | list[DeviceRecord]]: +) -> dict[str, Any]: await run_migration(db) async with connection_context(db) as conn: await upsert_device( @@ -71,17 +71,16 @@ async def _seed_export_setup( def _build_controller( db: Path, - seeds: dict[str, DeviceRecord | list[DeviceRecord]], + seeds: dict[str, Any], cache: StateCache, *, current_wr_limit_w: int | None = 200, ) -> Controller: devices_by_role: dict[str, DeviceRecord] = { - "grid_meter": cast(DeviceRecord, seeds["grid_meter"]), + "grid_meter": seeds["grid_meter"], } wr_limit = seeds["wr_limit"] if wr_limit is not None: - wr_limit = cast(DeviceRecord, wr_limit) devices_by_role["wr_limit"] = wr_limit if current_wr_limit_w is not None: cache.last_states[wr_limit.entity_id] = HaStateEntry( diff --git a/backend/tests/unit/test_migrate.py b/backend/tests/unit/test_migrate.py index a469c0c..4dd3518 100644 --- a/backend/tests/unit/test_migrate.py +++ b/backend/tests/unit/test_migrate.py @@ -66,6 +66,10 @@ async def test_devices_table_schema(tmp_path: Path) -> None: async def test_adapter_key_rename_migration(tmp_path: Path) -> None: db = tmp_path / "test.db" async with aiosqlite.connect(str(db)) as conn: + # Pre-seed the schema as if migrations 001+002 had already run so + # that 003 (this test's subject) and 004 (Story 3.8 — needs the + # control_cycles + latency_measurements tables to copy rows from) + # both have something to apply. await conn.executescript( """ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); @@ -83,6 +87,32 @@ async def test_adapter_key_rename_migration(tmp_path: Path) -> None: updated_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(entity_id, role) ); + CREATE TABLE control_cycles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TIMESTAMP NOT NULL, + device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + mode TEXT NOT NULL CHECK (mode IN ('drossel','speicher','multi')), + source TEXT NOT NULL CHECK (source IN ('solalex','manual','ha_automation')), + sensor_value_w REAL, + target_value_w INTEGER, + readback_status TEXT CHECK (readback_status IN ('passed','failed','timeout','vetoed','noop')), + readback_actual_w REAL, + readback_mismatch INTEGER NOT NULL DEFAULT 0, + latency_ms INTEGER, + cycle_duration_ms INTEGER NOT NULL, + reason TEXT + ); + CREATE INDEX idx_control_cycles_ts ON control_cycles(ts DESC); + CREATE INDEX idx_control_cycles_device ON control_cycles(device_id); + CREATE TABLE latency_measurements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + command_at TIMESTAMP NOT NULL, + effect_at TIMESTAMP NOT NULL, + latency_ms INTEGER NOT NULL, + mode TEXT NOT NULL CHECK (mode IN ('drossel','speicher','multi')) + ); + CREATE INDEX idx_latency_device_ts ON latency_measurements(device_id, command_at DESC); """ ) await conn.execute( diff --git a/frontend/src/lib/api/client.test.ts b/frontend/src/lib/api/client.test.ts index 0659fa3..3c1ccc5 100644 --- a/frontend/src/lib/api/client.test.ts +++ b/frontend/src/lib/api/client.test.ts @@ -1,7 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { isApiError } from './errors.js'; -import { getEntities, getEntityState, saveDevices, updateDevices } from './client.js'; -import type { EntitiesResponse, EntityState } from './types.js'; +import { + getEntities, + getEntityState, + saveDevices, + setSurplusExport, + updateDevices, +} from './client.js'; +import type { DeviceResponse, EntitiesResponse, EntityState } from './types.js'; const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); @@ -191,6 +197,79 @@ describe('updateDevices', () => { }); }); +describe('setSurplusExport (Story 3.8)', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('sends PATCH /api/v1/devices/{id}/config with allow_surplus_export', async () => { + const updated: DeviceResponse = { + id: 7, + type: 'generic', + role: 'wr_limit', + entity_id: 'number.opendtu_limit', + adapter_key: 'generic', + config_json: '{"allow_surplus_export": true, "max_limit_w": 600}', + last_write_at: null, + commissioned_at: '2026-04-25T12:00:00Z', + created_at: '2026-04-25T11:00:00Z', + updated_at: '2026-04-25T12:30:00Z', + }; + mockFetch.mockResolvedValue(okResponse(updated)); + + const result = await setSurplusExport(7, true); + + expect(result.id).toBe(7); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/api/v1/devices/7/config'); + expect(init.method).toBe('PATCH'); + expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' }); + const body = JSON.parse(init.body as string) as Record; + expect(body).toEqual({ allow_surplus_export: true }); + }); + + it('returns the parsed DeviceResponse on 200', async () => { + const updated: DeviceResponse = { + id: 9, + type: 'generic', + role: 'wr_limit', + entity_id: 'number.opendtu_limit', + adapter_key: 'generic', + config_json: '{"allow_surplus_export": false}', + last_write_at: null, + commissioned_at: null, + created_at: '2026-04-25T11:00:00Z', + updated_at: '2026-04-25T12:30:00Z', + }; + mockFetch.mockResolvedValue(okResponse(updated)); + + const result = await setSurplusExport(9, false); + expect(result.config_json).toContain('allow_surplus_export'); + expect(JSON.parse(result.config_json)).toEqual({ allow_surplus_export: false }); + }); + + it('throws ApiError with German detail on 422 missing max_limit_w', async () => { + mockFetch.mockResolvedValue( + errResponse(422, { + type: 'urn:solalex:validation-error', + title: 'Validierungsfehler', + detail: + 'Surplus-Einspeisung erfordert ein konfiguriertes Hardware-Max-Limit (max_limit_w).', + }), + ); + + try { + await setSurplusExport(3, true); + } catch (err) { + expect(isApiError(err)).toBe(true); + if (isApiError(err)) { + expect(err.status).toBe(422); + expect(err.detail).toContain('max_limit_w'); + } + } + }); +}); + describe('getEntityState', () => { beforeEach(() => { mockFetch.mockReset(); diff --git a/frontend/src/routes/Running.test.ts b/frontend/src/routes/Running.test.ts index cd4a150..8f10f4e 100644 --- a/frontend/src/routes/Running.test.ts +++ b/frontend/src/routes/Running.test.ts @@ -638,6 +638,18 @@ describe('Running — Live-Betriebs-View', () => { expect(expl.textContent ?? '').toContain('Idle — wartet auf erstes Sensor-Event'); }); + // Story 3.8 — mode-label mapping for the new EXPORT mode (AC 25). + it('displays the Einspeisung label when the current mode is export', async () => { + getDevicesMock.mockResolvedValue([device({ id: 1, role: 'wr_limit' })]); + getStateSnapshotMock.mockResolvedValue(snapshot({ current_mode: 'export' })); + + render(Running); + await flushPolling(); + + const expl = await screen.findByTestId('mode-explanation'); + expect(expl.textContent ?? '').toContain('Einspeisung'); + }); + // AC 12 — Status-Tile-Reihe oberhalb des Charts. it('renders the status-tile row with grid + WR + connection tiles', async () => { getDevicesMock.mockResolvedValue([