Makes the flow engine reachable from the API, which is what M3 needs before
any of it can reach the browser.
- app/flow is a package now; the prototype's watch-dir scripts and the
matplotlib/networkx visualiser are gone with their dependencies.
- Messages carry a serializable dtype instead of a live Python type, and a
port name, so the graph can speak qualified names while node functions keep
local arguments. Redis state is JSON, not pickle.
- Message names are namespaced per flow ("heating.temp"); a bare name resolves
to its own flow, a dotted one crosses flows.
- Several nodes may provide the same message: producers are a list, so fan-in
is a real edge instead of a silently dropped one.
- Flows are stored as flow.json plus node sources in a git repository, one
commit per save, with identical saves skipped so autosave stays quiet.
- Node failures are isolated and reported per node; validate() returns cycles
and unconnected inputs instead of raising deep in a run.
- Credentials live in an encrypted store and are referenced as {"$secret": …}.
- Engine events reach websocket clients through a bus, so values, node status
and execution show up live.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
56 lines
1.7 KiB
Python
56 lines
1.7 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from app.flow.messages import DType, MessageSpec, flow_of, qualify
|
|
|
|
|
|
def test_port_defaults_to_last_name_segment():
|
|
assert MessageSpec(name="temperature").port == "temperature"
|
|
assert MessageSpec(name="heating.temperature").port == "temperature"
|
|
assert MessageSpec(name="heating.temperature", port="t").port == "t"
|
|
|
|
|
|
def test_int_rejects_bool():
|
|
# bool is a subclass of int, but a flag is not a number here.
|
|
spec = MessageSpec(name="count", dtype=DType.INT)
|
|
spec.check(3)
|
|
with pytest.raises(TypeError):
|
|
spec.check(True)
|
|
|
|
|
|
def test_float_accepts_int_but_not_bool():
|
|
spec = MessageSpec(name="temp", dtype=DType.FLOAT)
|
|
spec.check(21)
|
|
spec.check(21.5)
|
|
with pytest.raises(TypeError):
|
|
spec.check(True)
|
|
with pytest.raises(TypeError):
|
|
spec.check("21")
|
|
|
|
|
|
def test_json_dtype_round_trips():
|
|
spec = MessageSpec(name="payload", dtype=DType.JSON)
|
|
value = {"a": [1, 2], "b": None}
|
|
spec.check(value)
|
|
assert json.loads(json.dumps(value)) == value
|
|
|
|
|
|
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
|
|
assert MessageSpec(name="a", dtype=DType.BOOL).coerce("yes") is True
|
|
assert MessageSpec(name="a", dtype=DType.BOOL).coerce("0") is False
|
|
|
|
|
|
def test_spec_serializes():
|
|
spec = MessageSpec(name="heating.temp", dtype=DType.FLOAT)
|
|
assert MessageSpec.model_validate_json(spec.model_dump_json()) == spec
|
|
|
|
|
|
def test_qualify_scopes_bare_names_only():
|
|
assert qualify("heating", "temp") == "heating.temp"
|
|
assert qualify("heating", "solar.power") == "solar.power"
|
|
assert qualify("heating", "") == ""
|
|
assert flow_of("heating.temp") == "heating"
|