返回 last30days-skill
test_resolve.py
根目录 / tests / test_resolve.py
1 import io
2 import unittest
3 from contextlib import redirect_stderr
4 from unittest.mock import patch
5
6 from lib import resolve
7 from lib.resolve import MAX_SUBS, _merge_category_peers
8
9
10 class TestHasBackend(unittest.TestCase):
11 def test_no_keys_returns_false(self):
12 self.assertFalse(resolve._has_backend({}))
13
14 def test_brave_key_returns_true(self):
15 self.assertTrue(resolve._has_backend({"BRAVE_API_KEY": "key"}))
16
17 def test_exa_key_returns_true(self):
18 self.assertTrue(resolve._has_backend({"EXA_API_KEY": "key"}))
19
20 def test_serper_key_returns_true(self):
21 self.assertTrue(resolve._has_backend({"SERPER_API_KEY": "key"}))
22
23
24 class TestExtractSubreddits(unittest.TestCase):
25 def test_extracts_from_title_and_snippet(self):
26 items = [
27 {"title": "Check out r/MachineLearning", "snippet": "Also r/artificial", "url": ""},
28 {"title": "More at r/datascience", "snippet": "", "url": ""},
29 ]
30 result = resolve._extract_subreddits(items)
31 self.assertEqual(result, ["MachineLearning", "artificial", "datascience"])
32
33 def test_extracts_from_url(self):
34 items = [
35 {"title": "Discussion", "snippet": "", "url": "https://reddit.com/r/python/comments/123"},
36 ]
37 result = resolve._extract_subreddits(items)
38 self.assertEqual(result, ["python"])
39
40 def test_deduplicates_case_insensitive(self):
41 items = [
42 {"title": "r/Python", "snippet": "r/python is great", "url": ""},
43 ]
44 result = resolve._extract_subreddits(items)
45 self.assertEqual(len(result), 1)
46
47 def test_empty_items_returns_empty(self):
48 self.assertEqual(resolve._extract_subreddits([]), [])
49
50 def test_no_subreddits_in_text(self):
51 items = [{"title": "No subreddits here", "snippet": "Just text", "url": ""}]
52 self.assertEqual(resolve._extract_subreddits(items), [])
53
54
55 class TestExtractXHandle(unittest.TestCase):
56 def test_extracts_from_url(self):
57 items = [
58 {"title": "OpenAI on X", "snippet": "Updates from @OpenAI", "url": "https://x.com/OpenAI"},
59 ]
60 result = resolve._extract_x_handle(items)
61 self.assertEqual(result, "openai")
62
63 def test_extracts_from_text(self):
64 items = [
65 {"title": "Follow @elonmusk", "snippet": "Also @elonmusk tweeted", "url": ""},
66 ]
67 result = resolve._extract_x_handle(items)
68 self.assertEqual(result, "elonmusk")
69
70 def test_filters_generic_handles(self):
71 items = [
72 {"title": "Go to @twitter", "snippet": "Visit @x", "url": ""},
73 ]
74 result = resolve._extract_x_handle(items)
75 self.assertEqual(result, "")
76
77 def test_empty_items_returns_empty(self):
78 self.assertEqual(resolve._extract_x_handle([]), "")
79
80
81 class TestBuildContextSummary(unittest.TestCase):
82 def test_builds_from_snippets(self):
83 items = [
84 {"snippet": "First news item about topic."},
85 {"snippet": "Second news item with details."},
86 {"snippet": "Third item ignored."},
87 ]
88 result = resolve._build_context_summary(items)
89 self.assertIn("First news item", result)
90 self.assertIn("Second news item", result)
91 # Only first 2 snippets used
92 self.assertNotIn("Third item", result)
93
94 def test_truncates_long_text(self):
95 items = [{"snippet": "A" * 200}, {"snippet": "B" * 200}]
96 result = resolve._build_context_summary(items)
97 self.assertLessEqual(len(result), 300)
98 self.assertTrue(result.endswith("..."))
99
100 def test_empty_items_returns_empty(self):
101 self.assertEqual(resolve._build_context_summary([]), "")
102
103 def test_items_with_empty_snippets(self):
104 items = [{"snippet": ""}, {"snippet": ""}]
105 self.assertEqual(resolve._build_context_summary(items), "")
106
107
108 class TestCanonicalizeGithubRepos(unittest.TestCase):
109 def test_rewrites_integration_repo_to_canonical_product(self):
110 repos = ["openai/codex", "anthropics/claude-code-action"]
111 result = resolve.canonicalize_github_repos("claude code vs codex", repos, cap=None)
112 self.assertEqual(result, ["openai/codex", "anthropics/claude-code"])
113
114 def test_preserves_action_repo_when_topic_intends_action(self):
115 repos = ["anthropics/claude-code-action", "openai/codex"]
116 result = resolve.canonicalize_github_repos("claude code action setup", repos, cap=None)
117 self.assertIn("anthropics/claude-code-action", result)
118 self.assertNotIn("anthropics/claude-code", result)
119
120 def test_dedupes_case_insensitive_after_canonicalization(self):
121 repos = ["Anthropics/Claude-Code-Action", "anthropics/claude-code"]
122 result = resolve.canonicalize_github_repos("claude code", repos, cap=None)
123 self.assertEqual(result, ["Anthropics/Claude-Code"])
124
125
126 class TestAutoResolve(unittest.TestCase):
127 def test_no_backend_returns_empty(self):
128 result = resolve.auto_resolve("test topic", {})
129 self.assertEqual(result["subreddits"], [])
130 self.assertEqual(result["x_handle"], "")
131 self.assertEqual(result["context"], "")
132 self.assertEqual(result["searches_run"], 0)
133
134 @patch("lib.resolve.grounding.web_search")
135 def test_full_resolve(self, mock_search):
136 def side_effect(query, date_range, config):
137 if "subreddit" in query:
138 return [
139 {"title": "r/technology discussion", "snippet": "Also r/gadgets", "url": ""},
140 ], {"label": "brave"}
141 if "news" in query:
142 return [
143 {"snippet": "Major tech breakthrough announced this week."},
144 ], {"label": "brave"}
145 if "handle" in query:
146 return [
147 {"title": "TechCo on X", "snippet": "@TechCo", "url": "https://x.com/TechCo"},
148 ], {"label": "brave"}
149 return [], {}
150
151 mock_search.side_effect = side_effect
152 result = resolve.auto_resolve("tech", {"BRAVE_API_KEY": "fake"})
153
154 self.assertEqual(result["subreddits"], ["technology", "gadgets"])
155 self.assertEqual(result["x_handle"], "techco")
156 self.assertIn("breakthrough", result["context"])
157 self.assertEqual(result["searches_run"], 4)
158 self.assertEqual(mock_search.call_count, 4)
159
160 @patch("lib.resolve.grounding.web_search")
161 def test_search_failure_graceful(self, mock_search):
162 mock_search.side_effect = RuntimeError("API error")
163 result = resolve.auto_resolve("test", {"BRAVE_API_KEY": "fake"})
164 self.assertEqual(result["subreddits"], [])
165 self.assertEqual(result["x_handle"], "")
166 self.assertEqual(result["context"], "")
167 self.assertEqual(result["searches_run"], 0)
168
169 @patch("lib.resolve.grounding.web_search")
170 def test_partial_failure(self, mock_search):
171 call_count = 0
172
173 def side_effect(query, date_range, config):
174 nonlocal call_count
175 call_count += 1
176 if "subreddit" in query:
177 return [{"title": "r/cooking tips", "snippet": "", "url": ""}], {}
178 if "news" in query:
179 raise RuntimeError("Timeout")
180 return [], {}
181
182 mock_search.side_effect = side_effect
183 result = resolve.auto_resolve("cooking", {"EXA_API_KEY": "fake"})
184 self.assertEqual(result["subreddits"], ["cooking"])
185 # News search failed, so context is empty
186 self.assertEqual(result["context"], "")
187 # 3 out of 4 succeeded (subreddit, x_handle, github; news failed)
188 self.assertEqual(result["searches_run"], 3)
189
190
191 class MergeCategoryPeersHappyPath(unittest.TestCase):
192 def test_image_gen_topic_appends_peers(self):
193 merged, category = _merge_category_peers(
194 "Prompting GPT Image 2",
195 ["OpenAI", "ChatGPT", "singularity"],
196 )
197 self.assertEqual(category, "ai_image_generation")
198 self.assertIn("OpenAI", merged)
199 self.assertIn("ChatGPT", merged)
200 self.assertIn("singularity", merged)
201 self.assertIn("StableDiffusion", merged)
202 self.assertIn("midjourney", merged)
203 self.assertIn("dalle2", merged)
204
205 def test_preserves_websearch_order_then_appends_peers(self):
206 merged, _ = _merge_category_peers(
207 "Prompting GPT Image 2",
208 ["OpenAI", "ChatGPT"],
209 )
210 self.assertEqual(merged[0], "OpenAI")
211 self.assertEqual(merged[1], "ChatGPT")
212 self.assertEqual(merged[2], "StableDiffusion")
213
214 def test_emits_stderr_log_when_peers_added(self):
215 buf = io.StringIO()
216 with redirect_stderr(buf):
217 _merge_category_peers(
218 "Prompting GPT Image 2",
219 ["OpenAI", "ChatGPT"],
220 )
221 output = buf.getvalue()
222 self.assertIn("Matched category=ai_image_generation", output)
223 self.assertIn("StableDiffusion", output)
224
225
226 class MergeCategoryPeersDedupe(unittest.TestCase):
227 def test_peer_already_in_websearch_not_duplicated(self):
228 merged, _ = _merge_category_peers(
229 "midjourney v7 prompts",
230 ["midjourney", "aiArt"],
231 )
232 self.assertEqual(
233 sum(1 for s in merged if s.lower() == "midjourney"),
234 1,
235 )
236
237 def test_dedupe_is_case_insensitive(self):
238 merged, _ = _merge_category_peers(
239 "Prompting GPT Image 2",
240 ["STABLEDIFFUSION"],
241 )
242 lower = [s.lower() for s in merged]
243 self.assertEqual(lower.count("stablediffusion"), 1)
244
245 def test_no_log_when_all_peers_already_present(self):
246 buf = io.StringIO()
247 with redirect_stderr(buf):
248 _merge_category_peers(
249 "Prompting GPT Image 2",
250 [
251 "StableDiffusion",
252 "midjourney",
253 "dalle2",
254 "aiArt",
255 "PromptEngineering",
256 "MediaSynthesis",
257 ],
258 )
259 self.assertNotIn("Matched category=", buf.getvalue())
260
261
262 class MergeCategoryPeersEdgeCases(unittest.TestCase):
263 def test_topic_with_no_category_returns_unchanged(self):
264 merged, category = _merge_category_peers(
265 "Kanye West",
266 ["Kanye", "hiphopheads"],
267 )
268 self.assertIsNone(category)
269 self.assertEqual(merged, ["Kanye", "hiphopheads"])
270
271 def test_empty_subreddit_list_with_category_still_adds_peers(self):
272 merged, category = _merge_category_peers("Prompting GPT Image 2", [])
273 self.assertEqual(category, "ai_image_generation")
274 self.assertIn("StableDiffusion", merged)
275
276 def test_empty_topic_returns_unchanged(self):
277 merged, category = _merge_category_peers("", ["foo", "bar"])
278 self.assertIsNone(category)
279 self.assertEqual(merged, ["foo", "bar"])
280
281 def test_none_topic_returns_unchanged(self):
282 merged, category = _merge_category_peers(None, ["foo", "bar"])
283 self.assertIsNone(category)
284 self.assertEqual(merged, ["foo", "bar"])
285
286 def test_no_log_when_topic_has_no_category(self):
287 buf = io.StringIO()
288 with redirect_stderr(buf):
289 _merge_category_peers("Kanye West", ["Kanye"])
290 self.assertNotIn("Matched category=", buf.getvalue())
291
292
293 class MergeCategoryPeersCap(unittest.TestCase):
294 def test_cap_is_enforced_at_max_subs(self):
295 websearch_subs = [f"Sub{i}" for i in range(9)]
296 merged, _ = _merge_category_peers(
297 "Prompting GPT Image 2",
298 websearch_subs,
299 )
300 self.assertEqual(len(merged), MAX_SUBS)
301 for s in websearch_subs:
302 self.assertIn(s, merged)
303 self.assertEqual(len(merged) - len(websearch_subs), 1)
304 self.assertEqual(merged[9], "StableDiffusion")
305
306 def test_cap_preserves_highest_priority_peer_when_trimming(self):
307 websearch_subs = [f"Sub{i}" for i in range(8)]
308 merged, _ = _merge_category_peers(
309 "Prompting GPT Image 2",
310 websearch_subs,
311 )
312 self.assertEqual(len(merged), MAX_SUBS)
313 self.assertEqual(merged[8], "StableDiffusion")
314 self.assertEqual(merged[9], "midjourney")
315
316
317 class MergeCategoryPeersClassificationFailure(unittest.TestCase):
318 def test_classification_error_returns_unwidened_list_and_logs(self):
319 original = resolve.categories.detect_category
320
321 def boom(_topic):
322 raise RuntimeError("synthetic classifier failure")
323
324 resolve.categories.detect_category = boom
325 try:
326 buf = io.StringIO()
327 with redirect_stderr(buf):
328 merged, category = _merge_category_peers(
329 "Prompting GPT Image 2",
330 ["OpenAI"],
331 )
332 self.assertEqual(merged, ["OpenAI"])
333 self.assertIsNone(category)
334 self.assertIn("Category classification failed", buf.getvalue())
335 finally:
336 resolve.categories.detect_category = original
337
338
339 class AutoResolveCategoryIntegration(unittest.TestCase):
340 @patch("lib.resolve.grounding.web_search")
341 def test_auto_resolve_returns_category_key(self, mock_search):
342 def side_effect(query, date_range, config):
343 if "subreddit" in query:
344 return [
345 {"title": "r/OpenAI", "snippet": "r/ChatGPT r/singularity", "url": ""},
346 ], {}
347 return [], {}
348
349 mock_search.side_effect = side_effect
350 result = resolve.auto_resolve(
351 "Prompting GPT Image 2",
352 {"BRAVE_API_KEY": "fake"},
353 )
354 self.assertEqual(result["category"], "ai_image_generation")
355 self.assertIn("StableDiffusion", result["subreddits"])
356 self.assertIn("OpenAI", result["subreddits"])
357
358 def test_no_backend_returns_category_none(self):
359 result = resolve.auto_resolve("test topic", {})
360 self.assertIsNone(result["category"])
361
362
363 class ExtractOfficialDomainTests(unittest.TestCase):
364 def test_extracts_matching_registrable_domain(self):
365 items = [{"url": "https://www.thriftbooks.com/about-thriftbooks/", "title": "", "snippet": ""}]
366 self.assertEqual(
367 resolve._extract_official_domain("ThriftBooks", items),
368 "thriftbooks.com",
369 )
370
371 def test_skips_platform_hosts(self):
372 items = [
373 {"url": "https://www.reddit.com/r/thriftbooks/", "title": "", "snippet": ""},
374 {"url": "https://x.com/thriftbooks", "title": "", "snippet": ""},
375 ]
376 self.assertEqual(resolve._extract_official_domain("ThriftBooks", items), "")
377
378 def test_non_matching_domain_returns_empty(self):
379 items = [{"url": "https://bookriot.com/thriftbooks-review/", "title": "", "snippet": ""}]
380 self.assertEqual(resolve._extract_official_domain("ThriftBooks", items), "")
381
382 def test_empty_items_returns_empty(self):
383 self.assertEqual(resolve._extract_official_domain("ThriftBooks", []), "")
384
385
386 class AutoResolveTrustpilotDomainTests(unittest.TestCase):
387 def test_no_backend_includes_empty_trustpilot_domain(self):
388 result = resolve.auto_resolve("test topic", {})
389 self.assertEqual(result["trustpilot_domain"], "")
390
391 @patch("lib.resolve.grounding.web_search")
392 def test_auto_resolve_fills_trustpilot_domain_from_news(self, mock_search):
393 def side_effect(query, date_range, config):
394 if "news" in query:
395 return [
396 {"title": "ThriftBooks launches challenge",
397 "snippet": "",
398 "url": "https://www.thriftbooks.com/blog/challenge/"},
399 ], {}
400 return [], {}
401
402 mock_search.side_effect = side_effect
403 result = resolve.auto_resolve("ThriftBooks", {"BRAVE_API_KEY": "fake"})
404 self.assertEqual(result["trustpilot_domain"], "thriftbooks.com")
405
406
407 if __name__ == "__main__":
408 unittest.main()
409
409 lines PYTHON