Files
google--adk-python/tests/unittests/workflow/utils/test_graph_parser.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

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