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
+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