sched: add typed occurrence expression kind for schedule inputs (T04)
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""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_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(Exception, match="count"):
|
||||
validate_payload_against_schema(
|
||||
{"type": "object", "properties": {"count": {"type": "integer"}}},
|
||||
resolved,
|
||||
"schedule s input",
|
||||
)
|
||||
|
||||
|
||||
def test_direct_schedule_expression_resolution_rejects_graph_paths() -> 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
|
||||
)
|
||||
Reference in New Issue
Block a user