Files
Dorod-Sky/tests/unit/test_compute_conditional_scopes.py
2026-02-12 20:43:27 -08:00

546 lines
17 KiB
Python

"""Tests for compute_conditional_scopes() function.
This function maps each block label to the conditional block label whose scope it belongs to.
It handles merge-point detection, nested conditionals, and deduplication of branch targets.
"""
from __future__ import annotations
from datetime import datetime, timezone
from skyvern.forge.sdk.workflow.models.block import (
BranchCondition,
ConditionalBlock,
HttpRequestBlock,
TaskBlock,
compute_conditional_scopes,
)
from skyvern.forge.sdk.workflow.models.parameter import OutputParameter
def _make_output_parameter(key: str) -> OutputParameter:
now = datetime.now(tz=timezone.utc)
return OutputParameter(
key=key,
parameter_type="output",
output_parameter_id=f"op_{key}",
workflow_id="wf_test",
created_at=now,
modified_at=now,
)
def _make_task_block(label: str, *, next_block_label: str | None = None) -> TaskBlock:
return TaskBlock(
label=label,
url="https://example.com",
output_parameter=_make_output_parameter(label),
next_block_label=next_block_label,
)
def _make_http_block(label: str, *, next_block_label: str | None = None) -> HttpRequestBlock:
return HttpRequestBlock(
label=label,
url="https://example.com",
method="GET",
output_parameter=_make_output_parameter(label),
next_block_label=next_block_label,
)
def _make_conditional_block(
label: str,
branches: list[tuple[str | None, bool]],
*,
next_block_label: str | None = None,
) -> ConditionalBlock:
"""Create a conditional block with the given branches.
Args:
label: Block label
branches: List of (next_block_label, is_default) tuples
next_block_label: Default next block for the conditional itself (usually None)
"""
branch_conditions = []
for target, is_default in branches:
if is_default:
branch_conditions.append(BranchCondition(next_block_label=target, is_default=True))
else:
branch_conditions.append(
BranchCondition(
next_block_label=target,
criteria={"criteria_type": "jinja2_template", "expression": "{{ true }}"},
)
)
return ConditionalBlock(
label=label,
output_parameter=_make_output_parameter(label),
branch_conditions=branch_conditions,
next_block_label=next_block_label,
)
class TestComputeConditionalScopes:
"""Tests for compute_conditional_scopes()."""
def test_simple_two_branch_conditional_with_merge(self):
"""Test a simple conditional with two branches that merge.
Workflow:
Conditional(C) -> Branch1 -> A -> MergePoint(M)
-> Branch2 -> B -> M
Expected: A and B are scoped to C, M is NOT scoped (merge point).
"""
block_a = _make_task_block("A", next_block_label="M")
block_b = _make_task_block("B", next_block_label="M")
block_m = _make_task_block("M")
cond = _make_conditional_block("C", [("A", False), ("B", True)])
label_to_block = {
"C": cond,
"A": block_a,
"B": block_b,
"M": block_m,
}
default_next_map = {
"C": None,
"A": "M",
"B": "M",
"M": None,
}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
assert scopes == {"A": "C", "B": "C"}
assert "M" not in scopes # M is a merge point
def test_conditional_with_chain_before_merge(self):
"""Test branches with multiple blocks before merge point.
Workflow:
Conditional(C) -> Branch1 -> A -> B -> MergePoint(M)
-> Branch2 -> D -> M
Expected: A, B, D are scoped to C. M is NOT scoped.
"""
block_a = _make_task_block("A", next_block_label="B")
block_b = _make_task_block("B", next_block_label="M")
block_d = _make_task_block("D", next_block_label="M")
block_m = _make_task_block("M")
cond = _make_conditional_block("C", [("A", False), ("D", True)])
label_to_block = {
"C": cond,
"A": block_a,
"B": block_b,
"D": block_d,
"M": block_m,
}
default_next_map = {
"C": None,
"A": "B",
"B": "M",
"D": "M",
"M": None,
}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
assert scopes == {"A": "C", "B": "C", "D": "C"}
assert "M" not in scopes
def test_conditional_with_terminal_branches(self):
"""Test branches that don't merge (terminate independently).
Workflow:
Conditional(C) -> Branch1 -> A (terminal)
-> Branch2 -> B (terminal)
Expected: A and B are scoped to C since they don't appear in all branches.
"""
block_a = _make_task_block("A")
block_b = _make_task_block("B")
cond = _make_conditional_block("C", [("A", False), ("B", True)])
label_to_block = {
"C": cond,
"A": block_a,
"B": block_b,
}
default_next_map = {
"C": None,
"A": None,
"B": None,
}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
assert scopes == {"A": "C", "B": "C"}
def test_conditional_all_branches_terminal_none(self):
"""Test when all branches have None as target (no blocks to scope).
Workflow:
Conditional(C) -> Branch1 -> None
-> Branch2 -> None
Expected: No scopes (no blocks in the branches).
"""
cond = _make_conditional_block("C", [(None, False), (None, True)])
label_to_block = {"C": cond}
default_next_map = {"C": None}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
assert scopes == {}
def test_multiple_branches_same_target_deduplication(self):
"""Test that duplicate branch targets are deduplicated.
Workflow:
Conditional(C) -> Branch1 -> A -> M
-> Branch2 -> A -> M (same as Branch1)
-> Branch3 -> B -> M
With deduplication, unique targets are [A, B], so num_branches = 2.
Both chains go to M, so M is a merge point.
A appears in only one chain (after dedup), B in another.
"""
block_a = _make_task_block("A", next_block_label="M")
block_b = _make_task_block("B", next_block_label="M")
block_m = _make_task_block("M")
cond = _make_conditional_block("C", [("A", False), ("A", False), ("B", True)])
label_to_block = {
"C": cond,
"A": block_a,
"B": block_b,
"M": block_m,
}
default_next_map = {
"C": None,
"A": "M",
"B": "M",
"M": None,
}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
# A and B are scoped to C, M is the merge point
assert scopes == {"A": "C", "B": "C"}
assert "M" not in scopes
def test_nested_conditionals(self):
"""Test nested conditionals (conditional inside another's branch).
Workflow:
OuterCond(C1) -> Branch1 -> InnerCond(C2) -> BranchA -> X
-> BranchB -> Y
-> Branch2 -> Z -> MergePoint(M)
Expected:
- C2 is scoped to C1 (it's in C1's branch)
- X and Y are scoped to C2 (inner conditional handles its own branches)
- Z is scoped to C1
- M might or might not be scoped depending on structure
"""
block_x = _make_task_block("X")
block_y = _make_task_block("Y")
block_z = _make_task_block("Z", next_block_label="M")
block_m = _make_task_block("M")
inner_cond = _make_conditional_block("C2", [("X", False), ("Y", True)])
outer_cond = _make_conditional_block("C1", [("C2", False), ("Z", True)])
label_to_block = {
"C1": outer_cond,
"C2": inner_cond,
"X": block_x,
"Y": block_y,
"Z": block_z,
"M": block_m,
}
default_next_map = {
"C1": None,
"C2": None, # Inner conditional doesn't have a default next
"X": None,
"Y": None,
"Z": "M",
"M": None,
}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
# C2 is scoped to C1 (it's in C1's branch, and tracing stops at C2)
assert scopes.get("C2") == "C1"
# X and Y are scoped to C2 (inner conditional)
assert scopes.get("X") == "C2"
assert scopes.get("Y") == "C2"
# Z is scoped to C1
assert scopes.get("Z") == "C1"
def test_no_conditionals_in_workflow(self):
"""Test workflow with no conditional blocks.
Workflow:
A -> B -> C
Expected: No scopes.
"""
block_a = _make_task_block("A", next_block_label="B")
block_b = _make_task_block("B", next_block_label="C")
block_c = _make_task_block("C")
label_to_block = {
"A": block_a,
"B": block_b,
"C": block_c,
}
default_next_map = {
"A": "B",
"B": "C",
"C": None,
}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
assert scopes == {}
def test_conditional_with_single_branch(self):
"""Test conditional with effectively one unique branch target.
Workflow:
Conditional(C) -> Branch1 -> A
-> Branch2 -> A (same target, deduplicated)
After deduplication, num_branches = 1, and A appears in 1/1 chains,
making it a "merge point" (appears in all branches).
"""
block_a = _make_task_block("A")
cond = _make_conditional_block("C", [("A", False), ("A", True)])
label_to_block = {
"C": cond,
"A": block_a,
}
default_next_map = {
"C": None,
"A": None,
}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
# A appears in all (1) branch chains, so it's treated as a merge point
assert scopes == {}
def test_three_branch_conditional_partial_merge(self):
"""Test three branches where only some merge.
Workflow:
Conditional(C) -> Branch1 -> A -> M
-> Branch2 -> B -> M
-> Branch3 -> D (terminal, no merge)
M appears in 2/3 branches, so it's NOT a merge point.
All of A, B, D, M should be scoped to C.
"""
block_a = _make_task_block("A", next_block_label="M")
block_b = _make_task_block("B", next_block_label="M")
block_d = _make_task_block("D")
block_m = _make_task_block("M")
cond = _make_conditional_block("C", [("A", False), ("B", False), ("D", True)])
label_to_block = {
"C": cond,
"A": block_a,
"B": block_b,
"D": block_d,
"M": block_m,
}
default_next_map = {
"C": None,
"A": "M",
"B": "M",
"D": None,
"M": None,
}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
# M only appears in 2/3 branches, so it's still inside the conditional scope
assert scopes == {"A": "C", "B": "C", "D": "C", "M": "C"}
def test_merge_point_with_blocks_after(self):
"""Test that blocks after the merge point are not scoped.
Workflow:
Conditional(C) -> Branch1 -> A -> M -> X -> Y
-> Branch2 -> B -> M
M is the merge point (appears in both chains).
X and Y come after M and should NOT be scoped.
"""
block_a = _make_task_block("A", next_block_label="M")
block_b = _make_task_block("B", next_block_label="M")
block_m = _make_task_block("M", next_block_label="X")
block_x = _make_task_block("X", next_block_label="Y")
block_y = _make_task_block("Y")
cond = _make_conditional_block("C", [("A", False), ("B", True)])
label_to_block = {
"C": cond,
"A": block_a,
"B": block_b,
"M": block_m,
"X": block_x,
"Y": block_y,
}
default_next_map = {
"C": None,
"A": "M",
"B": "M",
"M": "X",
"X": "Y",
"Y": None,
}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
# A and B are scoped, M and everything after is NOT
assert scopes == {"A": "C", "B": "C"}
assert "M" not in scopes
assert "X" not in scopes
assert "Y" not in scopes
def test_branch_to_nonexistent_block(self):
"""Test graceful handling when branch targets a non-existent block.
This shouldn't happen in practice (validation catches it), but the
function should handle it gracefully.
"""
cond = _make_conditional_block("C", [("MISSING", False), ("A", True)])
block_a = _make_task_block("A")
label_to_block = {
"C": cond,
"A": block_a,
}
default_next_map = {
"C": None,
"A": None,
}
# Should not raise, MISSING just won't be in the results
scopes = compute_conditional_scopes(label_to_block, default_next_map)
# Only A is scoped (MISSING is not in label_to_block)
assert scopes == {"A": "C"}
def test_empty_workflow(self):
"""Test with empty inputs."""
scopes = compute_conditional_scopes({}, {})
assert scopes == {}
def test_conditional_only_no_other_blocks(self):
"""Test with only a conditional block and no branch targets.
Workflow:
Conditional(C) -> Branch1 -> None
-> Branch2 -> None
"""
cond = _make_conditional_block("C", [(None, False), (None, True)])
label_to_block = {"C": cond}
default_next_map = {"C": None}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
assert scopes == {}
def test_asymmetric_branch_lengths(self):
"""Test branches with significantly different chain lengths.
Workflow:
Conditional(C) -> Branch1 -> A -> B -> C2 -> D -> M
-> Branch2 -> M
Branch1 has a long chain, Branch2 goes directly to M.
M is the only block in both chains, so it's the merge point.
"""
block_a = _make_task_block("A", next_block_label="B")
block_b = _make_task_block("B", next_block_label="C2")
block_c2 = _make_task_block("C2", next_block_label="D")
block_d = _make_task_block("D", next_block_label="M")
block_m = _make_task_block("M")
cond = _make_conditional_block("C", [("A", False), ("M", True)])
label_to_block = {
"C": cond,
"A": block_a,
"B": block_b,
"C2": block_c2,
"D": block_d,
"M": block_m,
}
default_next_map = {
"C": None,
"A": "B",
"B": "C2",
"C2": "D",
"D": "M",
"M": None,
}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
# A, B, C2, D are in Branch1 only, so they're scoped
# M appears in both branches, so it's the merge point
assert scopes == {"A": "C", "B": "C", "C2": "C", "D": "C"}
assert "M" not in scopes
def test_multiple_independent_conditionals(self):
"""Test multiple conditionals at the same level (not nested).
Workflow:
C1 -> Branch1 -> A
-> Branch2 -> B
(after C1) -> C2 -> Branch1 -> X
-> Branch2 -> Y
"""
block_a = _make_task_block("A", next_block_label="C2")
block_b = _make_task_block("B", next_block_label="C2")
block_x = _make_task_block("X")
block_y = _make_task_block("Y")
cond1 = _make_conditional_block("C1", [("A", False), ("B", True)])
cond2 = _make_conditional_block("C2", [("X", False), ("Y", True)])
label_to_block = {
"C1": cond1,
"C2": cond2,
"A": block_a,
"B": block_b,
"X": block_x,
"Y": block_y,
}
default_next_map = {
"C1": None,
"C2": None,
"A": "C2",
"B": "C2",
"X": None,
"Y": None,
}
scopes = compute_conditional_scopes(label_to_block, default_next_map)
# A and B are scoped to C1
# C2 is the merge point for C1 (appears in both A and B chains)
# X and Y are scoped to C2
assert scopes.get("A") == "C1"
assert scopes.get("B") == "C1"
assert "C2" not in scopes # C2 is a merge point for C1
assert scopes.get("X") == "C2"
assert scopes.get("Y") == "C2"