546 lines
17 KiB
Python
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"
|