Files
app/backend/tests/flow/test_brain_graph.py
T
stroblmeandClaude Opus 5.5 a1bdae3484 Connection nodes speak of listener and sender, and of send_queue_size
mqtt and http take websocket's vocabulary: their Mode is LISTENER/SENDER,
default names read mqtt_listener_… and http_listener_…, and mqtt's
publish_queue_size is send_queue_size. No alias stays behind, so the flow
store rewrites the old setting in every published and draft mqtt node when it
opens, in the same migration as trigger → debounce. Every mqtt and http
setting carries a one-line description for the editor, and the node-types
reference uses one wording for direction and says that an http sender
publishes a JSON object response on its outputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
2026-10-15 09:44:22 +02:00

139 lines
4.6 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, LoadedNode
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 listener(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=[
listener("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=[listener("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.target_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_a_node_that_is_down_is_a_troubled_neuron(controller: FlowController):
"""A node that stopped working is what someone comes to this view to find.
`self.issues` is what a build found; being down happens afterwards, so the
graph has to read the health issues too or a dead node draws as a well one.
"""
controller.loaded["house.scale"] = LoadedNode(
id="house.scale",
flow="house",
health="down",
health_detail="broker unreachable",
)
marked = {
node.id: node.issue for node in controller.brain_graph().nodes if node.issue
}
assert list(marked) == ["house.scale"]
assert "broker unreachable" in marked["house.scale"]
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)