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>
119 lines
3.9 KiB
Python
119 lines
3.9 KiB
Python
"""Nodes talking to the same outside thing are one neuron, across flows.
|
|
|
|
That merge is the whole point of the brain graph: a broker topic is a single
|
|
physical thing, and two flows sharing one are wired together through it even
|
|
though neither canvas can draw the other end.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from fluksio.flow.controller import FlowController
|
|
from fluksio.flow.messages import DType, MessageSpec
|
|
from fluksio.flow.nodes import MqttNode
|
|
from fluksio.flow.pipeline import ValidationIssue
|
|
from fluksio.flow.schemas import FlowDef, NodeDef
|
|
from fluksio.flow.store import FlowStore
|
|
|
|
BROKER = {"broker_host": "mosquitto", "broker_port": 1883, "topic": "sensors/temp"}
|
|
|
|
|
|
def subscriber(node_id: str) -> NodeDef:
|
|
return NodeDef(
|
|
id=node_id,
|
|
type="mqtt",
|
|
params=dict(BROKER),
|
|
provides=[MessageSpec(name="temp", dtype=DType.FLOAT)],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def controller(tmp_path: Path) -> FlowController:
|
|
store = FlowStore(tmp_path / "flows")
|
|
store.write_flow(
|
|
FlowDef(
|
|
name="house",
|
|
nodes=[
|
|
subscriber("sensor"),
|
|
NodeDef(
|
|
id="scale",
|
|
type="change",
|
|
requires=[MessageSpec(name="temp", dtype=DType.FLOAT)],
|
|
provides=[MessageSpec(name="scaled", dtype=DType.FLOAT)],
|
|
),
|
|
],
|
|
)
|
|
)
|
|
store.write_flow(FlowDef(name="shed", nodes=[subscriber("sensor")]))
|
|
return FlowController(store)
|
|
|
|
|
|
def test_two_flows_on_one_topic_are_one_neuron(controller: FlowController):
|
|
graph = controller.brain_graph()
|
|
|
|
merged = [node for node in graph.nodes if node.kind == "mqtt"]
|
|
assert len(merged) == 1
|
|
assert merged[0].id == "mqtt:mosquitto:1883/sensors/temp"
|
|
assert merged[0].members == ["house.sensor", "shed.sensor"]
|
|
assert merged[0].flows == ["house", "shed"]
|
|
|
|
|
|
def test_a_node_with_no_outside_thing_stays_its_own(controller: FlowController):
|
|
graph = controller.brain_graph()
|
|
|
|
scale = next(node for node in graph.nodes if node.kind == "change")
|
|
assert scale.id == "house.scale"
|
|
assert scale.members == ["house.scale"]
|
|
|
|
|
|
def test_an_edge_carries_the_qualified_message(controller: FlowController):
|
|
graph = controller.brain_graph()
|
|
|
|
assert [(edge.source, edge.target, edge.messages) for edge in graph.edges] == [
|
|
("mqtt:mosquitto:1883/sensors/temp", "house.scale", ["house.temp"])
|
|
]
|
|
|
|
|
|
def test_a_credential_never_reaches_the_key():
|
|
# Stored params, so a secret is still a reference. Neither its name nor its
|
|
# value belongs in something the browser gets to see.
|
|
key = MqttNode.instance_key({**BROKER, "password": {"$secret": "broker_pw"}})
|
|
|
|
assert key == "mosquitto:1883/sensors/temp"
|
|
|
|
|
|
def test_a_neuron_carries_what_stops_it_running(controller: FlowController):
|
|
# Validation runs on a build, so the graph on its own knows nothing yet.
|
|
assert all(node.issue is None for node in controller.brain_graph().nodes)
|
|
|
|
controller.issues = [
|
|
ValidationIssue(
|
|
code="cycle",
|
|
message="These nodes depend on each other in a loop",
|
|
flow="house",
|
|
nodes=["house.scale", "house.sensor"],
|
|
)
|
|
]
|
|
graph = controller.brain_graph()
|
|
|
|
# Both named nodes are marked, the merged neuron among them, and the flow
|
|
# that has nothing wrong with it is left alone.
|
|
assert {node.id: node.issue is not None for node in graph.nodes} == {
|
|
"house.scale": True,
|
|
"mqtt:mosquitto:1883/sensors/temp": True,
|
|
}
|
|
|
|
|
|
def test_an_advisory_issue_leaves_the_graph_clean(controller: FlowController):
|
|
controller.issues = [
|
|
ValidationIssue(
|
|
code="unauthenticated_hook",
|
|
message="Webhook 'hook' has no secret",
|
|
flow="house",
|
|
node="house.scale",
|
|
)
|
|
]
|
|
|
|
assert all(node.issue is None for node in controller.brain_graph().nodes)
|