Files
app/backend/tests/flow/test_discretization.py
T
Melvin StroblandClaude Fable 5 3724b68f23 Add the connector contract, reusable nodes and per-port intervals
Connectors are the device-facing node class third parties write, so the
surface they build against is versioned and documented: ConnectorNode carries
a declared contract version, a polling loop that publishes only what changed
and reports health around it, and parameters whose credential fields are
marked x-secret so the editor offers the secrets store instead of a text box.
They are found through the fluksio.node_types entry point group, with the
package's own metadata as the manifest. docs/connectors/ has the contract and
the authoring guide; connector-skeleton/ is a working one to copy.

The controller no longer knows what any node type is: start, stop and
report_health are protocol methods on Node, and the built-ins were migrated to
them first, so the hooks a connector implements are the ones the engine has
been driving all along.

Marking a node reusable moves its source to _lib/ and points the node at it by
name. Other flows instantiate it with their own ports and settings, one fix
reaches all of them, and a shared source still in use cannot be deleted.

Ports gained an interval: an output publishes, and an input wakes its node, at
most every n seconds. State keeps the latest value, so only the delivery is
skipped, and pressing Run is never throttled.

Also fixes autosave sending no version on its first save of a session, which
made every flow saved more than once conflict with itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 23:57:44 +02:00

101 lines
3.1 KiB
Python

"""Per-port intervals: deliver at most every x seconds."""
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import Node
from app.flow.pipeline import Pipeline
def spec(name: str, interval: float = 0) -> MessageSpec:
return MessageSpec(name=name, dtype=DType.FLOAT, interval=interval)
def make_node(node_id: str, f, requires=(), provides=()) -> Node:
node = Node(f=f, requires=list(requires), provides=list(provides), name=node_id)
node.assign_flow("demo", node_id)
return node
def test_a_limited_output_publishes_once_inside_its_window():
readings = iter([1.0, 2.0, 3.0])
source = make_node(
"source",
lambda params: {"temp": next(readings)},
provides=[spec("temp", interval=60)],
)
pipeline = Pipeline(nodes=[source])
pipeline.run({})
assert pipeline.state["demo.temp"] == 1.0
# Same window: the reading is taken but not published.
pipeline.run({})
assert pipeline.state["demo.temp"] == 1.0
def test_an_unlimited_output_publishes_every_time():
readings = iter([1.0, 2.0])
source = make_node(
"source",
lambda params: {"temp": next(readings)},
provides=[spec("temp")],
)
pipeline = Pipeline(nodes=[source])
pipeline.run({})
pipeline.run({})
assert pipeline.state["demo.temp"] == 2.0
def test_a_limited_input_wakes_its_node_once_inside_the_window():
seen: list[float] = []
source = make_node("source", lambda params: {"temp": 20.0}, provides=[spec("temp")])
consumer = make_node(
"consumer",
lambda temp, params: seen.append(temp),
requires=[spec("temp", interval=60)],
)
# Binding the nodes is what the pipeline is for here.
Pipeline(nodes=[source, consumer])
source.inject({"temp": 20.0})
source.inject({"temp": 21.0})
assert seen == [20.0]
def test_an_unthrottled_input_still_wakes_a_node_beside_a_throttled_one():
seen: list[tuple[float, float]] = []
fast = make_node("fast", lambda params: None, provides=[spec("quick")])
slow = make_node("slow", lambda params: None, provides=[spec("rare")])
consumer = make_node(
"consumer",
lambda quick, rare, params: seen.append((quick, rare)),
requires=[spec("quick"), spec("rare", interval=60)],
)
pipeline = Pipeline(nodes=[fast, slow, consumer])
pipeline.state["demo.rare"] = 1.0
fast.inject({"quick": 1.0})
fast.inject({"quick": 2.0})
# The throttled port holds back only itself.
assert [quick for quick, _ in seen] == [1.0, 2.0]
def test_a_manual_run_is_never_throttled_on_its_inputs():
seen: list[float] = []
source = make_node("source", lambda params: {"temp": 20.0}, provides=[spec("temp")])
consumer = make_node(
"consumer",
lambda temp, params: seen.append(temp),
requires=[spec("temp", interval=3600)],
)
pipeline = Pipeline(nodes=[source, consumer])
# Pressing Run is an explicit ask; the interval governs the flow's own
# traffic, not what the person in front of it asked for.
pipeline.run({})
pipeline.run({})
assert len(seen) == 2