Files
lda-wf/tests/wf_client/test_authoring.py
T

324 lines
11 KiB
Python

from __future__ import annotations
from typing import Any, cast
import pytest
from wf_authoring import WorkflowBuilder
from wf_client import App, ArtifactRef, EditableWorkflow, RemoteCapability
from wf_client.errors import InvalidResponse
from wf_client.protocols import WorkflowClientPort
from wf_platform import CapabilityRef
class FakePort:
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
self.validate_artifact_plan_result: dict[str, Any] = {
"status": "valid",
"diagnostics": [],
"required_capabilities": [],
"workflow_dependencies": {},
}
self.inspect_artifact_result: dict[str, Any] | None = None
self.create_artifact_result: dict[str, Any] | None = None
async def validate_artifact_plan(self, **params: Any) -> object:
self.calls.append(("validate_artifact_plan", params))
return self.validate_artifact_plan_result
async def create_artifact_from_plan(self, **params: Any) -> object:
self.calls.append(("create_artifact_from_plan", params))
return self.create_artifact_result or {
"artifact_id": params["artifact_id"],
"version": params["version"],
"saved": True,
}
async def inspect_artifact(self, **params: Any) -> object:
self.calls.append(("inspect_artifact", params))
assert self.inspect_artifact_result is not None
return self.inspect_artifact_result
def valid_plan(version: int = 1) -> dict[str, Any]:
return {
"id": "report",
"version": version,
"title": "Report",
"kind": "workflow",
"description": None,
"input_schema": {"type": "object", "properties": {}},
"output_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
},
"outcomes": ["ok"],
"plan": {
"name": "report",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
},
"output_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
},
"outcomes": ["ok"],
"output": [{"path": "state.value", "target": "value"}],
"start": "done",
"nodes": [{"id": "done", "type": "end", "outcome": "ok"}],
"edges": [],
},
"required_capabilities": [],
"workflow_dependencies": {},
"created_from_catalog_version": None,
}
def remote_plan_without_schema_snapshots(version: int = 1) -> dict[str, Any]:
payload = valid_plan(version)
payload["plan"]["nodes"] = [
{
"id": "remote",
"type": "node",
"node": "app.default.remote",
"input": [],
"output": [],
},
{"id": "done", "type": "end", "outcome": "ok"},
]
payload["plan"]["start"] = "remote"
payload["plan"]["edges"] = [{"from": "remote", "outcome": "ok", "to": "done"}]
payload["required_capabilities"] = [
{
"ref": {"source": "app.default", "capability_key": "remote"},
"kind": "node_spec",
"input_schema_hash": None,
"input_schema_snapshot": None,
"output_schema_hash": None,
"output_schema_snapshot": None,
"observed_concrete_source": None,
"observed_at_epoch_ms": None,
}
]
return payload
def remote_plan_with_real_node_def(version: int = 1) -> dict[str, Any]:
payload = remote_plan_without_schema_snapshots(version)
payload["plan"]["node_defs"] = [
{
"name": "app.default.remote",
"input_schema": {
"type": "object",
"properties": {"real": {"type": "string"}},
},
"output_schema": {"type": "object", "properties": {}},
"outcomes": ["ok"],
}
]
return payload
@pytest.mark.asyncio
async def test_validate_stops_before_remote_call_when_local_graph_is_invalid() -> None:
port = FakePort()
graph = App._from_port(cast(WorkflowClientPort, port)).new_workflow(
"invalid",
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
)
result = await graph.validate()
assert result.local.ok is False
assert result.remote_status == "not_run"
assert port.calls == []
@pytest.mark.asyncio
async def test_validate_runs_local_and_server_validation() -> None:
port = FakePort()
graph = App._from_port(cast(WorkflowClientPort, port)).new_workflow(
"report",
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {"value": {"type": "string"}}},
output_schema={"type": "object", "properties": {"value": {"type": "string"}}},
)
done = graph.end("ok", id="done")
graph.set_entry_point(done)
result = await graph.validate()
assert result.ok is True
assert result.local.ok is True
assert result.remote_status == "valid"
assert port.calls[-1][0] == "validate_artifact_plan"
@pytest.mark.asyncio
async def test_edit_and_save_inspects_exact_saved_version() -> None:
port = FakePort()
port.inspect_artifact_result = valid_plan(version=1)
app = App._from_port(cast(WorkflowClientPort, port))
graph = await app.edit_workflow("report", version=1)
assert isinstance(graph, WorkflowBuilder)
assert isinstance(graph, EditableWorkflow)
assert all(
hasattr(graph, name)
for name in (
"when",
"choose",
"match",
"foreach",
"interrupt",
"end",
"connect",
"set_entry_point",
)
)
port.inspect_artifact_result = valid_plan(version=2)
saved = await graph.save(version=2)
create = next(
params
for operation, params in port.calls
if operation == "create_artifact_from_plan"
)
assert create["plan"] == valid_plan(version=1)["plan"]
inspect = [
params for operation, params in port.calls if operation == "inspect_artifact"
][-1]
assert inspect == {"artifact_id": "report", "version": 2}
assert saved.ref == ArtifactRef("report", 2)
assert str(saved.workflow.output[0].target) == "value"
@pytest.mark.asyncio
async def test_save_rejects_mismatched_create_acknowledgement() -> None:
port = FakePort()
graph = App._from_port(cast(WorkflowClientPort, port)).new_workflow(
"report",
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
)
graph.set_entry_point(graph.end("ok", id="done"))
port.create_artifact_result = {
"artifact_id": "other",
"version": 2,
"saved": True,
}
with pytest.raises(InvalidResponse, match="workflow.artifacts.create_from_plan"):
await graph.save(version=2)
@pytest.mark.asyncio
async def test_save_rejects_mismatched_exact_inspection() -> None:
port = FakePort()
graph = App._from_port(cast(WorkflowClientPort, port)).new_workflow(
"report",
input_schema={"type": "object", "properties": {}},
state_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
)
graph.set_entry_point(graph.end("ok", id="done"))
port.inspect_artifact_result = valid_plan(version=3)
with pytest.raises(InvalidResponse, match="workflow.artifacts.inspect"):
await graph.save(version=2)
@pytest.mark.asyncio
async def test_editable_artifact_without_schema_snapshots_remains_saveable() -> None:
port = FakePort()
port.inspect_artifact_result = remote_plan_without_schema_snapshots(version=1)
graph = await App._from_port(cast(WorkflowClientPort, port)).edit_workflow(
"report", version=1
)
result = graph.validate_local()
assert result.ok is True
port.inspect_artifact_result = remote_plan_without_schema_snapshots(version=2)
saved = await graph.save(version=2)
assert saved.ref == ArtifactRef("report", 2)
@pytest.mark.asyncio
async def test_snapshotless_remote_node_upgrades_to_real_capability_contract() -> None:
port = FakePort()
port.inspect_artifact_result = remote_plan_without_schema_snapshots(version=1)
graph = await App._from_port(cast(WorkflowClientPort, port)).edit_workflow(
"report", version=1
)
capability = RemoteCapability(
_port=cast(WorkflowClientPort, port),
ref=CapabilityRef.parse("app.default.remote"),
qualified_name="app.default.remote",
description=None,
input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
output_schema={"type": "object", "properties": {}},
outcomes=("ok",),
is_async=False,
)
replacement = graph.use(capability, id="replacement", input=[], output=[])
graph.connect(replacement, "ok", "done")
# A disconnected replacement has no derivable control region; new
# validation reports it as unreachable rather than silently accepting it.
report = graph.validate_local()
assert any(
issue.code == "unreachable_node" and issue.path == "nodes[replacement]"
for issue in report.errors
)
assert graph.seeded_node_defs["app.default.remote"].input_schema.properties == {
"query": {"type": "string"}
}
incompatible = RemoteCapability(
_port=cast(WorkflowClientPort, port),
ref=CapabilityRef.parse("app.default.remote"),
qualified_name="app.default.remote",
description=None,
input_schema={"type": "object", "properties": {"other": {"type": "string"}}},
output_schema={"type": "object", "properties": {}},
outcomes=("ok",),
is_async=False,
)
with pytest.raises(ValueError, match="incompatible duplicate"):
graph.use(incompatible, id="incompatible", input=[], output=[])
@pytest.mark.asyncio
async def test_real_plan_node_def_remains_authoritative_without_snapshots() -> None:
port = FakePort()
port.inspect_artifact_result = remote_plan_with_real_node_def(version=1)
graph = await App._from_port(cast(WorkflowClientPort, port)).edit_workflow(
"report", version=1
)
incompatible = RemoteCapability(
_port=cast(WorkflowClientPort, port),
ref=CapabilityRef.parse("app.default.remote"),
qualified_name="app.default.remote",
description=None,
input_schema={"type": "object", "properties": {"other": {"type": "string"}}},
output_schema={"type": "object", "properties": {}},
outcomes=("ok",),
is_async=False,
)
with pytest.raises(ValueError, match="incompatible duplicate"):
graph.use(incompatible, id="incompatible", input=[], output=[])
assert graph.seeded_node_defs["app.default.remote"].input_schema.properties == {
"real": {"type": "string"}
}