Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
146 lines
5.0 KiB
Python
146 lines
5.0 KiB
Python
"""What a flow's canvas draws for the wiring that leaves it.
|
|
|
|
Both directions matter and only one used to be answered: an outsider reaching
|
|
into this flow was drawn, and this flow reaching out was not — so a port bound
|
|
to another flow's message had nothing on the canvas to account for it.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from fluksio.api.routes.flows import _endpoints
|
|
from fluksio.core.config import settings
|
|
from fluksio.flow.controller import FlowController
|
|
from fluksio.flow.messages import DType, MessageSpec
|
|
from fluksio.flow.panels import PanelDef, PanelPort, PanelsConfig, write_config
|
|
from fluksio.flow.schemas import FlowDef, NodeDef
|
|
from fluksio.flow.store import FlowStore
|
|
|
|
|
|
def spec(name: str) -> MessageSpec:
|
|
return MessageSpec(name=name, dtype=DType.FLOAT)
|
|
|
|
|
|
@pytest.fixture
|
|
def controller(tmp_path: Path) -> FlowController:
|
|
store = FlowStore(tmp_path / "flows")
|
|
store.write_flow(
|
|
FlowDef(
|
|
name="house",
|
|
nodes=[
|
|
NodeDef(id="sensor", type="mqtt", provides=[spec("temp")]),
|
|
# Reaches into `garage`, which is the direction that was drawn.
|
|
NodeDef(id="relay", type="change", provides=[spec("garage.open")]),
|
|
],
|
|
)
|
|
)
|
|
store.write_flow(
|
|
FlowDef(
|
|
name="garage",
|
|
nodes=[
|
|
NodeDef(id="door", type="python", requires=[spec("garage.open")]),
|
|
# Reaches out into `house`, which was drawn nowhere.
|
|
NodeDef(id="watch", type="python", requires=[spec("house.temp")]),
|
|
],
|
|
)
|
|
)
|
|
return FlowController(store)
|
|
|
|
|
|
def labels(
|
|
controller: FlowController, definition: FlowDef
|
|
) -> dict[str, tuple[str, str]]:
|
|
return {
|
|
endpoint.id: (endpoint.label, endpoint.detail)
|
|
for endpoint in _endpoints(controller, definition)
|
|
}
|
|
|
|
|
|
def test_a_port_bound_to_another_flow_names_the_node_at_the_far_end(controller):
|
|
endpoints = labels(controller, controller.store.read_flow("garage"))
|
|
|
|
# `house.relay` publishes `garage.open`, so it reaches in — drawn before.
|
|
# `house.sensor` publishes `house.temp`, which `garage.watch` reads, so
|
|
# this flow reaches out — and the label names the node and its type, the
|
|
# way a dashboard's label names the widget and its type.
|
|
assert endpoints == {
|
|
"flow:house.relay": ("house.relay", "flow"),
|
|
"flow:house.sensor": ("house.sensor", "mqtt"),
|
|
}
|
|
|
|
|
|
def test_a_node_reaching_into_and_out_of_one_flow_is_one_label(controller):
|
|
endpoints = _endpoints(controller, controller.store.read_flow("house"))
|
|
|
|
# `garage.door` reads what `house.relay` publishes, and `garage.watch`
|
|
# reads `house.temp` — two directions, two nodes, two labels, no collision.
|
|
assert {endpoint.id for endpoint in endpoints} == {
|
|
"flow:garage.watch",
|
|
"flow:garage.door",
|
|
}
|
|
door = next(one for one in endpoints if one.id == "flow:garage.door")
|
|
assert door.requires == ["garage.open"]
|
|
|
|
|
|
def test_a_name_no_published_flow_declares_is_drawn_as_the_name(controller):
|
|
definition = controller.store.read_flow("house")
|
|
definition.nodes.append(
|
|
NodeDef(id="guess", type="python", requires=[spec("shed.humidity")])
|
|
)
|
|
|
|
endpoints = labels(controller, definition)
|
|
|
|
assert endpoints["flow:shed.humidity"] == ("shed.humidity", "flow")
|
|
|
|
|
|
def test_the_wiring_index_is_read_once_per_store_revision(controller):
|
|
reads = 0
|
|
original = controller.store.read_all
|
|
|
|
def counted():
|
|
nonlocal reads
|
|
reads += 1
|
|
return original()
|
|
|
|
controller.store.read_all = counted # type: ignore[method-assign]
|
|
_endpoints(controller, controller.store.read_flow("house"))
|
|
_endpoints(controller, controller.store.read_flow("garage"))
|
|
assert reads == 1
|
|
|
|
controller.store.write_flow(FlowDef(name="shed", nodes=[]))
|
|
_endpoints(controller, controller.store.read_flow("house"))
|
|
assert reads == 2
|
|
|
|
|
|
def test_a_panel_port_is_drawn_on_the_side_it_wires(controller, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(settings, "PANELS_FILE", tmp_path / "panels.json")
|
|
write_config(
|
|
PanelsConfig(
|
|
panels=[
|
|
PanelDef(
|
|
id="hall",
|
|
title="Hall",
|
|
io=[
|
|
PanelPort(port="gpio.4", message="house.temp", direction="in"),
|
|
PanelPort(
|
|
port="backlight", message="house.temp", direction="out"
|
|
),
|
|
PanelPort(port="mic", message="garage.open", direction="in"),
|
|
],
|
|
)
|
|
]
|
|
)
|
|
)
|
|
|
|
endpoints = {
|
|
one.id: one
|
|
for one in _endpoints(controller, controller.store.read_flow("house"))
|
|
}
|
|
|
|
# The id is what the device publishes under, so its edges pulse for it.
|
|
assert endpoints["panel:hall:gpio.4"].provides == ["house.temp"]
|
|
assert endpoints["panel:hall:backlight"].requires == ["house.temp"]
|
|
assert endpoints["panel:hall:backlight"].detail == "Hall"
|
|
assert "panel:hall:mic" not in endpoints
|