Files
app/backend/tests/flow/test_dashboards.py
T
stroblmeandClaude Fable 5 895bd89b2b Dashboards as documents, and messages as something to bind to
A dashboard is its own document rather than widgets placed in a flow.
Node-RED's dashboard tab is 260 nodes, about forty of them pure layout,
which is exactly what the small-graph principle exists to avoid — and
since the graph is already wired by message name, a widget can bind to a
name without belonging to any flow.

Stored beside the flows in the same repository, sharing their write lock
and commit, under a directory the flow listing ignores. No draft/publish
split: nothing executes a dashboard, so edit mode is its own staging area.

Two things it needs from the engine. A message catalog spanning every
flow, because a wall panel shows the heating next to the solar and the
flow-scoped API is the wrong shape for that. And a way to put a value in
without owning a node — a slider is a real value that happened to come
from a person — which runs whatever consumes it and applies the same type
check a node's output gets. Only a message some flow declares can be
published to; flows own the namespace.

Charts also need more past than the 120 points a sparkline wanted, so a
chart widget declares its depth and the engine keeps that message's
series that deep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 09:20:49 +02:00

162 lines
4.4 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_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() == {}