- NodeDef `device`/`device_policy` become `worker`/`worker_policy`, the issue `ignored_device` becomes `ignored_worker`, and the SDK's `@node` takes `worker=` and `worker_policy=` - FlowStore._migrate renames the keys in published and draft documents once, bumping their version; a queued run's `needs` reads the old key - placement passes the label as `label`, client regenerated - docs: worker placement wording, MCP issuer without DOMAIN, device rows in the CLI data directory and the API table, device links in compose comments
206 lines
7.1 KiB
Python
206 lines
7.1 KiB
Python
"""Editing writes drafts; only publishing changes what the engine reads."""
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from fluksio.flow.controller import FlowController
|
|
from fluksio.flow.messages import MessageSpec
|
|
from fluksio.flow.schemas import FlowDef, FlowOrigin, NodeDef
|
|
from fluksio.flow.store import FlowStore, StaleVersion
|
|
|
|
SOURCE = "def process():\n return {}\n"
|
|
EDITED = "def process():\n return {'temp': 1}\n"
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path: Path) -> FlowStore:
|
|
return FlowStore(tmp_path / "flows")
|
|
|
|
|
|
def a_flow(title: str = "") -> FlowDef:
|
|
return FlowDef(
|
|
name="heating",
|
|
title=title,
|
|
nodes=[NodeDef(id="sensor", provides=[MessageSpec(name="temp")])],
|
|
)
|
|
|
|
|
|
def test_a_draft_is_invisible_to_the_engine(store: FlowStore):
|
|
store.write_draft(a_flow(), 0)
|
|
|
|
assert store.has_draft("heating")
|
|
assert store.list_flows() == ["heating"] # the editor sees it
|
|
assert store.read_all() == [] # the engine does not
|
|
|
|
|
|
def test_the_version_counter_advances_per_save(store: FlowStore):
|
|
first = store.write_draft(a_flow(), 0)
|
|
assert first.version == 1
|
|
|
|
second = store.write_draft(a_flow(title="Warm"), first.version)
|
|
assert second.version == 2
|
|
|
|
|
|
def test_resaving_identical_content_neither_bumps_nor_commits(store: FlowStore):
|
|
stored = store.write_draft(a_flow(), 0)
|
|
|
|
again = store.write_draft(a_flow(), stored.version)
|
|
assert again.version == stored.version
|
|
|
|
|
|
def test_a_stale_write_is_refused(store: FlowStore):
|
|
store.write_draft(a_flow(), 0)
|
|
store.write_draft(a_flow(title="Mine"), 1)
|
|
|
|
# A second client still holding version 1 must not overwrite version 2.
|
|
with pytest.raises(StaleVersion) as excinfo:
|
|
store.write_draft(a_flow(title="Theirs"), 1)
|
|
assert excinfo.value.current == 2
|
|
|
|
|
|
def test_publishing_promotes_the_draft_and_its_sources(store: FlowStore):
|
|
stored = store.write_draft(a_flow(title="Warm"), 0)
|
|
store.write_node_source("heating", "sensor", EDITED, draft=True)
|
|
|
|
published = store.publish_flow("heating", stored.version)
|
|
|
|
assert published.title == "Warm"
|
|
assert not store.has_draft("heating")
|
|
assert store.read_flow("heating").title == "Warm"
|
|
assert store.read_node_source("heating", "sensor") == EDITED
|
|
assert [flow.name for flow in store.read_all()] == ["heating"]
|
|
|
|
|
|
def test_publishing_a_stale_draft_is_refused(store: FlowStore):
|
|
store.write_draft(a_flow(), 0)
|
|
|
|
with pytest.raises(StaleVersion):
|
|
store.publish_flow("heating", 99)
|
|
|
|
|
|
def test_discarding_goes_back_to_what_is_running(store: FlowStore):
|
|
store.write_flow(a_flow(title="Published"))
|
|
store.write_node_source("heating", "sensor", SOURCE)
|
|
store.write_draft(a_flow(title="Edited"), a_flow().version)
|
|
store.write_node_source("heating", "sensor", EDITED, draft=True)
|
|
|
|
restored = store.discard_draft("heating")
|
|
|
|
assert restored.title == "Published"
|
|
assert not store.has_draft("heating")
|
|
assert store.read_node_source("heating", "sensor", draft=True) == SOURCE
|
|
|
|
|
|
def test_an_edited_source_alone_counts_as_a_draft(store: FlowStore):
|
|
"""Editing only code still has to be published before the engine runs it."""
|
|
store.write_flow(a_flow())
|
|
store.write_node_source("heating", "sensor", SOURCE)
|
|
|
|
store.write_node_source("heating", "sensor", EDITED, draft=True)
|
|
|
|
assert store.has_draft("heating")
|
|
assert store.read_node_source("heating", "sensor") == SOURCE
|
|
assert store.read_node_source("heating", "sensor", draft=True) == EDITED
|
|
|
|
store.publish_flow("heating", store.read_flow("heating").version)
|
|
assert store.read_node_source("heating", "sensor") == EDITED
|
|
|
|
|
|
def test_a_published_node_with_no_body_is_reported(store: FlowStore):
|
|
"""It would run the new-node template, which returns nothing and says so."""
|
|
store.write_flow(a_flow())
|
|
controller = FlowController(store)
|
|
|
|
asyncio.run(controller.reload())
|
|
(issue,) = [i for i in controller.issues if i.code == "missing_source"]
|
|
assert issue.node == "heating.sensor"
|
|
assert not issue.advisory
|
|
# It still loads: one node with no body does not take the flow down.
|
|
assert controller.get_node("heating.sensor") is not None
|
|
|
|
store.write_node_source("heating", "sensor", SOURCE)
|
|
asyncio.run(controller.reload())
|
|
assert [i for i in controller.issues if i.code == "missing_source"] == []
|
|
|
|
|
|
def test_a_worker_on_a_node_without_code_is_reported(store: FlowStore):
|
|
"""Only code travels to a worker, so the worker would be ignored silently."""
|
|
store.write_flow(
|
|
FlowDef(
|
|
name="heating",
|
|
nodes=[
|
|
NodeDef(
|
|
id="relay",
|
|
type="change",
|
|
worker="gpu",
|
|
provides=[MessageSpec(name="temp")],
|
|
)
|
|
],
|
|
)
|
|
)
|
|
controller = FlowController(store)
|
|
|
|
asyncio.run(controller.reload())
|
|
(issue,) = [i for i in controller.issues if i.code == "ignored_worker"]
|
|
assert issue.node == "heating.relay"
|
|
assert issue.advisory
|
|
|
|
|
|
def test_a_node_being_written_in_the_editor_is_not_reported(store: FlowStore):
|
|
"""A draft legitimately has no published body yet — that is what a draft is."""
|
|
store.write_draft(a_flow(), 0)
|
|
controller = FlowController(store)
|
|
|
|
assert controller.preview("heating").issues == []
|
|
|
|
|
|
def test_resaving_the_published_source_creates_no_draft(store: FlowStore):
|
|
store.write_flow(a_flow())
|
|
store.write_node_source("heating", "sensor", SOURCE)
|
|
|
|
assert store.write_node_source("heating", "sensor", SOURCE, draft=True) is False
|
|
assert not store.has_draft("heating")
|
|
|
|
|
|
def test_renaming_carries_the_draft_and_repoints_other_drafts(store: FlowStore):
|
|
store.write_draft(a_flow(), 0)
|
|
store.write_draft(
|
|
FlowDef(
|
|
name="display",
|
|
nodes=[NodeDef(id="gauge", requires=[MessageSpec(name="heating.temp")])],
|
|
),
|
|
0,
|
|
)
|
|
|
|
renamed = store.rename_flow("heating", "warmth")
|
|
|
|
assert renamed.name == "warmth"
|
|
assert store.has_draft("warmth")
|
|
assert store.read_flow("display", draft=True).nodes[0].requires[0].name == (
|
|
"warmth.temp"
|
|
)
|
|
|
|
|
|
def test_a_duplicate_is_a_local_draft_of_what_the_editor_shows(store: FlowStore):
|
|
flow = a_flow(title="Warm").model_copy(update={"origin": FlowOrigin()})
|
|
flow.nodes.append(NodeDef(id="reader", requires=[MessageSpec(name="heating.temp")]))
|
|
store.write_flow(flow)
|
|
store.write_node_source("heating", "sensor", SOURCE)
|
|
store.write_node_source("heating", "sensor", EDITED, draft=True)
|
|
store.write_enabled("heating", False)
|
|
|
|
copy = store.duplicate_flow("heating")
|
|
|
|
assert (copy.name, copy.title, copy.version) == ("heating_copy", "Warm (copy)", 1)
|
|
assert copy.origin is None
|
|
assert copy.nodes[1].requires[0].name == "heating_copy.temp"
|
|
assert store.has_draft("heating_copy") and not store.is_published("heating_copy")
|
|
assert store.read_node_source("heating_copy", "sensor", draft=True) == EDITED
|
|
assert not (store.root / "heating_copy" / "runtime.json").exists()
|
|
assert store.duplicate_flow("heating").name == "heating_copy_2"
|
|
|
|
store.publish_flow("heating_copy", copy.version)
|
|
assert store.read_node_source("heating_copy", "sensor") == EDITED
|