"""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", )