Files
app/backend/tests/flow/test_dashboards.py
T
stroblmeandClaude Opus 5 413501c6ce flow: structured dtypes, and the widgets that read them
A series, record or list message declares its shape instead of riding
DType.JSON, so a widget binds a shape rather than some JSON and a wrong
binding is refused before anything runs. A list declares its item type,
which is what keeps list[float] expressible for a pipeline.

On top of that: an agenda over a list, a notification over a record, and
a dashboard alert channel that publishes engine faults as one — so a
panel can show what went wrong without a flow wiring it by hand.

Also: only None means a node published nothing, a falsy value of the
wrong shape is now the named error it always should have been; and the
gauge's readout says its size is viewBox geometry rather than type scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:06:45 +02:00

251 lines
7.5 KiB
Python

"""Dashboards: documents beside the flows, and the values their widgets move."""
import pytest
from app.flow.dashboards import (
DashboardDef,
DashboardNotFound,
DashboardStore,
PageDef,
SectionDef,
WidgetDef,
default_dashboard,
)
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import Node
from app.flow.pipeline import Pipeline
from app.flow.state import MemoryState
from app.flow.store import FlowStore, StaleVersion
@pytest.fixture
def store(tmp_path) -> DashboardStore:
return DashboardStore(FlowStore(tmp_path / "flows"))
def chart(message: str, points: int) -> WidgetDef:
return WidgetDef(
id="temp",
type="chart",
config={"series": [{"message": message}], "history": {"points": points}},
)
def test_a_dashboard_survives_a_round_trip(store: DashboardStore):
saved = store.write(default_dashboard("house"))
read = store.read("house")
assert read.name == "house"
assert [p.id for p in read.pages] == ["main"]
assert read.version == saved.version
def test_dashboards_are_invisible_to_the_flow_listing(store: DashboardStore):
"""They share the repository; they are not flows."""
store.write(default_dashboard("house"))
assert store.flows.list_flows() == []
assert [d.name for d in store.list()] == ["house"]
def test_a_save_based_on_a_version_someone_moved_past_is_refused(
store: DashboardStore,
):
first = store.write(default_dashboard("house"))
store.write(first, first.version)
with pytest.raises(StaleVersion):
store.write(first, first.version)
def test_an_edit_reaches_a_panel_only_once_it_is_published(store: DashboardStore):
"""A wall panel reads the published file; the editor writes beside it."""
published = store.write(default_dashboard("house"))
draft = store.write_draft(
published.model_copy(update={"title": "Kitchen"}), published.version
)
assert store.has_draft("house")
assert store.read("house").title == published.title
assert store.read("house", draft=True).title == "Kitchen"
store.publish("house", draft.version)
assert store.read("house").title == "Kitchen"
assert not store.has_draft("house")
def test_discarding_leaves_what_is_published(store: DashboardStore):
published = store.write(default_dashboard("house"))
store.write_draft(
published.model_copy(update={"title": "Kitchen"}), published.version
)
assert store.discard_draft("house").title == published.title
assert not store.has_draft("house")
def test_deleting_and_renaming(store: DashboardStore):
store.write(default_dashboard("house"))
renamed = store.rename("house", "home")
assert renamed.name == "home"
assert not store.exists("house")
store.delete("home")
with pytest.raises(DashboardNotFound):
store.read("home")
def test_the_deepest_chart_decides_how_much_past_is_kept(store: DashboardStore):
store.write(
DashboardDef(
name="house",
pages=[
PageDef(
id="main",
sections=[
SectionDef(id="a", widgets=[chart("heating.temp", 400)]),
SectionDef(
id="b",
widgets=[
chart("heating.temp", 900),
chart("solar.watts", 100),
],
),
],
)
],
)
)
assert store.history_requirements() == {"heating.temp": 900, "solar.watts": 100}
def test_a_chart_cannot_ask_for_an_unbounded_series(store: DashboardStore):
store.write(
DashboardDef(
name="house",
pages=[
PageDef(
id="main",
sections=[
SectionDef(id="a", widgets=[chart("heating.temp", 10**9)])
],
)
],
)
)
assert store.history_requirements() == {"heating.temp": 5000}
def test_history_is_kept_to_the_depth_a_chart_asked_for():
state = MemoryState()
limits = {"f.temp": 300}
for i in range(400):
state.append_history({"f.temp": float(i)}, float(i), limits)
assert len(state.history("f.temp")) == 300
# ---------------------------------------------------------------------------
# What an input widget does
# ---------------------------------------------------------------------------
def test_publishing_a_value_runs_what_consumes_it():
"""A slider is a value arriving; the graph should not care who sent it."""
seen: list[float] = []
def consume(setpoint, params):
seen.append(setpoint)
return {"applied": setpoint}
node = Node(
f=consume,
requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)],
provides=[MessageSpec(name="applied", port="applied", dtype=DType.FLOAT)],
name="thermostat",
)
node.assign_flow("heating", "thermostat")
state = MemoryState()
pipeline = Pipeline(nodes=[node], state=state)
pipeline.publish({"heating.setpoint": 21.5})
assert seen == [21.5]
assert state["heating.applied"] == 21.5
def test_publishing_nothing_does_nothing():
pipeline = Pipeline(nodes=[], state=MemoryState())
pipeline.publish({})
assert pipeline.values() == {}
def query_chart(**config) -> dict:
return {
"source": "query",
"request": "heating.chart_req",
"request_dtype": "record",
"message": "heating.chart_series",
"dtype": "series",
"refresh_s": 30,
**config,
}
def test_a_widget_refuses_a_dtype_it_cannot_carry():
WidgetDef(id="s", type="switch", config={"message": "a.b", "dtype": "bool"})
# A document written before the editor recorded types binds anything.
WidgetDef(id="s", type="switch", config={"message": "a.b"})
with pytest.raises(ValueError):
WidgetDef(id="s", type="switch", config={"message": "a.b", "dtype": "float"})
def test_the_structured_widgets_bind_their_shapes():
WidgetDef(id="a", type="agenda", config={"message": "a.b", "dtype": "list"})
WidgetDef(id="n", type="notification", config={"message": "a.b", "dtype": "record"})
with pytest.raises(ValueError):
WidgetDef(id="a", type="agenda", config={"message": "a.b", "dtype": "json"})
with pytest.raises(ValueError):
WidgetDef(
id="n", type="notification", config={"message": "a.b", "dtype": "list"}
)
def test_a_querying_chart_asks_with_a_record_and_draws_a_series():
widget = WidgetDef(id="c", type="chart", config=query_chart())
# It publishes its request and reads the answer, so the canvas draws both.
assert widget.target == "heating.chart_req"
assert widget.messages == ["heating.chart_series"]
with pytest.raises(ValueError):
WidgetDef(id="c", type="chart", config=query_chart(dtype="float"))
with pytest.raises(ValueError):
WidgetDef(id="c", type="chart", config=query_chart(request_dtype="json"))
def test_a_querying_chart_keeps_no_ring():
"""The answer carries its own past; a ring would store it twice."""
widget = WidgetDef(
id="c", type="chart", config=query_chart(history={"points": 900})
)
assert widget.history_points == 0
def test_a_dashboard_is_cut_into_a_sane_number_of_columns():
for columns in (0, 49):
with pytest.raises(ValueError):
DashboardDef(name="house", columns=columns)