mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7554e2dc47
commit
527c26d424
@@ -0,0 +1,200 @@
|
||||
"""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"
|
||||
+1
@@ -9,6 +9,7 @@
|
||||
../_generated/configelecfee.md
|
||||
../_generated/configelecprice.md
|
||||
../_generated/configems.md
|
||||
../_generated/configeosdash.md
|
||||
../_generated/configfeedintariff.md
|
||||
../_generated/configgeneral.md
|
||||
../_generated/configload.md
|
||||
|
||||
Reference in New Issue
Block a user