"""What the flow-logic nodes actually do. These are the shapes a Node-RED instance 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"}