Files
EOS/tests/test_eosdashsettings.py
T
527c26d424 feat(eosdash): plan page with EMS plan timeline and persisted settings (#1460)
* feat(eosdash): plan page with EMS plan timeline and persisted settings

Fixes:
- SolutionCard no longer appends to the module-level solution_excludes
  list on every render (the list grew without limit).
- All EOSdash pages: keep the scroll position after htmx actions instead
  of jumping to the bottom; Bokeh() reserves the plot height.
- Default visible PV column pvforecast_power_w -> pvforecast_ac_energy_wh
  (the old column no longer exists).
- All EOSdash pages: remove w-screen from components.Page (horizontal
  scrollbar).
- Solution chart: energy is the main axis; the always empty power axis is
  only shown with power columns. Typo "Electricty" fixed.

Features:
- EMS plan timeline per device, plan table as collapsible section.
- Solution chart rework: KPI row, legend, totals view, value axes for the
  displayed columns only, units menu, currency from the timezone, aligned
  time axes with the timeline, themed tooltips, system font.
- Per-column and per-command settings: visibility, alias, color, style;
  device aliases.
- New config section eosdash.plan (server/dash/dashsettings.py) stores the
  plan page settings in the EOS configuration; docs regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: SchaafAlexander <86607129+SchaafAlexander@users.noreply.github.com>
2026-10-30 08:03:19 +01:00

201 lines
8.0 KiB
Python

"""Tests for the EOSdash settings in the EOS configuration (eosdash/plan)."""
from unittest.mock import MagicMock, patch
import pytest
from pydantic import ValidationError
import akkudoktoreos.server.dash.eosstatus as eosstatus
from akkudoktoreos.config.config import SettingsEOS
from akkudoktoreos.server.dash import plan
from akkudoktoreos.server.dash.dashsettings import (
EOSdashCommonSettings,
EOSdashPlanSettings,
)
SERVER = "http://127.0.0.1:8503"
class TestEOSdashPlanSettings:
def test_defaults(self):
settings = EOSdashPlanSettings()
assert settings.visible == {}
assert settings.color == {}
assert settings.device_alias == {}
assert settings.plan_table_open is False
assert settings.energy_kwh is True
assert settings.factor_percent is True
assert settings.price_minor_unit is True
assert EOSdashCommonSettings().plan.model_dump() == settings.model_dump()
def test_valid_values(self):
settings = EOSdashPlanSettings(
color={"battery1_soc_factor": "#DAA520"},
style={"pvforecast_ac_energy_wh": "bar"},
width={"loadforecast_energy_wh": 5},
line_dash={"mode_SELF_CONSUMPTION": "dotted"},
alias={"mode_SELF_CONSUMPTION": " Self consumption "},
)
assert settings.alias["mode_SELF_CONSUMPTION"] == "Self consumption"
@pytest.mark.parametrize(
"field, value",
[
("color", {"col": "red"}),
("color", {"col": "#12345"}),
("style", {"col": "area"}),
("width", {"col": 0}),
("width", {"col": 6}),
("line_dash", {"col": "dashdot"}),
("alias", {"col": ""}),
("alias", {"col": "x" * 33}),
("device_alias", {"battery1": " "}),
],
)
def test_invalid_values(self, field, value):
with pytest.raises(ValidationError):
EOSdashPlanSettings(**{field: value})
class TestEOSdashConfig:
def test_config_has_eosdash_section(self, config_eos):
assert config_eos.eosdash.plan.model_dump() == EOSdashPlanSettings().model_dump()
def test_set_nested_value(self, config_eos):
config_eos.set_nested_value("eosdash/plan/color", {"battery1_soc_factor": "#FF0000"})
config_eos.set_nested_value("eosdash/plan/price_minor_unit", False)
assert config_eos.eosdash.plan.color == {"battery1_soc_factor": "#FF0000"}
assert config_eos.eosdash.plan.price_minor_unit is False
with pytest.raises(ValueError):
config_eos.set_nested_value("eosdash/plan/color", {"battery1_soc_factor": "red"})
def test_merge_settings_from_dict(self, config_eos):
config_eos.merge_settings_from_dict(
{"eosdash": {"plan": {"device_alias": {"battery1": "House battery"}}}}
)
assert config_eos.eosdash.plan.device_alias == {"battery1": "House battery"}
# Other plan settings keep their defaults
assert config_eos.eosdash.plan.energy_kwh is True
@pytest.fixture
def eosdash_config():
"""EOSdash copy of the EOS configuration, restored after the test."""
saved = eosstatus.eos_config
eosstatus.eos_config = SettingsEOS(
eosdash=EOSdashCommonSettings(
plan=EOSdashPlanSettings(
color={"battery1_soc_factor": "#00FF00"},
alias={"mode_IDLE": "Idle"},
device_alias={"battery1": "House battery"},
price_minor_unit=False,
)
)
)
yield eosstatus.eos_config
eosstatus.eos_config = saved
def put_response(config: SettingsEOS) -> MagicMock:
"""Response of EOS to a configuration update."""
response = MagicMock()
response.json.return_value = config.model_dump(mode="json")
return response
class TestPlanSettings:
def test_defaults_without_config(self):
saved = eosstatus.eos_config
eosstatus.eos_config = None
try:
assert plan.plan_settings().model_dump() == EOSdashPlanSettings().model_dump()
assert plan.plan_option("price_minor_unit") is True
finally:
eosstatus.eos_config = saved
def test_settings_from_config(self, eosdash_config):
assert plan.solution_column_color("battery1_soc_factor") == "#00FF00"
assert plan.plan_mode_label("IDLE") == "Idle"
assert plan.device_alias("battery1") == "House battery"
assert plan.device_alias("ev11") == "ev11"
assert plan.column_label("battery1_soc_factor", ["battery1"]) == "House battery soc_factor"
assert plan.plan_option("price_minor_unit") is False
assert plan.plan_option("energy_kwh") is True
def test_put_replaces_config(self, eosdash_config):
updated = eosdash_config.model_copy(deep=True)
updated.eosdash.plan.price_minor_unit = True
with patch.object(plan.requests, "put", return_value=put_response(updated)) as put:
plan.plan_settings_put(SERVER, "price_minor_unit", True)
put.assert_called_once_with(
f"{SERVER}/v1/config/eosdash/plan/price_minor_unit", json=True, timeout=10
)
assert plan.plan_option("price_minor_unit") is True
def test_put_failure_keeps_config(self, eosdash_config):
with patch.object(plan.requests, "put", side_effect=OSError("no connection")):
plan.plan_settings_put(SERVER, "price_minor_unit", True)
assert plan.plan_option("price_minor_unit") is False
@pytest.mark.parametrize(
"data, key, value",
[
(
{"action": "color", "renderer": "load", "color": "#abcdef"},
"color",
{"battery1_soc_factor": "#00FF00", "load": "#ABCDEF"},
),
({"action": "reset", "renderer": "battery1_soc_factor"}, "color", {}),
({"action": "visible", "renderer": "load", "load-visible": "true"}, "visible", {"load": True}),
({"action": "visible", "renderer": "load"}, "visible", {"load": False}),
({"action": "style", "renderer": "load", "style": "bar"}, "style", {"load": "bar"}),
({"action": "width", "renderer": "load", "width": "3"}, "width", {"load": 3}),
({"action": "dash", "renderer": "load", "dash": "dotted"}, "line_dash", {"load": "dotted"}),
(
{"action": "alias", "renderer": "load", "alias": " House load\x00 "},
"alias",
{"mode_IDLE": "Idle", "load": "House load"},
),
({"action": "alias", "renderer": "mode_IDLE", "alias": ""}, "alias", {}),
],
)
def test_solution_settings_action(self, eosdash_config, data, key, value):
with patch.object(plan, "plan_settings_put") as put:
plan.solution_settings_action(SERVER, data)
put.assert_called_once_with(SERVER, key, value)
@pytest.mark.parametrize(
"data",
[
{"action": "color", "renderer": "load", "color": "red"},
{"action": "style", "renderer": "load", "style": "area"},
{"action": "width", "renderer": "load", "width": "9"},
{"action": "dash", "renderer": "load", "dash": "dashdot"},
{"action": "color", "color": "#ABCDEF"},
],
)
def test_solution_settings_action_invalid(self, eosdash_config, data):
with patch.object(plan, "plan_settings_put") as put:
plan.solution_settings_action(SERVER, data)
put.assert_not_called()
class TestCurrencyMinorUnit:
@pytest.mark.parametrize(
"currency, expected",
[
("EUR", ("ct", 100)),
("CHF", ("Rp.", 100)),
("SEK", ("1/100 SEK", 100)),
("KWD", ("1/1000 KWD", 1000)),
("JPY", None),
(None, None),
],
)
def test_currency_minor_unit(self, currency, expected):
assert plan.currency_minor_unit(currency) == expected
def test_config_currency(self):
config = SettingsEOS.model_validate({"general": {"timezone_override": "Europe/Zurich"}})
assert plan.config_currency(config) == "CHF"