234 lines
7.4 KiB
Python
234 lines
7.4 KiB
Python
"""Typed occurrence expressions: schedule-only leaves, shared composition (T04)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from wf_core.errors import WorkflowExecutionError
|
|
from wf_core.models.input_bindings import (
|
|
InputExpressionBinding,
|
|
ScheduleInputBinding,
|
|
validate_input_expression_limits,
|
|
)
|
|
from wf_core.paths import GraphSourcePath
|
|
from wf_core.runtime.input_sources import (
|
|
resolve_schedule_expression,
|
|
resolve_schedule_input_bindings,
|
|
)
|
|
|
|
OCC = {
|
|
"schedule_id": "sched-1",
|
|
"occurrence_id": "sched-1|2026-09-08T13:00:00+00:00",
|
|
"scheduled_at": "2026-09-08T13:00:00+00:00",
|
|
}
|
|
|
|
|
|
def test_graph_union_still_rejects_occurrence_kind() -> None:
|
|
with pytest.raises(ValidationError):
|
|
InputExpressionBinding.model_validate(
|
|
{
|
|
"target": "x",
|
|
"expression": {"kind": "occurrence", "field": "scheduled_at"},
|
|
}
|
|
)
|
|
|
|
|
|
def test_graph_roots_stay_closed() -> None:
|
|
with pytest.raises(ValueError):
|
|
GraphSourcePath.parse("occurrence.scheduled_at")
|
|
|
|
|
|
def test_schedule_binding_json_round_trip() -> None:
|
|
binding = ScheduleInputBinding.model_validate(
|
|
{
|
|
"target": "report_time",
|
|
"expression": {"kind": "occurrence", "field": "scheduled_at"},
|
|
}
|
|
)
|
|
assert binding.model_dump(mode="json") == {
|
|
"target": "report_time",
|
|
"expression": {"kind": "occurrence", "field": "scheduled_at"},
|
|
}
|
|
literal = ScheduleInputBinding.model_validate(
|
|
{"target": "team", "expression": {"kind": "literal", "value": "eng"}}
|
|
)
|
|
assert literal.expression.model_dump(mode="json")["value"] == "eng"
|
|
|
|
|
|
def test_literal_and_nested_occurrence_composition() -> None:
|
|
binding = ScheduleInputBinding.model_validate(
|
|
{
|
|
"target": "payload",
|
|
"expression": {
|
|
"kind": "object",
|
|
"fields": {
|
|
"team": {"kind": "literal", "value": "eng"},
|
|
"when": {"kind": "occurrence", "field": "scheduled_at"},
|
|
"tags": {
|
|
"kind": "array",
|
|
"items": [
|
|
{"kind": "occurrence", "field": "schedule_id"},
|
|
{"kind": "literal", "value": "x"},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
)
|
|
resolved = resolve_schedule_input_bindings(
|
|
[binding], occurrence=OCC, label="schedule sched-1"
|
|
)
|
|
assert resolved == {
|
|
"payload": {
|
|
"team": "eng",
|
|
"when": "2026-09-08T13:00:00+00:00",
|
|
"tags": ["sched-1", "x"],
|
|
}
|
|
}
|
|
|
|
|
|
def test_missing_occurrence_field_fails_before_dispatch() -> None:
|
|
binding = ScheduleInputBinding.model_validate(
|
|
{
|
|
"target": "x",
|
|
"expression": {"kind": "occurrence", "field": "scheduled_at"},
|
|
}
|
|
)
|
|
with pytest.raises(WorkflowExecutionError, match="unknown occurrence field"):
|
|
resolve_schedule_input_bindings(
|
|
[binding], occurrence={"schedule_id": "s"}, label="schedule s"
|
|
)
|
|
|
|
|
|
def test_graph_only_path_is_invalid_in_schedule_bindings() -> None:
|
|
with pytest.raises(ValidationError):
|
|
ScheduleInputBinding.model_validate(
|
|
{
|
|
"target": "x",
|
|
"expression": {"kind": "path", "path": "input.a"},
|
|
}
|
|
)
|
|
|
|
|
|
def test_nested_graph_path_is_invalid_in_schedule_bindings() -> None:
|
|
with pytest.raises(ValidationError):
|
|
ScheduleInputBinding.model_validate(
|
|
{
|
|
"target": "x",
|
|
"expression": {
|
|
"kind": "object",
|
|
"fields": {
|
|
"nested": {"kind": "path", "path": "input.a"},
|
|
},
|
|
},
|
|
}
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
ScheduleInputBinding.model_validate(
|
|
{
|
|
"target": "x",
|
|
"expression": {
|
|
"kind": "array",
|
|
"items": [{"kind": "path", "path": "state.b"}],
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def test_nested_occurrence_is_invalid_in_graph_bindings() -> None:
|
|
from wf_core.models.input_bindings import (
|
|
ArrayExpression,
|
|
ObjectExpression,
|
|
)
|
|
|
|
with pytest.raises(ValidationError):
|
|
ArrayExpression.model_validate(
|
|
{
|
|
"kind": "array",
|
|
"items": [{"kind": "occurrence", "field": "scheduled_at"}],
|
|
}
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
ObjectExpression.model_validate(
|
|
{
|
|
"kind": "object",
|
|
"fields": {"when": {"kind": "occurrence", "field": "scheduled_at"}},
|
|
}
|
|
)
|
|
|
|
|
|
def test_invalid_field_rejected() -> None:
|
|
with pytest.raises(ValidationError):
|
|
ScheduleInputBinding.model_validate(
|
|
{
|
|
"target": "x",
|
|
"expression": {"kind": "occurrence", "field": "nope"},
|
|
}
|
|
)
|
|
|
|
|
|
def test_over_budget_schedule_expression_rejected() -> None:
|
|
deep: dict = {"kind": "literal", "value": 0}
|
|
for _ in range(70):
|
|
deep = {"kind": "array", "items": [deep]}
|
|
with pytest.raises(ValueError, match="limit exceeded"):
|
|
validate_input_expression_limits(deep)
|
|
with pytest.raises(ValueError, match="limit exceeded"):
|
|
ScheduleInputBinding.model_validate({"target": "x", "expression": deep})
|
|
|
|
|
|
def test_conflicting_targets_rejected() -> None:
|
|
first = ScheduleInputBinding.model_validate(
|
|
{"target": "a.b", "expression": {"kind": "literal", "value": 1}}
|
|
)
|
|
second = ScheduleInputBinding.model_validate(
|
|
{"target": "a.b.c", "expression": {"kind": "literal", "value": 2}}
|
|
)
|
|
with pytest.raises(WorkflowExecutionError, match="overlap"):
|
|
resolve_schedule_input_bindings(
|
|
[first, second], occurrence=OCC, label="schedule s"
|
|
)
|
|
|
|
|
|
def test_invalid_resolved_input_fails_schema_check() -> None:
|
|
from wf_core.runtime.ops.schemas import validate_payload_against_schema
|
|
|
|
binding = ScheduleInputBinding.model_validate(
|
|
{"target": "count", "expression": {"kind": "literal", "value": "not-an-int"}}
|
|
)
|
|
resolved = resolve_schedule_input_bindings(
|
|
[binding], occurrence=OCC, label="schedule s"
|
|
)
|
|
with pytest.raises(WorkflowExecutionError, match="count"):
|
|
validate_payload_against_schema(
|
|
{"type": "object", "properties": {"count": {"type": "integer"}}},
|
|
resolved,
|
|
"schedule s input",
|
|
)
|
|
|
|
|
|
def test_direct_schedule_expression_resolves_literal() -> None:
|
|
expr = ScheduleInputBinding.model_validate(
|
|
{"target": "x", "expression": {"kind": "literal", "value": 1}}
|
|
).expression
|
|
assert (
|
|
resolve_schedule_expression(expr, occurrence=OCC, label="s", location="x") == 1
|
|
)
|
|
|
|
|
|
def test_graph_path_leaf_rejected_inside_schedule_traversal() -> None:
|
|
from wf_core.models.input_bindings import PathExpression
|
|
from wf_core.runtime.input_sources import MappingSourceResolver
|
|
|
|
graph_leaf = PathExpression.model_validate(
|
|
{"kind": "path", "path": "input.a"}
|
|
)
|
|
with pytest.raises(WorkflowExecutionError, match="graph paths are invalid"):
|
|
resolve_schedule_expression(
|
|
graph_leaf, # type: ignore[arg-type]
|
|
occurrence=MappingSourceResolver(OCC),
|
|
label="s",
|
|
location="x",
|
|
)
|