Skip to content

Commit e78b622

Browse files
authored
Add additional tests for processing fns (#59)
1 parent 91ccf6a commit e78b622

File tree

1 file changed

+267
-1
lines changed

1 file changed

+267
-1
lines changed

aieng-eval-agents/tests/aieng/agent_evals/tools/test_search.py

Lines changed: 267 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Tests for Google Search tool."""
22

3-
from unittest.mock import MagicMock
3+
from unittest.mock import AsyncMock, MagicMock, patch
44

55
import pytest
66
from aieng.agent_evals.tools import (
@@ -10,6 +10,7 @@
1010
format_response_with_citations,
1111
google_search,
1212
)
13+
from aieng.agent_evals.tools.search import _extract_grounding_sources, _extract_summary_from_response
1314
from google.adk.tools.function_tool import FunctionTool
1415

1516

@@ -211,3 +212,268 @@ async def test_google_search_response_structure(self):
211212
assert isinstance(source, dict)
212213
assert "title" in source
213214
assert "url" in source
215+
216+
217+
class TestExtractSummaryFromResponse:
218+
"""Tests for _extract_summary_from_response."""
219+
220+
def test_no_candidates_returns_empty(self):
221+
"""Test that an empty candidates list yields an empty summary."""
222+
response = MagicMock()
223+
response.candidates = []
224+
assert _extract_summary_from_response(response) == ""
225+
226+
def test_candidate_with_no_content_returns_empty(self):
227+
"""Test that a candidate whose content is None yields an empty summary."""
228+
candidate = MagicMock()
229+
candidate.content = None
230+
response = MagicMock()
231+
response.candidates = [candidate]
232+
assert _extract_summary_from_response(response) == ""
233+
234+
def test_candidate_with_no_parts_returns_empty(self):
235+
"""Test that content with no parts yields an empty summary."""
236+
candidate = MagicMock()
237+
candidate.content.parts = None
238+
response = MagicMock()
239+
response.candidates = [candidate]
240+
assert _extract_summary_from_response(response) == ""
241+
242+
def test_single_text_part_returned(self):
243+
"""Test that a single text part is returned as the summary."""
244+
part = MagicMock()
245+
part.text = "Paris is the capital of France."
246+
candidate = MagicMock()
247+
candidate.content.parts = [part]
248+
response = MagicMock()
249+
response.candidates = [candidate]
250+
assert _extract_summary_from_response(response) == "Paris is the capital of France."
251+
252+
def test_multiple_text_parts_are_concatenated(self):
253+
"""Test that multiple text parts are joined without a separator."""
254+
part1, part2 = MagicMock(), MagicMock()
255+
part1.text = "First part. "
256+
part2.text = "Second part."
257+
candidate = MagicMock()
258+
candidate.content.parts = [part1, part2]
259+
response = MagicMock()
260+
response.candidates = [candidate]
261+
assert _extract_summary_from_response(response) == "First part. Second part."
262+
263+
def test_part_without_text_attribute_is_skipped(self):
264+
"""Test that parts lacking a text attribute are skipped."""
265+
part_no_text = MagicMock(spec=[]) # hasattr(part, "text") → False
266+
part_with_text = MagicMock()
267+
part_with_text.text = "Only this."
268+
candidate = MagicMock()
269+
candidate.content.parts = [part_no_text, part_with_text]
270+
response = MagicMock()
271+
response.candidates = [candidate]
272+
assert _extract_summary_from_response(response) == "Only this."
273+
274+
def test_part_with_empty_text_is_skipped(self):
275+
"""Test that parts with an empty string text value are skipped."""
276+
part_empty = MagicMock()
277+
part_empty.text = ""
278+
part_valid = MagicMock()
279+
part_valid.text = "Non-empty."
280+
candidate = MagicMock()
281+
candidate.content.parts = [part_empty, part_valid]
282+
response = MagicMock()
283+
response.candidates = [candidate]
284+
assert _extract_summary_from_response(response) == "Non-empty."
285+
286+
def test_only_first_candidate_is_used(self):
287+
"""Test that only the first candidate contributes to the summary."""
288+
part1, part2 = MagicMock(), MagicMock()
289+
part1.text = "First candidate text."
290+
part2.text = "Second candidate text."
291+
candidate1, candidate2 = MagicMock(), MagicMock()
292+
candidate1.content.parts = [part1]
293+
candidate2.content.parts = [part2]
294+
response = MagicMock()
295+
response.candidates = [candidate1, candidate2]
296+
assert _extract_summary_from_response(response) == "First candidate text."
297+
298+
299+
class TestExtractGroundingSources:
300+
"""Tests for _extract_grounding_sources."""
301+
302+
@pytest.mark.asyncio
303+
async def test_no_candidates_returns_empty(self):
304+
"""Test that an empty candidates list yields no sources."""
305+
response = MagicMock()
306+
response.candidates = []
307+
assert await _extract_grounding_sources(response) == []
308+
309+
@pytest.mark.asyncio
310+
async def test_no_grounding_metadata_returns_empty(self):
311+
"""Test that a candidate with no grounding_metadata yields no sources."""
312+
candidate = MagicMock()
313+
candidate.grounding_metadata = None
314+
response = MagicMock()
315+
response.candidates = [candidate]
316+
assert await _extract_grounding_sources(response) == []
317+
318+
@pytest.mark.asyncio
319+
async def test_grounding_chunks_attribute_missing_returns_empty(self):
320+
"""Test that grounding_metadata lacking grounding_chunks yields no sources."""
321+
# spec=[] makes hasattr(gm, "grounding_chunks") return False
322+
gm = MagicMock(spec=[])
323+
candidate = MagicMock()
324+
candidate.grounding_metadata = gm
325+
response = MagicMock()
326+
response.candidates = [candidate]
327+
assert await _extract_grounding_sources(response) == []
328+
329+
@pytest.mark.asyncio
330+
async def test_empty_grounding_chunks_returns_empty(self):
331+
"""Test that an empty grounding_chunks list yields no sources."""
332+
candidate = MagicMock()
333+
candidate.grounding_metadata.grounding_chunks = []
334+
response = MagicMock()
335+
response.candidates = [candidate]
336+
assert await _extract_grounding_sources(response) == []
337+
338+
@pytest.mark.asyncio
339+
async def test_single_valid_source(self):
340+
"""Test that a single web chunk with a valid URL is returned."""
341+
chunk = MagicMock()
342+
chunk.web.uri = "https://example.com/article"
343+
chunk.web.title = "Example Article"
344+
candidate = MagicMock()
345+
candidate.grounding_metadata.grounding_chunks = [chunk]
346+
response = MagicMock()
347+
response.candidates = [candidate]
348+
349+
with patch(
350+
"aieng.agent_evals.tools.search.resolve_redirect_urls_async",
351+
new=AsyncMock(return_value=["https://example.com/article"]),
352+
):
353+
result = await _extract_grounding_sources(response)
354+
355+
assert result == [{"title": "Example Article", "url": "https://example.com/article"}]
356+
357+
@pytest.mark.asyncio
358+
async def test_multiple_sources_preserved_in_order(self):
359+
"""Test that multiple sources are returned in the same order as the chunks."""
360+
chunk1, chunk2 = MagicMock(), MagicMock()
361+
chunk1.web.uri = "https://site1.com"
362+
chunk1.web.title = "Site 1"
363+
chunk2.web.uri = "https://site2.com"
364+
chunk2.web.title = "Site 2"
365+
candidate = MagicMock()
366+
candidate.grounding_metadata.grounding_chunks = [chunk1, chunk2]
367+
response = MagicMock()
368+
response.candidates = [candidate]
369+
370+
with patch(
371+
"aieng.agent_evals.tools.search.resolve_redirect_urls_async",
372+
new=AsyncMock(return_value=["https://site1.com", "https://site2.com"]),
373+
):
374+
result = await _extract_grounding_sources(response)
375+
376+
assert result == [
377+
{"title": "Site 1", "url": "https://site1.com"},
378+
{"title": "Site 2", "url": "https://site2.com"},
379+
]
380+
381+
@pytest.mark.asyncio
382+
async def test_all_chunks_without_web_skips_url_resolution(self):
383+
"""Test that URL resolution is not called when no chunks have a web source."""
384+
chunk1, chunk2 = MagicMock(), MagicMock()
385+
chunk1.web = None
386+
chunk2.web = None
387+
candidate = MagicMock()
388+
candidate.grounding_metadata.grounding_chunks = [chunk1, chunk2]
389+
response = MagicMock()
390+
response.candidates = [candidate]
391+
392+
with patch("aieng.agent_evals.tools.search.resolve_redirect_urls_async") as mock_resolve:
393+
result = await _extract_grounding_sources(response)
394+
395+
mock_resolve.assert_not_called()
396+
assert result == []
397+
398+
@pytest.mark.asyncio
399+
async def test_chunk_without_web_is_skipped(self):
400+
"""Test that chunks with a falsy web attribute are ignored."""
401+
chunk_no_web = MagicMock()
402+
chunk_no_web.web = None
403+
chunk_valid = MagicMock()
404+
chunk_valid.web.uri = "https://example.com"
405+
chunk_valid.web.title = "Example"
406+
candidate = MagicMock()
407+
candidate.grounding_metadata.grounding_chunks = [chunk_no_web, chunk_valid]
408+
response = MagicMock()
409+
response.candidates = [candidate]
410+
411+
with patch(
412+
"aieng.agent_evals.tools.search.resolve_redirect_urls_async",
413+
new=AsyncMock(return_value=["https://example.com"]),
414+
):
415+
result = await _extract_grounding_sources(response)
416+
417+
assert result == [{"title": "Example", "url": "https://example.com"}]
418+
419+
@pytest.mark.asyncio
420+
async def test_vertexaisearch_url_is_filtered_out(self):
421+
"""Test that resolved URLs beginning with vertexaisearch are excluded."""
422+
chunk = MagicMock()
423+
chunk.web.uri = "https://vertexaisearch.cloud.google.com/redirect/abc"
424+
chunk.web.title = "Redirect"
425+
candidate = MagicMock()
426+
candidate.grounding_metadata.grounding_chunks = [chunk]
427+
response = MagicMock()
428+
response.candidates = [candidate]
429+
430+
with patch(
431+
"aieng.agent_evals.tools.search.resolve_redirect_urls_async",
432+
new=AsyncMock(return_value=["https://vertexaisearch.cloud.google.com/redirect/abc"]),
433+
):
434+
result = await _extract_grounding_sources(response)
435+
436+
assert result == []
437+
438+
@pytest.mark.asyncio
439+
async def test_empty_resolved_url_is_filtered_out(self):
440+
"""Test that sources whose resolved URL is an empty string are excluded."""
441+
chunk = MagicMock()
442+
chunk.web.uri = "https://example.com"
443+
chunk.web.title = "Example"
444+
candidate = MagicMock()
445+
candidate.grounding_metadata.grounding_chunks = [chunk]
446+
response = MagicMock()
447+
response.candidates = [candidate]
448+
449+
with patch(
450+
"aieng.agent_evals.tools.search.resolve_redirect_urls_async",
451+
new=AsyncMock(return_value=[""]),
452+
):
453+
result = await _extract_grounding_sources(response)
454+
455+
assert result == []
456+
457+
@pytest.mark.asyncio
458+
async def test_valid_and_filtered_sources_mixed(self):
459+
"""Test that vertexaisearch sources are filtered when mixed with valid ones."""
460+
chunk_valid = MagicMock()
461+
chunk_valid.web.uri = "https://valid.com/page"
462+
chunk_valid.web.title = "Valid"
463+
chunk_vertex = MagicMock()
464+
chunk_vertex.web.uri = "https://vertexaisearch.cloud.google.com/redirect/xyz"
465+
chunk_vertex.web.title = "Vertex"
466+
candidate = MagicMock()
467+
candidate.grounding_metadata.grounding_chunks = [chunk_valid, chunk_vertex]
468+
response = MagicMock()
469+
response.candidates = [candidate]
470+
471+
with patch(
472+
"aieng.agent_evals.tools.search.resolve_redirect_urls_async",
473+
new=AsyncMock(
474+
return_value=["https://valid.com/page", "https://vertexaisearch.cloud.google.com/redirect/xyz"]
475+
),
476+
):
477+
result = await _extract_grounding_sources(response)
478+
479+
assert result == [{"title": "Valid", "url": "https://valid.com/page"}]

0 commit comments

Comments
 (0)