"""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 app.flow.messages import DType, MessageSpec from app.flow.nodes import ChangeNode, ExecNode, FileNode, JoinNode, RbeNode, SwitchNode from app.flow.pipeline import Pipeline from app.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 app.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"}