A node type can now say which outside thing its parameters point at, and nodes sharing one — a broker topic, a URL, a bucket — are drawn as a single neuron on a new /brain canvas. That makes the wiring which runs between flows through a broker visible for the first time; no single flow's canvas can show it. The key is read off stored parameters, so a credential reference never reaches an id. Layout is a d3 force simulation settled once and then frozen, lit by the socket the editor already listens to: a neuron pulses when any node behind it publishes, and its connections light as values pass. Fixes the message pulse while here: interpolating the stroke against the edge's `color-mix()` resting colour went through oklab and left the gamut, which turned every pulse on both canvases fluorescent yellow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
83 lines
2.7 KiB
Python
83 lines
2.7 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.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"
|