| 1 | """Tests for scripts/lib/competitors.discover_competitors.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import io |
| 6 | import unittest |
| 7 | from contextlib import redirect_stderr |
| 8 | from unittest import mock |
| 9 | |
| 10 | from lib import competitors |
| 11 | |
| 12 | |
| 13 | def _serp(items: list[tuple[str, str]]) -> list[dict]: |
| 14 | """Build a minimal SERP items list from (title, snippet) pairs.""" |
| 15 | return [ |
| 16 | {"title": title, "snippet": snippet, "url": "https://example.test/"} |
| 17 | for title, snippet in items |
| 18 | ] |
| 19 | |
| 20 | OPENAI_SERP = _serp( |
| 21 | [ |
| 22 | ("OpenAI vs Anthropic vs xAI: which is better?", "xAI and Anthropic now compete directly with OpenAI."), |
| 23 | ("Top OpenAI alternatives in 2026", "Anthropic, Google Gemini, and xAI are the leading alternatives this year."), |
| 24 | ("xAI and Anthropic challenge OpenAI dominance", "xAI and Anthropic push Google Gemini hard; xAI keeps shipping."), |
| 25 | ("Anthropic vs xAI: head to head", "Anthropic and xAI trade punches; Google Gemini is not far behind."), |
| 26 | ] |
| 27 | ) |
| 28 | |
| 29 | KANYE_SERP = _serp( |
| 30 | [ |
| 31 | ("Kanye West vs Drake: the feud explained", "Drake responded to Kanye with a diss track."), |
| 32 | ("Top rappers of the decade: Kendrick Lamar, Drake, J Cole", "Kendrick Lamar released a new album; Drake toured Europe."), |
| 33 | ("Drake and Kendrick Lamar trade shots", "J Cole stayed out of the Drake vs Kendrick Lamar feud."), |
| 34 | ] |
| 35 | ) |
| 36 | |
| 37 | |
| 38 | class CompetitorDiscoveryTests(unittest.TestCase): |
| 39 | def _run(self, serp: list[dict], topic: str, count: int = 3) -> list[str]: |
| 40 | config = {"BRAVE_API_KEY": "test-key"} |
| 41 | with mock.patch.object( |
| 42 | competitors.grounding, "web_search", return_value=(serp, {}) |
| 43 | ): |
| 44 | with redirect_stderr(io.StringIO()): |
| 45 | return competitors.discover_competitors(topic, count, config) |
| 46 | |
| 47 | def test_openai_surfaces_anthropic_and_peers(self): |
| 48 | results = self._run(OPENAI_SERP, "OpenAI", count=3) |
| 49 | self.assertEqual(len(results), 3) |
| 50 | joined = " ".join(results) |
| 51 | self.assertIn("Anthropic", joined) |
| 52 | self.assertIn("xAI", joined) |
| 53 | # Should not surface the topic itself |
| 54 | self.assertNotIn("OpenAI", results) |
| 55 | self.assertFalse( |
| 56 | any("OpenAI" in entity for entity in results), |
| 57 | f"Topic token leaked into results: {results}", |
| 58 | ) |
| 59 | |
| 60 | def test_kanye_surfaces_rap_peers(self): |
| 61 | results = self._run(KANYE_SERP, "Kanye West", count=2) |
| 62 | self.assertEqual(len(results), 2) |
| 63 | joined = " ".join(results) |
| 64 | self.assertTrue( |
| 65 | "Drake" in joined and "Kendrick Lamar" in joined, |
| 66 | f"Expected Drake and Kendrick Lamar in {results}", |
| 67 | ) |
| 68 | |
| 69 | def test_empty_serp_returns_empty(self): |
| 70 | results = self._run([], "OpenAI", count=3) |
| 71 | self.assertEqual(results, []) |
| 72 | |
| 73 | def test_no_backend_returns_empty(self): |
| 74 | err = io.StringIO() |
| 75 | with redirect_stderr(err): |
| 76 | results = competitors.discover_competitors("OpenAI", 3, config={}) |
| 77 | self.assertEqual(results, []) |
| 78 | self.assertIn("No web search backend", err.getvalue()) |
| 79 | |
| 80 | def test_backend_error_returns_empty(self): |
| 81 | config = {"BRAVE_API_KEY": "test-key"} |
| 82 | |
| 83 | def boom(*_args, **_kwargs): |
| 84 | raise RuntimeError("SERP provider offline") |
| 85 | |
| 86 | err = io.StringIO() |
| 87 | with mock.patch.object(competitors.grounding, "web_search", side_effect=boom): |
| 88 | with redirect_stderr(err): |
| 89 | results = competitors.discover_competitors("OpenAI", 3, config) |
| 90 | self.assertEqual(results, []) |
| 91 | self.assertIn("Search failed", err.getvalue()) |
| 92 | |
| 93 | def test_topic_tokens_filtered(self): |
| 94 | """Candidates overlapping topic tokens are rejected.""" |
| 95 | serp = _serp( |
| 96 | [ |
| 97 | ("Open AI vs Anthropic", "Open AI, Anthropic, and Google lead."), |
| 98 | ("OpenAI Alternatives: Anthropic", "Anthropic is a competitor to Open AI."), |
| 99 | ] |
| 100 | ) |
| 101 | results = self._run(serp, "OpenAI", count=3) |
| 102 | # "Open AI" shares the "openai" lowercased-concatenation? Actually tokenizer |
| 103 | # splits "Open AI" into ["open", "ai"]. Topic "OpenAI" tokenizes to ["openai"]. |
| 104 | # They do not overlap at the token level, which is fine — the filter is |
| 105 | # best-effort. We only assert that bare "OpenAI" is filtered and real |
| 106 | # competitors still surface. |
| 107 | self.assertNotIn("OpenAI", results) |
| 108 | self.assertIn("Anthropic", results) |
| 109 | |
| 110 | def test_deduplicates_case_insensitively(self): |
| 111 | serp = _serp( |
| 112 | [ |
| 113 | ("Anthropic vs Gemini", "anthropic is strong."), |
| 114 | ("ANTHROPIC makes Claude", "Anthropic announced Claude 4."), |
| 115 | ] |
| 116 | ) |
| 117 | results = self._run(serp, "OpenAI", count=3) |
| 118 | # "Anthropic" should appear exactly once (first-seen capitalization wins). |
| 119 | anthropic_matches = [r for r in results if r.lower() == "anthropic"] |
| 120 | self.assertEqual(len(anthropic_matches), 1) |
| 121 | |
| 122 | def test_count_one_returns_single(self): |
| 123 | results = self._run(OPENAI_SERP, "OpenAI", count=1) |
| 124 | self.assertEqual(len(results), 1) |
| 125 | |
| 126 | def test_stopword_only_candidates_rejected(self): |
| 127 | serp = _serp( |
| 128 | [ |
| 129 | ("Top Alternatives", "Best Competitors and Top Tools."), |
| 130 | ("Free Software Reviews", "Complete Guide to The Options."), |
| 131 | ] |
| 132 | ) |
| 133 | results = self._run(serp, "Widget", count=5) |
| 134 | self.assertEqual( |
| 135 | results, [], |
| 136 | f"Stopword-only phrases should not be returned: got {results}", |
| 137 | ) |
| 138 | |
| 139 | def test_count_zero_returns_empty(self): |
| 140 | results = self._run(OPENAI_SERP, "OpenAI", count=0) |
| 141 | self.assertEqual(results, []) |
| 142 | |
| 143 | if __name__ == "__main__": |
| 144 | unittest.main() |
| 145 |