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>
This commit is contained in:
2026-08-17 15:06:45 +02:00
co-authored by Claude Opus 5
parent 18837e8880
commit 413501c6ce
16 changed files with 751 additions and 43 deletions
+33
View File
@@ -197,3 +197,36 @@ def test_alerting_can_be_switched_off():
def test_every_alerting_event_reads_as_a_sentence(event, expected):
alert = describe(event)
assert (alert.title if alert else None) == expected
def test_a_dashboard_channel_publishes_the_alert_as_a_record():
published: list[tuple[str, dict]] = []
config = AlertsConfig(
channels=[
Channel(name="panel", kind="dashboard", config={"message": "house.notice"})
],
rules=[Rule(events=[], channels=["panel"])],
)
alerts = AlertManager(EventBus(), config=config, now=Clock())
alerts.publish = lambda name, value: published.append((name, value))
asyncio.run(alerts.handle(error_event()))
assert len(published) == 1
name, record = published[0]
assert name == "house.notice"
# Flat named scalars, which is what a notification widget binds.
assert record["title"] == "heating.pump failed"
assert record["severity"] == "error"
assert all(isinstance(v, str) for v in record.values())
def test_a_dashboard_channel_without_a_message_says_so():
alerts = AlertManager(EventBus(), now=Clock())
alerts.publish = lambda name, value: None
channel = Channel(name="panel", kind="dashboard")
with pytest.raises(ValueError):
asyncio.run(
alerts.send(channel, Alert(title="t", body="b"), raise_on_error=True)
)
+61
View File
@@ -187,3 +187,64 @@ def test_publishing_nothing_does_nothing():
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)
+61
View File
@@ -36,6 +36,67 @@ def test_json_dtype_round_trips():
assert json.loads(json.dumps(value)) == value
def test_record_takes_flat_scalars_only():
spec = MessageSpec(name="notice", dtype=DType.RECORD)
spec.check({"title": "Boiler", "count": 3, "hot": True, "detail": None})
for wrong in ({"a": {"nested": 1}}, {"a": [1]}, [], "x"):
with pytest.raises(TypeError):
spec.check(wrong)
def test_series_carries_lines_and_whatever_else_the_answer_echoes():
spec = MessageSpec(name="temps", dtype=DType.SERIES)
spec.check(
{
"range_s": 3600,
"interval_s": 60,
"lines": [{"label": "living", "points": [[1.0, 21.5], [2.0, 21.6]]}],
}
)
spec.check({"lines": []})
for wrong in (
{"lines": [{"label": "a", "points": [[1.0, 2.0, 3.0]]}]},
{"lines": [{"label": "a", "points": [["1", 2.0]]}]},
{"lines": [{"label": 1, "points": []}]},
{"lines": {}},
{},
):
with pytest.raises(TypeError):
spec.check(wrong)
def test_list_items_follow_the_declared_shape():
records = MessageSpec(name="agenda", dtype=DType.LIST)
records.check([{"title": "Dentist", "ts": 1.0}])
records.check([])
with pytest.raises(TypeError):
records.check([1.0])
numbers = MessageSpec(name="window", dtype=DType.LIST, item=DType.FLOAT)
numbers.check([21.4, 2])
for wrong in ([True], ["1"], 1.0):
with pytest.raises(TypeError):
numbers.check(wrong)
def test_the_failing_item_is_named():
spec = MessageSpec(name="window", dtype=DType.LIST, item=DType.FLOAT)
with pytest.raises(TypeError, match="index 1"):
spec.check([1.0, "x"])
def test_a_list_holds_one_declared_level():
for item in (DType.LIST, DType.SERIES):
with pytest.raises(ValueError):
MessageSpec(name="x", dtype=DType.LIST, item=item)
def test_coerce_parses_structured_text():
spec = MessageSpec(name="notice", dtype=DType.RECORD)
assert spec.coerce('{"title": "Boiler"}') == {"title": "Boiler"}
assert spec.coerce({"title": "Boiler"}) == {"title": "Boiler"}
def test_coerce_from_text():
assert MessageSpec(name="a", dtype=DType.FLOAT).coerce("2.5") == 2.5
assert MessageSpec(name="a", dtype=DType.INT).coerce("7") == 7
+57
View File
@@ -133,3 +133,60 @@ def test_every_offered_type_has_a_fixture():
if info.type != "python" and not info.plugin
}
assert offered == set(FIXTURES)
def test_a_flux_request_is_run_rather_than_written(monkeypatch):
"""A query passing through is not a point to store."""
from app.flow.nodes import InfluxDbNode
node = InfluxDbNode(
requires=[
MessageSpec(name="query", dtype=DType.RECORD),
MessageSpec(name="temp", dtype=DType.FLOAT),
],
provides=[MessageSpec(name="answer", dtype=DType.JSON)],
params={"url": "http://influx", "token": "t", "org": "o", "bucket": "b"},
)
# The class, not the instance: the node types use __slots__.
written: list[dict] = []
monkeypatch.setattr(
InfluxDbNode, "_write_points", lambda self, data: written.append(data)
)
monkeypatch.setattr(
InfluxDbNode,
"_run_flux",
lambda self, request: {
"rows": [],
**{key: value for key, value in request.items() if key != "flux"},
},
)
out = node.execute(
{
"query": {"flux": 'from(bucket: "b")', "range_s": 3600},
"temp": 21.5,
}
)
# The reading is stored, the query is not.
assert written == [{"temp": 21.5}]
# The answer carries back what the caller asked for.
assert out == {"answer": {"rows": [], "range_s": 3600}}
def test_a_falsy_return_is_a_mistake_not_silence():
"""Only None means "nothing to publish"."""
from app.flow.nodes import Node
from app.flow.nodes.base import NodeOutputError
def make(retval):
return Node(
f=lambda params: retval,
provides=[MessageSpec(name="out", dtype=DType.FLOAT)],
)
assert make(None).execute({}) is None
assert make({}).execute({}) is None
for wrong in ([], 0, ""):
with pytest.raises(NodeOutputError):
make(wrong).execute({})