A dependency loop is flagged on the canvas and was invisible everywhere else: /observability/summary answered "ok" with an empty problems list while the published flow could not run at all. It now reports the flows validation blocks, and the brain graph carries the reason on each neuron the issue names so the view built to find broken wiring can show it. Node errors stay counted once, as the nodes that failed to load, and an advisory like an unauthenticated webhook marks nothing — it is worth saying, but the flow still runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
119 lines
3.8 KiB
Python
119 lines
3.8 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 app.flow.controller import FlowController
|
|
from app.flow.messages import DType, MessageSpec
|
|
from app.flow.nodes import MqttNode
|
|
from app.flow.pipeline import ValidationIssue
|
|
from app.flow.schemas import FlowDef, NodeDef
|
|
from app.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)
|