返回 last30days-skill
test_competitors.py
根目录 / tests / test_competitors.py
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
145 lines PYTHON