451 lines
14 KiB
Python
451 lines
14 KiB
Python
from decimal import Decimal
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from wf_core.models.steps import (
|
|
ForeachNode,
|
|
InputExpressionBinding,
|
|
InputPathBinding,
|
|
InputValueBinding,
|
|
InterruptNode,
|
|
NodeUse,
|
|
)
|
|
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
|
|
|
|
|
def test_node_use_accepts_canonical_input_and_output_bindings():
|
|
node = NodeUse.model_validate(
|
|
{
|
|
"id": "echo",
|
|
"type": "node",
|
|
"node": "echo",
|
|
"input": [
|
|
{"target": "message", "path": "input.message"},
|
|
{"target": "mode", "value": None},
|
|
],
|
|
"output": [{"source": "echoed", "target": "state.echoed"}],
|
|
}
|
|
)
|
|
|
|
path_binding = node.input[0]
|
|
assert isinstance(path_binding, InputPathBinding)
|
|
assert path_binding.target == LocalPath.of("message")
|
|
assert path_binding.path == GraphSourcePath.input("message")
|
|
|
|
value_binding = node.input[1]
|
|
assert isinstance(value_binding, InputValueBinding)
|
|
assert value_binding.target == LocalPath.of("mode")
|
|
assert value_binding.value is None
|
|
|
|
assert node.output[0].source == LocalPath.of("echoed")
|
|
assert node.output[0].target == StatePath.of("echoed")
|
|
|
|
|
|
def test_node_use_accepts_composite_input_binding_without_flattening():
|
|
binding = {
|
|
"target": "request",
|
|
"expression": {
|
|
"kind": "object",
|
|
"fields": {
|
|
"items": {
|
|
"kind": "array",
|
|
"items": [
|
|
{"kind": "path", "path": "state.foo"},
|
|
{"kind": "literal", "value": "wowcool"},
|
|
],
|
|
},
|
|
"separator": {"kind": "literal", "value": " "},
|
|
},
|
|
},
|
|
}
|
|
|
|
node = NodeUse.model_validate(
|
|
{"id": "join", "type": "node", "node": "join", "input": [binding]}
|
|
)
|
|
|
|
assert isinstance(node.input[0], InputExpressionBinding)
|
|
assert node.model_dump(mode="json")["input"] == [binding]
|
|
|
|
|
|
def test_node_use_converts_old_maps_to_canonical_bindings():
|
|
node = NodeUse.model_validate(
|
|
{
|
|
"id": "echo",
|
|
"type": "node",
|
|
"node": "echo",
|
|
"in_map": {"input.message": "message"},
|
|
"input_values": {"mode": "fast"},
|
|
"out_map": {"echoed": "state.echoed"},
|
|
}
|
|
)
|
|
|
|
dumped = node.model_dump(mode="json")
|
|
assert "in_map" not in dumped
|
|
assert "input_values" not in dumped
|
|
assert "out_map" not in dumped
|
|
assert dumped["input"][0]["value"] == "fast"
|
|
assert dumped["input"][0]["target"] == "mode"
|
|
assert dumped["input"][1]["path"] == "input.message"
|
|
assert dumped["input"][1]["target"] == "message"
|
|
assert dumped["output"][0]["source"] == "echoed"
|
|
assert dumped["output"][0]["target"] == "state.echoed"
|
|
|
|
|
|
def test_node_use_serializes_canonical_binding_paths_as_strings():
|
|
node = NodeUse.model_validate(
|
|
{
|
|
"id": "echo",
|
|
"type": "node",
|
|
"node": "echo",
|
|
"input": [{"target": "message", "path": "input.message"}],
|
|
"output": [{"source": "echoed", "target": "state.echoed"}],
|
|
}
|
|
)
|
|
|
|
python_dumped = node.model_dump()
|
|
json_dumped = node.model_dump(mode="json")
|
|
|
|
assert python_dumped["input"][0]["target"] == "message"
|
|
assert python_dumped["input"][0]["path"] == "input.message"
|
|
assert python_dumped["output"][0]["source"] == "echoed"
|
|
assert python_dumped["output"][0]["target"] == "state.echoed"
|
|
assert json_dumped["input"][0]["target"] == "message"
|
|
assert json_dumped["input"][0]["path"] == "input.message"
|
|
assert json_dumped["output"][0]["source"] == "echoed"
|
|
assert json_dumped["output"][0]["target"] == "state.echoed"
|
|
|
|
|
|
def test_canonical_binding_json_schema_describes_nested_fields():
|
|
schema = NodeUse.model_json_schema()
|
|
defs = schema["$defs"]
|
|
input_path = defs["InputPathBinding"]
|
|
input_value = defs["InputValueBinding"]
|
|
output = defs["OutputBinding"]
|
|
|
|
assert (
|
|
"whole node input payload" in input_path["properties"]["target"]["description"]
|
|
)
|
|
assert "input, state, or context" in input_path["properties"]["path"]["description"]
|
|
assert (
|
|
"Literal JSON-compatible value"
|
|
in input_value["properties"]["value"]["description"]
|
|
)
|
|
assert "whole node output payload" in output["properties"]["source"]["description"]
|
|
assert "Bare state is invalid" in output["properties"]["target"]["description"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
"markdown",
|
|
3.14,
|
|
True,
|
|
None,
|
|
["markdown", 3, False, None],
|
|
{"format": "markdown", "options": {"strict": True}},
|
|
],
|
|
)
|
|
def test_input_value_binding_accepts_every_json_value_category(value: object):
|
|
binding = InputValueBinding.model_validate({"target": "literal", "value": value})
|
|
|
|
assert binding.value == value
|
|
if value is True:
|
|
assert type(binding.value) is bool
|
|
|
|
|
|
def test_input_value_binding_json_schema_is_recursive_json_value():
|
|
value_schema = InputValueBinding.model_json_schema()["$defs"]["JsonValue"]
|
|
|
|
assert {branch["type"] for branch in value_schema["anyOf"]} == {
|
|
"boolean",
|
|
"integer",
|
|
"number",
|
|
"string",
|
|
"array",
|
|
"object",
|
|
"null",
|
|
}
|
|
assert value_schema["anyOf"][4]["items"] == {"$ref": "#/$defs/JsonValue"}
|
|
assert value_schema["anyOf"][5]["additionalProperties"] == {
|
|
"$ref": "#/$defs/JsonValue"
|
|
}
|
|
|
|
|
|
def test_input_value_binding_rejects_non_json_python_objects():
|
|
with pytest.raises(ValidationError):
|
|
InputValueBinding.model_validate({"target": "literal", "value": object()})
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
("tuple", 1),
|
|
{"set", "value"},
|
|
Decimal("1.25"),
|
|
{1: "non-string key"},
|
|
],
|
|
)
|
|
def test_input_value_binding_rejects_non_json_values_without_coercion(value: object):
|
|
with pytest.raises(ValidationError):
|
|
InputValueBinding.model_validate({"target": "literal", "value": value})
|
|
|
|
|
|
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")])
|
|
def test_input_value_binding_rejects_non_finite_json_numbers(value: float):
|
|
with pytest.raises(ValidationError):
|
|
InputValueBinding.model_validate({"target": "literal", "value": value})
|
|
|
|
|
|
def test_node_use_rejects_mixed_old_and_new_binding_styles():
|
|
with pytest.raises(ValidationError):
|
|
NodeUse.model_validate(
|
|
{
|
|
"id": "echo",
|
|
"type": "node",
|
|
"node": "echo",
|
|
"input": [{"target": "message", "path": "input.message"}],
|
|
"in_map": {"input.other": "other"},
|
|
}
|
|
)
|
|
|
|
|
|
def test_input_binding_rejects_path_and_value_together():
|
|
with pytest.raises(ValidationError):
|
|
NodeUse.model_validate(
|
|
{
|
|
"id": "bad",
|
|
"type": "node",
|
|
"node": "bad",
|
|
"input": [{"target": "message", "path": "input.message", "value": "x"}],
|
|
}
|
|
)
|
|
|
|
|
|
def test_input_binding_rejects_neither_path_nor_value():
|
|
with pytest.raises(ValidationError):
|
|
NodeUse.model_validate(
|
|
{
|
|
"id": "bad",
|
|
"type": "node",
|
|
"node": "bad",
|
|
"input": [{"target": "message"}],
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field,binding",
|
|
[
|
|
("input", {"target": "message", "path": "input.message", "extra": True}),
|
|
("output", {"source": "echoed", "target": "state.echoed", "extra": True}),
|
|
],
|
|
)
|
|
def test_bindings_reject_extra_fields(field: str, binding: dict[str, object]):
|
|
with pytest.raises(ValidationError):
|
|
NodeUse.model_validate(
|
|
{
|
|
"id": "bad",
|
|
"type": "node",
|
|
"node": "bad",
|
|
field: [binding],
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field,value",
|
|
[
|
|
("in_map", None),
|
|
("input_values", []),
|
|
("out_map", "bad"),
|
|
],
|
|
)
|
|
def test_deprecated_maps_reject_non_mapping_values(field: str, value: object):
|
|
with pytest.raises(ValidationError):
|
|
NodeUse.model_validate(
|
|
{
|
|
"id": "bad",
|
|
"type": "node",
|
|
"node": "bad",
|
|
field: value,
|
|
}
|
|
)
|
|
|
|
|
|
def test_deprecated_conversion_preserves_input_value_then_in_map_order():
|
|
node = NodeUse.model_validate(
|
|
{
|
|
"id": "ordered",
|
|
"type": "node",
|
|
"node": "ordered",
|
|
"input_values": {"first": 1, "second": 2},
|
|
"in_map": {"input.third": "third", "state.fourth": "fourth"},
|
|
}
|
|
)
|
|
|
|
dumped_input = node.model_dump(mode="json")["input"]
|
|
assert dumped_input[0]["target"] == "first"
|
|
assert dumped_input[0]["value"] == 1
|
|
assert dumped_input[1]["target"] == "second"
|
|
assert dumped_input[1]["value"] == 2
|
|
assert dumped_input[2]["target"] == "third"
|
|
assert dumped_input[2]["path"] == "input.third"
|
|
assert dumped_input[3]["target"] == "fourth"
|
|
assert dumped_input[3]["path"] == "state.fourth"
|
|
|
|
|
|
def test_deprecated_input_value_preserves_explicit_null():
|
|
node = NodeUse.model_validate(
|
|
{
|
|
"id": "null",
|
|
"type": "node",
|
|
"node": "null",
|
|
"input_values": {"maybe": None},
|
|
}
|
|
)
|
|
|
|
value_binding = node.input[0]
|
|
assert isinstance(value_binding, InputValueBinding)
|
|
assert value_binding.value is None
|
|
|
|
dumped_input = node.model_dump(mode="json")["input"]
|
|
assert dumped_input[0]["target"] == "maybe"
|
|
assert dumped_input[0]["value"] is None
|
|
|
|
|
|
def test_interrupt_node_accepts_canonical_request_and_resume_bindings():
|
|
node = InterruptNode.model_validate(
|
|
{
|
|
"id": "approval",
|
|
"type": "interrupt",
|
|
"kind": "approval",
|
|
"request": [{"target": "summary", "path": "state.summary"}],
|
|
"resume": [{"source": "approved", "target": "state.approved"}],
|
|
}
|
|
)
|
|
|
|
assert isinstance(node.request[0], InputPathBinding)
|
|
assert node.request[0].path == GraphSourcePath.state("summary")
|
|
assert node.request[0].target == LocalPath.of("summary")
|
|
assert node.resume[0].source == LocalPath.of("approved")
|
|
assert node.resume[0].target == StatePath.of("approved")
|
|
|
|
|
|
def test_interrupt_node_converts_old_maps_to_canonical_bindings():
|
|
node = InterruptNode.model_validate(
|
|
{
|
|
"id": "approval",
|
|
"type": "interrupt",
|
|
"kind": "approval",
|
|
"request_map": {"input.message": "message"},
|
|
"out_map": {"approved": "state.approved"},
|
|
}
|
|
)
|
|
|
|
dumped = node.model_dump(mode="json")
|
|
assert "request_map" not in dumped
|
|
assert "out_map" not in dumped
|
|
assert dumped["request"][0]["path"] == "input.message"
|
|
assert dumped["request"][0]["target"] == "message"
|
|
assert dumped["resume"][0]["source"] == "approved"
|
|
assert dumped["resume"][0]["target"] == "state.approved"
|
|
|
|
|
|
def test_interrupt_node_rejects_mixed_old_and_new_binding_styles():
|
|
with pytest.raises(ValidationError):
|
|
InterruptNode.model_validate(
|
|
{
|
|
"id": "approval",
|
|
"type": "interrupt",
|
|
"kind": "approval",
|
|
"request": [{"target": "message", "path": "input.message"}],
|
|
"request_map": {"input.other": "other"},
|
|
}
|
|
)
|
|
|
|
|
|
def test_interrupt_node_defaults_to_untyped_object_contract():
|
|
node = InterruptNode.model_validate(
|
|
{
|
|
"id": "approval",
|
|
"type": "interrupt",
|
|
"kind": "approval",
|
|
}
|
|
)
|
|
|
|
assert node.request_schema == {
|
|
"type": "object",
|
|
"additionalProperties": True,
|
|
}
|
|
assert node.resume_schema == {
|
|
"type": "object",
|
|
"additionalProperties": True,
|
|
}
|
|
assert node.has_explicit_contract is False
|
|
|
|
dumped = node.model_dump(mode="json")
|
|
assert dumped["request_schema"] == {
|
|
"type": "object",
|
|
"additionalProperties": True,
|
|
}
|
|
assert dumped["resume_schema"] == {
|
|
"type": "object",
|
|
"additionalProperties": True,
|
|
}
|
|
|
|
|
|
def test_interrupt_node_accepts_explicit_request_and_resume_schemas():
|
|
node = InterruptNode.model_validate(
|
|
{
|
|
"id": "approval",
|
|
"type": "interrupt",
|
|
"kind": "approval",
|
|
"request_schema": {
|
|
"type": "object",
|
|
"properties": {"message": {"type": "string"}},
|
|
"required": ["message"],
|
|
"additionalProperties": False,
|
|
},
|
|
"resume_schema": {
|
|
"type": "object",
|
|
"properties": {"approved": {"type": "boolean"}},
|
|
"required": ["approved"],
|
|
"additionalProperties": False,
|
|
},
|
|
}
|
|
)
|
|
|
|
assert node.has_explicit_contract is True
|
|
assert node.request_schema["required"] == ["message"]
|
|
assert node.resume_schema["required"] == ["approved"]
|
|
|
|
|
|
def test_interrupt_node_rejects_invalid_json_schema_contract():
|
|
with pytest.raises(ValidationError, match="invalid JSON Schema"):
|
|
InterruptNode.model_validate(
|
|
{
|
|
"id": "approval",
|
|
"type": "interrupt",
|
|
"kind": "approval",
|
|
"resume_schema": {
|
|
"type": "object",
|
|
"properties": {"approved": {"type": "not-a-json-schema-type"}},
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def test_foreach_node_serializes_over_path_as_canonical_string():
|
|
node = ForeachNode.model_validate(
|
|
{
|
|
"id": "each_item",
|
|
"type": "foreach",
|
|
"over": "state.items",
|
|
"as": "item",
|
|
}
|
|
)
|
|
|
|
assert node.over == GraphSourcePath.state("items")
|
|
assert node.model_dump(mode="json")["over"] == "state.items"
|