import pytest from pydantic import ValidationError from wf_core.models.steps import ( ForeachNode, 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_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"] 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"