fix: complete ref handling with composition, siblings, recursion
This commit is contained in:
@@ -424,3 +424,105 @@ def test_scoped_cycle_terminates_and_preserves_scoped_field_availability() -> No
|
||||
# A canonical back-edge pops the item stack, so the controller itself
|
||||
# stays in the outer region and exposes no item alias.
|
||||
assert "item" not in _field_map(workflow, "each")
|
||||
|
||||
|
||||
def _composer_state_schema() -> dict[str, object]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"orders_list": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Order"},
|
||||
},
|
||||
},
|
||||
"$defs": {
|
||||
"Order": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sku": {"type": "string"},
|
||||
"nick": {
|
||||
"$ref": "#/$defs/Detail",
|
||||
"description": "Short display name",
|
||||
"properties": {"label": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Detail": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _composer_workflow() -> Workflow:
|
||||
return _workflow(
|
||||
start="orders",
|
||||
nodes=[
|
||||
_foreach("orders", over="state.orders_list", alias="order"),
|
||||
_node("body"),
|
||||
],
|
||||
edges=[
|
||||
{"from": "orders", "outcome": "loop", "to": "body"},
|
||||
{"from": "body", "outcome": "ok", "to": "orders"},
|
||||
{"from": "orders", "outcome": "done", "to": END},
|
||||
],
|
||||
state_schema=_composer_state_schema(),
|
||||
)
|
||||
|
||||
|
||||
def test_ref_sibling_metadata_survives_inlining() -> None:
|
||||
from wf_core.analysis.context_scopes import context_schema_for_node
|
||||
|
||||
schema = context_schema_for_node(_composer_workflow(), "body")
|
||||
item = schema["properties"]["foreach"]["properties"]["orders"]["properties"][
|
||||
"item"
|
||||
]
|
||||
assert set(item["properties"]) == {"sku", "nick"}
|
||||
nick = item["properties"]["nick"]
|
||||
assert nick["description"] == "Short display name"
|
||||
assert set(nick["properties"]) == {"name", "label"}
|
||||
|
||||
|
||||
def test_recursive_item_schema_carries_definitions() -> None:
|
||||
from wf_core.analysis.context_scopes import context_schema_for_node
|
||||
|
||||
workflow = _workflow(
|
||||
start="cats",
|
||||
nodes=[
|
||||
_foreach("cats", over="state.cats", alias="cat"),
|
||||
_node("body"),
|
||||
],
|
||||
edges=[
|
||||
{"from": "cats", "outcome": "loop", "to": "body"},
|
||||
{"from": "body", "outcome": "ok", "to": "cats"},
|
||||
{"from": "cats", "outcome": "done", "to": END},
|
||||
],
|
||||
state_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cats": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Category"},
|
||||
},
|
||||
},
|
||||
"$defs": {
|
||||
"Category": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"parent": {
|
||||
"anyOf": [
|
||||
{"$ref": "#/$defs/Category"},
|
||||
{"type": "null"},
|
||||
]
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
schema = context_schema_for_node(workflow, "body")
|
||||
item = schema["properties"]["foreach"]["properties"]["cats"]["properties"]["item"]
|
||||
# The cut recursion keeps its definitions table instead of a bare $ref.
|
||||
assert item["$defs"]["Category"]["properties"]["name"] == {"type": "string"}
|
||||
|
||||
@@ -592,3 +592,143 @@ def test_nested_ref_item_unknown_leaf_reports_available_keys() -> None:
|
||||
assert issue is not None
|
||||
assert "'bogus'" in issue.message
|
||||
assert "available: name" in issue.message
|
||||
|
||||
|
||||
def _composer_ref_workflow(*, work_path: str) -> Workflow:
|
||||
"""State schema with anyOf-optional, sibling-extended, recursive shapes."""
|
||||
workflow = _base_workflow(work_path=work_path)
|
||||
workflow.state_schema = StateSchema.model_validate(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {"type": "array", "items": {"type": "string"}},
|
||||
"orders_list": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Order"},
|
||||
},
|
||||
},
|
||||
"$defs": {
|
||||
"Order": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sku": {"type": "string"},
|
||||
"detail": {
|
||||
"anyOf": [
|
||||
{"$ref": "#/$defs/Detail"},
|
||||
{"type": "null"},
|
||||
]
|
||||
},
|
||||
"nick": {
|
||||
"$ref": "#/$defs/Detail",
|
||||
"description": "Short display name",
|
||||
"properties": {"label": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
"required": ["sku"],
|
||||
},
|
||||
"Detail": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
return workflow
|
||||
|
||||
|
||||
def test_optional_nested_model_subpath_is_accepted() -> None:
|
||||
from wf_core.validation import validate_workflow
|
||||
|
||||
workflow = _composer_ref_workflow(
|
||||
work_path="context.foreach.orders.item.detail.name"
|
||||
)
|
||||
report = validate_workflow(workflow)
|
||||
assert [
|
||||
issue
|
||||
for issue in report.errors
|
||||
if issue.code == ValidationIssueCode.INVALID_CONTEXT_PATH
|
||||
] == []
|
||||
|
||||
|
||||
def test_ref_sibling_properties_extend_the_target() -> None:
|
||||
from wf_core.validation import validate_workflow
|
||||
|
||||
for work_path in (
|
||||
"context.foreach.orders.item.nick.name",
|
||||
"context.foreach.orders.item.nick.label",
|
||||
):
|
||||
workflow = _composer_ref_workflow(work_path=work_path)
|
||||
report = validate_workflow(workflow)
|
||||
assert [
|
||||
issue
|
||||
for issue in report.errors
|
||||
if issue.code == ValidationIssueCode.INVALID_CONTEXT_PATH
|
||||
] == []
|
||||
|
||||
|
||||
def _recursive_ref_workflow(*, work_path: str) -> Workflow:
|
||||
workflow = _base_workflow(work_path=work_path)
|
||||
workflow.state_schema = StateSchema.model_validate(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {"type": "array", "items": {"type": "string"}},
|
||||
"orders_list": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Category"},
|
||||
},
|
||||
},
|
||||
"$defs": {
|
||||
"Category": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"parent": {
|
||||
"anyOf": [
|
||||
{"$ref": "#/$defs/Category"},
|
||||
{"type": "null"},
|
||||
]
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Category"},
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
return workflow
|
||||
|
||||
|
||||
def test_recursive_ref_paths_validate_through_definitions() -> None:
|
||||
from wf_core.validation import validate_workflow
|
||||
|
||||
for work_path in (
|
||||
"context.foreach.orders.item.parent.name",
|
||||
"context.foreach.orders.item.children",
|
||||
):
|
||||
workflow = _recursive_ref_workflow(work_path=work_path)
|
||||
report = validate_workflow(workflow)
|
||||
assert [
|
||||
issue
|
||||
for issue in report.errors
|
||||
if issue.code == ValidationIssueCode.INVALID_CONTEXT_PATH
|
||||
] == []
|
||||
|
||||
|
||||
def test_recursive_ref_unknown_leaf_reports_defined_keys() -> None:
|
||||
from wf_core.validation import validate_workflow
|
||||
|
||||
workflow = _recursive_ref_workflow(
|
||||
work_path="context.foreach.orders.item.parent.bogus"
|
||||
)
|
||||
report = validate_workflow(workflow)
|
||||
issue = _issue(
|
||||
report, ValidationIssueCode.INVALID_CONTEXT_PATH, "nodes[2].input[0].path"
|
||||
)
|
||||
assert issue is not None
|
||||
assert "'bogus'" in issue.message
|
||||
assert "available: children,name,parent" in issue.message
|
||||
|
||||
@@ -586,3 +586,134 @@ def test_root_inventory_offers_foreach_map() -> None:
|
||||
paths = {option["path"] for option in options}
|
||||
assert "context.foreach" in paths
|
||||
assert "context.prior_outcome" in paths
|
||||
|
||||
|
||||
def _composer_inventory_workflow():
|
||||
from wf_core import END, Edge, ForeachNode, NodeUse, SchemaRef, Workflow
|
||||
from wf_core.models.schemas import StateSchema
|
||||
|
||||
return Workflow(
|
||||
name="inventory_composer_refs",
|
||||
input_schema=SchemaRef(type="object"),
|
||||
state_schema=StateSchema.model_validate(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"orders": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Order"},
|
||||
},
|
||||
},
|
||||
"$defs": {
|
||||
"Order": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sku": {"type": "string"},
|
||||
"detail": {
|
||||
"anyOf": [
|
||||
{"$ref": "#/$defs/Detail"},
|
||||
{"type": "null"},
|
||||
]
|
||||
},
|
||||
"nick": {
|
||||
"$ref": "#/$defs/Detail",
|
||||
"description": "Short display name",
|
||||
"properties": {"label": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Detail": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
output_schema=SchemaRef(type="object"),
|
||||
start="orders",
|
||||
nodes=[
|
||||
ForeachNode.model_validate(
|
||||
{"id": "orders", "type": "foreach", "over": "state.orders", "as": "order"}
|
||||
),
|
||||
NodeUse(id="body", type="node", node="noop"),
|
||||
],
|
||||
edges=[
|
||||
Edge.model_validate({"from": "orders", "outcome": "loop", "to": "body"}),
|
||||
Edge.model_validate({"from": "body", "outcome": "ok", "to": "orders"}),
|
||||
Edge.model_validate({"from": "orders", "outcome": "done", "to": END}),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_composer_ref_children_appear_in_authoring_inventory() -> None:
|
||||
from wf_api.authoring_contracts import context_path_options_for_node
|
||||
|
||||
options = context_path_options_for_node(_composer_inventory_workflow(), "body")
|
||||
paths = {option["path"] for option in options}
|
||||
assert "context.foreach.orders.item.detail.name" in paths
|
||||
assert "context.foreach.orders.item.nick.name" in paths
|
||||
assert "context.foreach.orders.item.nick.label" in paths
|
||||
|
||||
|
||||
def _recursive_inventory_workflow():
|
||||
from wf_core import END, Edge, ForeachNode, NodeUse, SchemaRef, Workflow
|
||||
from wf_core.models.schemas import StateSchema
|
||||
|
||||
return Workflow(
|
||||
name="inventory_recursive_refs",
|
||||
input_schema=SchemaRef(type="object"),
|
||||
state_schema=StateSchema.model_validate(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cats": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Category"},
|
||||
},
|
||||
},
|
||||
"$defs": {
|
||||
"Category": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"parent": {
|
||||
"anyOf": [
|
||||
{"$ref": "#/$defs/Category"},
|
||||
{"type": "null"},
|
||||
]
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Category"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
output_schema=SchemaRef(type="object"),
|
||||
start="cats",
|
||||
nodes=[
|
||||
ForeachNode.model_validate(
|
||||
{"id": "cats", "type": "foreach", "over": "state.cats", "as": "cat"}
|
||||
),
|
||||
NodeUse(id="body", type="node", node="noop"),
|
||||
],
|
||||
edges=[
|
||||
Edge.model_validate({"from": "cats", "outcome": "loop", "to": "body"}),
|
||||
Edge.model_validate({"from": "body", "outcome": "ok", "to": "cats"}),
|
||||
Edge.model_validate({"from": "cats", "outcome": "done", "to": END}),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_recursive_ref_inventory_stays_bounded() -> None:
|
||||
from wf_api.authoring_contracts import context_path_options_for_node
|
||||
|
||||
options = context_path_options_for_node(_recursive_inventory_workflow(), "body")
|
||||
paths = {option["path"] for option in options}
|
||||
assert "context.foreach.cats.item.parent.name" in paths
|
||||
assert "context.foreach.cats.item.children" in paths
|
||||
# The repeated reference stays selectable but is not expanded again.
|
||||
assert "context.foreach.cats.item.parent.parent" in paths
|
||||
assert "context.foreach.cats.item.parent.parent.name" not in paths
|
||||
|
||||
Reference in New Issue
Block a user