sched: add typed occurrence expression kind for schedule inputs (T04)

This commit is contained in:
lda
2026-09-08 10:09:31 +07:00 Verified
parent 352686128b
commit 0ded9b0d40
3 changed files with 323 additions and 2 deletions
@@ -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
)