ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
382 lines
12 KiB
Python
382 lines
12 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Tests for Graph parser utility."""
|
|
|
|
from google.adk.workflow import Edge
|
|
from google.adk.workflow import FunctionNode
|
|
from google.adk.workflow import START
|
|
from google.adk.workflow._graph import DEFAULT_ROUTE
|
|
from google.adk.workflow.utils._graph_parser import parse_edge_items
|
|
import pytest
|
|
|
|
from ..workflow_testing_utils import TestingNode
|
|
|
|
|
|
def test_parse_edge_items_with_node_reuse() -> None:
|
|
"""Tests that node reuse during parsing works and deduplicates wrapped nodes."""
|
|
|
|
def my_node_func() -> None:
|
|
pass
|
|
|
|
node_b = TestingNode(name='NodeB')
|
|
edges = parse_edge_items([
|
|
(START, my_node_func),
|
|
(my_node_func, node_b),
|
|
])
|
|
|
|
assert len(edges) == 2
|
|
edge1, edge2 = edges
|
|
assert edge1.from_node == START
|
|
assert isinstance(edge1.to_node, FunctionNode)
|
|
assert edge1.to_node.name == 'my_node_func'
|
|
|
|
assert isinstance(edge2.from_node, FunctionNode)
|
|
assert edge2.from_node.name == 'my_node_func'
|
|
assert edge2.to_node == node_b
|
|
|
|
# Verify exact same object instance was reused
|
|
assert edge1.to_node is edge2.from_node
|
|
|
|
|
|
def test_routing_map_basic() -> None:
|
|
"""Tests that a string-keyed routing map expands to correct edges."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
node_c = TestingNode(name='NodeC')
|
|
edges = parse_edge_items([
|
|
(START, node_a),
|
|
(node_a, {'route_b': node_b, 'route_c': node_c}),
|
|
])
|
|
|
|
assert len(edges) == 3 # START->A, A->B(route_b), A->C(route_c)
|
|
|
|
routed_edges = [e for e in edges if e.route is not None]
|
|
assert len(routed_edges) == 2
|
|
|
|
routes_and_targets = {(e.route, e.to_node.name) for e in routed_edges}
|
|
assert routes_and_targets == {('route_b', 'NodeB'), ('route_c', 'NodeC')}
|
|
|
|
for e in routed_edges:
|
|
assert e.from_node.name == 'NodeA'
|
|
|
|
|
|
def test_routing_map_int_keys() -> None:
|
|
"""Tests that integer route keys work in routing maps."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
node_c = TestingNode(name='NodeC')
|
|
edges = parse_edge_items([
|
|
(START, node_a),
|
|
(node_a, {1: node_b, 2: node_c}),
|
|
])
|
|
|
|
routed_edges = [e for e in edges if e.route is not None]
|
|
assert len(routed_edges) == 2
|
|
routes = [e.route for e in routed_edges]
|
|
assert 1 in routes
|
|
assert 2 in routes
|
|
|
|
|
|
def test_routing_map_bool_keys() -> None:
|
|
"""Tests that boolean route keys work in routing maps."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
node_c = TestingNode(name='NodeC')
|
|
edges = parse_edge_items([
|
|
(START, node_a),
|
|
(node_a, {True: node_b, False: node_c}),
|
|
])
|
|
|
|
routed_edges = [e for e in edges if e.route is not None]
|
|
assert len(routed_edges) == 2
|
|
routes = [e.route for e in routed_edges]
|
|
assert True in routes
|
|
assert False in routes
|
|
|
|
|
|
def test_routing_map_with_fan_in_source() -> None:
|
|
"""Tests that fan-in on the source side works with routing maps."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
node_c = TestingNode(name='NodeC')
|
|
node_d = TestingNode(name='NodeD')
|
|
edges = parse_edge_items([
|
|
(START, node_a),
|
|
(START, node_b),
|
|
((node_a, node_b), {'route_x': node_c, 'route_y': node_d}),
|
|
])
|
|
|
|
# 2 from START + 4 from fan-in (A->C, A->D, B->C, B->D)
|
|
assert len(edges) == 6
|
|
|
|
fan_in_edges = [e for e in edges if e.from_node.name in ('NodeA', 'NodeB')]
|
|
assert len(fan_in_edges) == 4
|
|
|
|
combos = {(e.from_node.name, e.to_node.name, e.route) for e in fan_in_edges}
|
|
assert combos == {
|
|
('NodeA', 'NodeC', 'route_x'),
|
|
('NodeA', 'NodeD', 'route_y'),
|
|
('NodeB', 'NodeC', 'route_x'),
|
|
('NodeB', 'NodeD', 'route_y'),
|
|
}
|
|
|
|
|
|
def test_routing_map_with_callable_target() -> None:
|
|
"""Tests that callable values in routing maps get wrapped via build_node."""
|
|
node_a = TestingNode(name='NodeA')
|
|
|
|
def my_target_func() -> None:
|
|
pass
|
|
|
|
edges = parse_edge_items([
|
|
(START, node_a),
|
|
(node_a, {'route_x': my_target_func}),
|
|
])
|
|
|
|
target_edge = next(e for e in edges if e.route == 'route_x')
|
|
assert isinstance(target_edge.to_node, FunctionNode)
|
|
assert target_edge.to_node.name == 'my_target_func'
|
|
|
|
|
|
def test_routing_map_node_reuse() -> None:
|
|
"""Tests that the same callable used in a map and elsewhere is deduplicated."""
|
|
|
|
def my_func() -> None:
|
|
pass
|
|
|
|
node_b = TestingNode(name='NodeB')
|
|
edges = parse_edge_items([
|
|
(START, my_func),
|
|
(my_func, {'route_x': node_b}),
|
|
])
|
|
|
|
# my_func should be wrapped once and reused.
|
|
assert len(edges) == 2
|
|
assert edges[0].to_node is edges[1].from_node
|
|
assert isinstance(edges[0].to_node, FunctionNode)
|
|
|
|
|
|
def test_routing_map_empty_dict_raises() -> None:
|
|
"""Tests that an empty routing map raises ValueError."""
|
|
node_a = TestingNode(name='NodeA')
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r'Routing map must not be empty',
|
|
):
|
|
parse_edge_items([
|
|
(START, node_a),
|
|
(node_a, {}),
|
|
])
|
|
|
|
|
|
def test_routing_map_invalid_key_raises() -> None:
|
|
"""Tests that a non-RouteValue key raises ValueError."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r'Invalid routing map key',
|
|
):
|
|
parse_edge_items([
|
|
(START, node_a),
|
|
(node_a, {1.5: node_b}),
|
|
])
|
|
|
|
|
|
def test_routing_map_invalid_value_raises() -> None:
|
|
"""Tests that a non-NodeLike value raises ValueError."""
|
|
node_a = TestingNode(name='NodeA')
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r'Invalid routing map value',
|
|
):
|
|
parse_edge_items([
|
|
(START, node_a),
|
|
(node_a, {'route_x': 42}),
|
|
])
|
|
|
|
|
|
def test_routing_map_fan_out_target() -> None:
|
|
"""Tests that a tuple value in a routing map creates fan-out edges."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
node_c = TestingNode(name='NodeC')
|
|
edges = parse_edge_items([
|
|
(START, node_a),
|
|
(node_a, {'route_x': (node_b, node_c)}),
|
|
])
|
|
|
|
# START->A, A->B(route_x), A->C(route_x)
|
|
assert len(edges) == 3
|
|
|
|
routed_edges = [e for e in edges if e.route is not None]
|
|
assert len(routed_edges) == 2
|
|
|
|
# Both fan-out edges share the same route and source.
|
|
for e in routed_edges:
|
|
assert e.from_node.name == 'NodeA'
|
|
assert e.route == 'route_x'
|
|
|
|
targets = {e.to_node.name for e in routed_edges}
|
|
assert targets == {'NodeB', 'NodeC'}
|
|
|
|
|
|
def test_routing_map_fan_out_invalid_element_raises() -> None:
|
|
"""Tests that a non-NodeLike element inside a fan-out tuple raises."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r'Invalid node in fan-out tuple',
|
|
):
|
|
parse_edge_items([
|
|
(START, node_a),
|
|
(node_a, {'route_x': (node_b, 42)}),
|
|
])
|
|
|
|
|
|
# --- Routing map as chain element tests ---
|
|
|
|
|
|
def test_routing_map_chain_ending_with_dict() -> None:
|
|
"""Tests a chain ending with a routing map creates correct edges."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
node_c = TestingNode(name='NodeC')
|
|
edges = parse_edge_items([
|
|
(START, node_a, {'r1': node_b, 'r2': node_c}),
|
|
])
|
|
|
|
# START->A (None), A->B (r1), A->C (r2)
|
|
assert len(edges) == 3
|
|
|
|
start_edge = next(e for e in edges if e.from_node.name == '__START__')
|
|
assert start_edge.to_node.name == 'NodeA'
|
|
assert start_edge.route is None
|
|
|
|
routed_edges = [e for e in edges if e.route is not None]
|
|
assert len(routed_edges) == 2
|
|
routes_and_targets = {(e.route, e.to_node.name) for e in routed_edges}
|
|
assert routes_and_targets == {('r1', 'NodeB'), ('r2', 'NodeC')}
|
|
for e in routed_edges:
|
|
assert e.from_node.name == 'NodeA'
|
|
|
|
|
|
def test_routing_map_mid_chain_with_fan_in() -> None:
|
|
"""Tests routing map mid-chain with fan-in to the next element."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
node_c = TestingNode(name='NodeC')
|
|
node_d = TestingNode(name='NodeD')
|
|
edges = parse_edge_items([
|
|
(START, node_a, {'r1': node_b, 'r2': node_c}, node_d),
|
|
])
|
|
|
|
# START->A (None), A->B (r1), A->C (r2), B->D (None), C->D (None)
|
|
assert len(edges) == 5
|
|
|
|
routed_edges = sorted(
|
|
[e for e in edges if e.route is not None],
|
|
key=lambda e: e.to_node.name,
|
|
)
|
|
assert len(routed_edges) == 2
|
|
assert routed_edges[0].from_node.name == 'NodeA'
|
|
assert routed_edges[0].to_node.name == 'NodeB'
|
|
assert routed_edges[0].route == 'r1'
|
|
assert routed_edges[1].from_node.name == 'NodeA'
|
|
assert routed_edges[1].to_node.name == 'NodeC'
|
|
assert routed_edges[1].route == 'r2'
|
|
|
|
fan_in_edges = sorted(
|
|
[e for e in edges if e.to_node.name == 'NodeD'],
|
|
key=lambda e: e.from_node.name,
|
|
)
|
|
assert len(fan_in_edges) == 2
|
|
assert fan_in_edges[0].from_node.name == 'NodeB'
|
|
assert fan_in_edges[0].route is None
|
|
assert fan_in_edges[1].from_node.name == 'NodeC'
|
|
assert fan_in_edges[1].route is None
|
|
|
|
|
|
def test_routing_map_mid_chain_fan_out_values() -> None:
|
|
"""Tests routing map with fan-out tuple values, followed by fan-in."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
node_c = TestingNode(name='NodeC')
|
|
node_d = TestingNode(name='NodeD')
|
|
edges = parse_edge_items([
|
|
(START, node_a, {'r1': (node_b, node_c)}, node_d),
|
|
])
|
|
|
|
# START->A (None), A->B (r1), A->C (r1), B->D (None), C->D (None)
|
|
assert len(edges) == 5
|
|
|
|
routed_edges = [e for e in edges if e.route is not None]
|
|
assert len(routed_edges) == 2
|
|
for e in routed_edges:
|
|
assert e.from_node.name == 'NodeA'
|
|
assert e.route == 'r1'
|
|
|
|
fan_in_edges = [e for e in edges if e.to_node.name == 'NodeD']
|
|
assert len(fan_in_edges) == 2
|
|
fan_in_sources = {e.from_node.name for e in fan_in_edges}
|
|
assert fan_in_sources == {'NodeB', 'NodeC'}
|
|
|
|
|
|
def test_routing_map_consecutive_dicts_raises() -> None:
|
|
"""Tests that consecutive routing maps in a chain are rejected."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
node_c = TestingNode(name='NodeC')
|
|
node_d = TestingNode(name='NodeD')
|
|
with pytest.raises(
|
|
ValueError, match=r'Consecutive routing maps are not allowed'
|
|
):
|
|
parse_edge_items([
|
|
(START, node_a, {'r1': node_b, 'r2': node_c}, {'r3': node_d}),
|
|
])
|
|
|
|
|
|
def test_routing_map_empty_dict_in_chain_raises() -> None:
|
|
"""Tests that an empty routing map in a chain raises ValueError."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
with pytest.raises(ValueError, match=r'Routing map must not be empty'):
|
|
parse_edge_items([
|
|
(START, node_a, {}, node_b),
|
|
])
|
|
|
|
|
|
def test_routing_map_invalid_key_in_chain_raises() -> None:
|
|
"""Tests that invalid routing map keys in a chain raise ValueError."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
with pytest.raises(ValueError, match=r'Invalid routing map key'):
|
|
parse_edge_items([
|
|
(START, node_a, {1.5: node_b}),
|
|
])
|
|
|
|
|
|
def test_routing_map_2_tuple_backward_compat() -> None:
|
|
"""Ensures existing 2-tuple routing map syntax still works."""
|
|
node_a = TestingNode(name='NodeA')
|
|
node_b = TestingNode(name='NodeB')
|
|
node_c = TestingNode(name='NodeC')
|
|
edges = parse_edge_items([
|
|
(START, node_a),
|
|
(node_a, {'r1': node_b, 'r2': node_c}),
|
|
])
|
|
assert len(edges) == 3
|