Files
app/backend/tests/flow/test_vocabulary.py
T
stroblmeandClaude Opus 5 60d7ec81c0 Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a
user's venv, so the package that is about to be published takes the name
it is published under. Only the Python package moves; the repo, the
Docker WORKDIR and the compose project keep theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:48:05 +02:00

264 lines
7.4 KiB
Python

"""What the flow-logic nodes actually do.
These are the shapes a Node-RED installation is mostly made of, so their
behaviour is worth pinning rather than just their construction.
"""
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import (
ChangeNode,
ExecNode,
FileNode,
JoinNode,
RbeNode,
SwitchNode,
)
from fluksio.flow.pipeline import Pipeline
from fluksio.flow.state import MemoryState
def spec(name: str, dtype: DType = DType.FLOAT) -> MessageSpec:
return MessageSpec(name=name, port=name, dtype=dtype)
def place(node, flow: str = "f"):
"""Give a node a flow and a pipeline, as the controller would."""
node.assign_flow(flow, node.name)
Pipeline(nodes=[node], state=MemoryState())
return node
# ---------------------------------------------------------------------------
# Switch
# ---------------------------------------------------------------------------
def switch(**params) -> SwitchNode:
node = SwitchNode(
requires=[spec("temp")],
provides=[spec("hot"), spec("cold")],
params=params,
name="switch",
)
return place(node)
def test_a_value_leaves_through_the_branch_whose_rule_it_matches():
node = switch(
rules=[
{"port": "hot", "op": "gt", "value": 20},
{"port": "cold", "op": "lte", "value": 20},
]
)
assert node.execute({"f.temp": 25.0}) == {"f.hot": 25.0}
assert node.execute({"f.temp": 15.0}) == {"f.cold": 15.0}
def test_a_value_matching_nothing_goes_nowhere_by_default():
node = switch(rules=[{"port": "hot", "op": "gt", "value": 100}])
assert node.execute({"f.temp": 15.0}) is None
def test_an_otherwise_branch_catches_what_the_rules_missed():
node = switch(rules=[{"port": "hot", "op": "gt", "value": 100}], otherwise="cold")
assert node.execute({"f.temp": 15.0}) == {"f.cold": 15.0}
def test_a_rule_comparing_incompatible_things_does_not_take_the_flow_down():
node = switch(rules=[{"port": "hot", "op": "gt", "value": "twenty"}])
assert node.execute({"f.temp": 25.0}) is None
# ---------------------------------------------------------------------------
# Change
# ---------------------------------------------------------------------------
def change(**params) -> ChangeNode:
node = ChangeNode(
requires=[spec("raw")],
provides=[spec("scaled")],
params=params,
name="change",
)
return place(node)
def test_scaling_and_offsetting_a_reading():
node = change(scale=0.1, offset=-273.15, round_to=2)
assert node.execute({"f.raw": 3000.0}) == {"f.scaled": 26.85}
def test_mapping_one_value_onto_another():
# The mapped output is text, and the port has to say so — the type check
# between nodes applies to a change node like any other.
node = ChangeNode(
requires=[spec("raw")],
provides=[spec("label", DType.STR)],
params={"mapping": {"1": "on", "0": "off"}, "default": "unknown"},
name="change",
)
place(node)
assert node.execute({"f.raw": 1}) == {"f.label": "on"}
assert node.execute({"f.raw": 7}) == {"f.label": "unknown"}
# ---------------------------------------------------------------------------
# Filter unchanged
# ---------------------------------------------------------------------------
def rbe(**params) -> RbeNode:
node = RbeNode(
requires=[spec("temp")],
provides=[spec("changed")],
params=params,
name="rbe",
)
return place(node)
def test_the_same_reading_twice_only_passes_once():
node = rbe()
assert node.execute({"f.temp": 21.0}) == {"f.changed": 21.0}
assert node.execute({"f.temp": 21.0}) is None
assert node.execute({"f.temp": 22.0}) == {"f.changed": 22.0}
def test_a_deadband_swallows_the_jitter():
node = rbe(deadband=1.0)
assert node.execute({"f.temp": 21.0}) == {"f.changed": 21.0}
assert node.execute({"f.temp": 21.4}) is None
assert node.execute({"f.temp": 22.5}) == {"f.changed": 22.5}
def test_a_falsy_first_reading_still_counts_as_new():
"""Zero is a reading, not the absence of one."""
node = rbe()
assert node.execute({"f.temp": 0.0}) == {"f.changed": 0.0}
# ---------------------------------------------------------------------------
# Join
# ---------------------------------------------------------------------------
def test_join_gathers_its_inputs_into_one_message():
node = JoinNode(
requires=[spec("temp"), spec("humidity")],
provides=[spec("reading", DType.JSON)],
params={"mode": "object"},
name="join",
)
place(node)
result = node.execute({"f.temp": 21.0, "f.humidity": 40.0})
assert result == {"f.reading": {"temp": 21.0, "humidity": 40.0}}
def test_join_can_produce_a_list_instead():
node = JoinNode(
requires=[spec("a"), spec("b")],
provides=[spec("both", DType.JSON)],
params={"mode": "array"},
name="join",
)
place(node)
assert node.execute({"f.a": 1.0, "f.b": 2.0}) == {"f.both": [1.0, 2.0]}
def test_join_waits_for_every_input_to_be_fresh():
node = JoinNode(
requires=[spec("a"), spec("b")],
provides=[spec("both", DType.JSON)],
name="join",
)
# Otherwise one input arriving would re-emit the previous combination.
assert node.synchronous
# ---------------------------------------------------------------------------
# Command and file
# ---------------------------------------------------------------------------
def test_a_command_hands_back_what_it_printed():
node = ExecNode(
requires=[spec("go", DType.BOOL)],
provides=[spec("stdout", DType.STR)],
params={"command": "echo hello"},
name="exec",
)
place(node)
assert node.execute({"f.go": True}) == {"f.stdout": "hello\n"}
def test_a_command_that_is_not_installed_says_so():
node = ExecNode(
requires=[spec("go", DType.BOOL)],
provides=[spec("stdout", DType.STR)],
params={"command": "definitely-not-a-real-command"},
name="exec",
)
place(node)
try:
node.execute({"f.go": True})
except FileNotFoundError as exc:
assert "not available in this container" in str(exc)
else: # pragma: no cover - the command really should not exist
raise AssertionError("expected a FileNotFoundError")
def test_a_file_node_refuses_to_leave_its_directory():
node = FileNode(
provides=[spec("contents", DType.STR)],
params={"path": "../../secrets.enc", "mode": "read"},
name="file",
)
place(node)
try:
node.execute({})
except ValueError as exc:
assert "outside the files directory" in str(exc)
else: # pragma: no cover
raise AssertionError("expected the path to be refused")
def test_a_file_round_trips_through_the_sandbox(tmp_path, monkeypatch):
from fluksio.core.config import settings
monkeypatch.setattr(settings, "FLOWS_DIR", tmp_path / "flows")
writer = FileNode(
requires=[spec("line", DType.STR)],
params={"path": "readings.log", "mode": "append"},
name="writer",
)
place(writer)
writer.execute({"f.line": "21.0"})
writer.execute({"f.line": "22.0"})
reader = FileNode(
provides=[spec("contents", DType.STR)],
params={"path": "readings.log", "mode": "read"},
name="reader",
)
place(reader)
assert reader.execute({}) == {"f.contents": "21.0\n22.0\n"}