返回 last30days-skill
test_reddit_keyless.py
根目录 / tests / test_reddit_keyless.py
1 """Tests for scripts/lib/reddit_keyless.py — tiered keyless Reddit pipeline."""
2
3 from unittest import mock
4
5 from lib import reddit_keyless
6
7
8 def _post(i, date="2026-05-20", rel=0.0):
9 url = f"https://www.reddit.com/r/test/comments/{i:06d}/post_{i}/"
10 return {
11 "id": "", "title": f"Post {i}", "url": url, "score": 0, "num_comments": 0,
12 "subreddit": "test", "created_utc": None, "author": "u", "selftext": "",
13 "date": date, "engagement": {"score": 0, "num_comments": 0, "upvote_ratio": None},
14 "relevance": rel, "why_relevant": "Reddit RSS", "metadata": {},
15 }
16
17
18 def _scored(i, score, ncmt=0):
19 p = _post(i)
20 p["score"] = score
21 p["num_comments"] = ncmt
22 p["engagement"]["score"] = score
23 p["engagement"]["num_comments"] = ncmt
24 p["why_relevant"] = "Reddit listing"
25 p["metadata"] = {"post_id": f"{i:06d}"}
26 return p
27
28
29 class TestDiscovery:
30 """RSS breadth + scored listings are the keyless discovery path (no .json)."""
31
32 def test_keyless_path_runs_rss_and_listings(self):
33 with mock.patch.object(reddit_keyless.reddit_rss, "search_rss",
34 return_value=[_post(1), _post(2)]) as rss, \
35 mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
36 return_value=[]), \
37 mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings",
38 return_value=[]):
39 out = reddit_keyless._discover("topic", "default", ["test"])
40 assert len(out) == 2
41 rss.assert_called_once()
42
43 def test_listing_scores_backfill_rss_posts(self):
44 # RSS finds post 1 (no score); listing card for post 1 carries the score.
45 rss_post = _post(1)
46 listing_post = _scored(1, score=52692, ncmt=1743)
47 listing_post["subreddit"] = "test" # Match the requested subreddit.
48 with mock.patch.object(reddit_keyless.reddit_rss, "search_rss",
49 return_value=[rss_post]), \
50 mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
51 return_value=[listing_post]), \
52 mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings",
53 return_value=[]): # No arctic supplement.
54 out = reddit_keyless._discover("topic", "default", ["test"])
55 # listing post (scored) is kept; RSS dup of same url is dropped
56 assert len(out) == 1
57 assert out[0]["engagement"]["score"] == 52692
58 assert out[0]["num_comments"] == 1743
59
60 def test_scores_flow_to_distinct_rss_posts(self):
61 # Distinct RSS post whose id matches a listing card gets backfilled.
62 rss_post = _post(7) # url .../000007/...
63 listing_post = _scored(7, score=999)
64 listing_post["url"] = "https://www.reddit.com/r/test/comments/zzzzzz/other/"
65 with mock.patch.object(reddit_keyless.reddit_rss, "search_rss",
66 return_value=[rss_post]), \
67 mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
68 return_value=[listing_post]):
69 out = reddit_keyless._discover("topic", "default", ["test"])
70 backfilled = [p for p in out if p["url"] == rss_post["url"]][0]
71 assert backfilled["engagement"]["score"] == 999
72
73 def test_bare_query_does_not_merge_listing_discovery(self):
74 # No subreddits provided: derived-subreddit listings must NOT be added as
75 # results (avoids flooding with off-topic high-upvote posts) — only used
76 # to backfill scores onto the keyword-matched RSS posts.
77 rss_post = _post(1) # on-topic keyword match
78 offtopic_listing = _scored(99, score=88888) # high score, unrelated sub
79 offtopic_listing["url"] = "https://www.reddit.com/r/random/comments/zzz999/x/"
80 with mock.patch.object(reddit_keyless.reddit_rss, "search_rss",
81 return_value=[rss_post]), \
82 mock.patch.object(reddit_keyless, "_top_subreddits", return_value=["random"]), \
83 mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
84 return_value=[offtopic_listing]):
85 out = reddit_keyless._discover("topic", "default", None)
86 urls = [p["url"] for p in out]
87 assert rss_post["url"] in urls
88 assert offtopic_listing["url"] not in urls # not merged as discovery
89
90 def test_discover_never_raises_returns_empty(self):
91 with mock.patch.object(reddit_keyless.reddit_rss, "search_rss", return_value=[]), \
92 mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", return_value=[]):
93 assert reddit_keyless._discover("t", "default", None) == []
94
95
96 class TestSearchAndEnrich:
97 """Full pipeline: discover -> date filter -> rank -> enrich -> reindex."""
98
99 def _patch_enrich_passthrough(self):
100 return mock.patch.object(
101 reddit_keyless.reddit_shreddit, "fetch_comments",
102 return_value={"top_comments": [], "comment_insights": [], "num_comments": None},
103 )
104
105 def test_returns_empty_when_no_discovery(self):
106 with mock.patch.object(reddit_keyless, "_discover", return_value=[]):
107 assert reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31") == []
108
109 def test_date_filter_keeps_in_range_and_unknown(self):
110 posts = [_post(1, date="2026-05-10"), _post(2, date="2020-01-01"),
111 _post(3, date=None)]
112 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
113 self._patch_enrich_passthrough():
114 out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31")
115 titles = {p["title"] for p in out}
116 assert "Post 1" in titles and "Post 3" in titles
117 assert "Post 2" not in titles
118
119 def test_reindexes_ids(self):
120 posts = [_post(1), _post(2), _post(3)]
121 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
122 self._patch_enrich_passthrough():
123 out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31")
124 assert [p["id"] for p in out] == ["R1", "R2", "R3"]
125
126 def test_enrichment_attaches_comments(self):
127 posts = [_post(1)]
128 enriched = {
129 "top_comments": [{"score": 9, "date": "2026-05-19", "author": "a",
130 "excerpt": "great", "url": "https://reddit.com/x"}],
131 "comment_insights": ["great point about X"],
132 "num_comments": 14,
133 }
134 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
135 mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
136 return_value=enriched):
137 out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31")
138 assert out[0]["top_comments"][0]["score"] == 9
139 assert out[0]["num_comments"] == 14
140 assert out[0]["engagement"]["num_comments"] == 14
141
142 def test_enrichment_failure_keeps_posts(self):
143 posts = [_post(i) for i in range(8)]
144 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
145 mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
146 side_effect=Exception("svc down")):
147 out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31")
148 assert len(out) == 8 # all posts retained despite enrichment failure
149
150 def test_only_top_n_enriched_by_depth(self):
151 posts = [_post(i, rel=1.0 - i / 100) for i in range(10)]
152 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
153 mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
154 return_value={"top_comments": [], "comment_insights": [],
155 "num_comments": None}) as fc:
156 reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31", depth="quick")
157 # quick depth enriches only top 3 posts
158 assert fc.call_count == reddit_keyless.ENRICH_LIMITS["quick"]
159
160
161 class TestSlotPriority:
162 """Enrichment slot selection prefers entity-matching posts (R1-R3)."""
163
164 @staticmethod
165 def _titled(i, title, score=0, selftext=""):
166 p = _post(i)
167 p["title"] = title
168 p["selftext"] = selftext
169 p["score"] = score
170 p["engagement"]["score"] = score
171 return p
172
173 def test_on_topic_low_score_beats_off_topic_high_score(self):
174 # 3 off-topic monsters + 2 on-topic small threads; quick depth = 3 slots.
175 posts = [
176 self._titled(1, "Stop asking what model to run", score=2662),
177 self._titled(2, "RTX 4090 PSA", score=2068),
178 self._titled(3, "Gemma 4 release", score=997),
179 self._titled(4, "My OpenClaw self-migrated", score=73),
180 self._titled(5, "Using openclaw with Claude API key is so expensive", score=47),
181 ]
182 enriched_urls = []
183
184 def _capture(url):
185 enriched_urls.append(url)
186 return {"top_comments": [], "comment_insights": [], "num_comments": None}
187
188 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
189 mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
190 side_effect=_capture):
191 reddit_keyless.search_and_enrich(
192 "openclaw", "2026-05-01", "2026-05-31", depth="quick")
193 assert posts[3]["url"] in enriched_urls
194 assert posts[4]["url"] in enriched_urls
195 assert len(enriched_urls) == reddit_keyless.ENRICH_LIMITS["quick"]
196
197 def test_slot_priority_grounds_on_head_token_not_full_phrase(self):
198 # Mirrors rerank's head-token grounding: a post naming the brand head
199 # ("Stripe") lands in the match tier even without the trailing search
200 # descriptor ("payments"), so it is not buried under an unrelated
201 # high-upvote post that never names the brand.
202 head_only = self._titled(1, "Stripe is friendly to 'friendly fraud'", score=5)
203 off_topic = self._titled(2, "PayPal raises dispute fees again", score=900)
204 out = reddit_keyless._slot_priority("Stripe payments", [off_topic, head_only])
205 assert out[0] is head_only
206 assert out[1] is off_topic
207
208 def test_intent_modifier_topic_prioritizes_head_token_match(self):
209 # Intent-modifier topics still partition by the brand head token: the
210 # on-entity post wins over a high-upvote post that never names the brand.
211 on_topic = self._titled(1, "Hermes Agent v0.13 is great", score=1)
212 off_topic = self._titled(2, "LangGraph tutorial walkthrough", score=900)
213 out = reddit_keyless._slot_priority("Hermes Agent review", [off_topic, on_topic])
214 assert out[0] is on_topic
215
216 def test_all_miss_keeps_score_order_and_full_slots(self):
217 posts = [self._titled(i, f"Gemma thread {i}", score=1000 - i) for i in range(5)]
218 out = reddit_keyless._slot_priority("openclaw", posts)
219 assert out == posts # order unchanged
220 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
221 mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
222 return_value={"top_comments": [], "comment_insights": [],
223 "num_comments": None}) as fc:
224 reddit_keyless.search_and_enrich(
225 "openclaw", "2026-05-01", "2026-05-31", depth="quick")
226 assert fc.call_count == reddit_keyless.ENRICH_LIMITS["quick"]
227
228 def test_same_tier_order_preserved(self):
229 posts = [self._titled(i, f"openclaw thread {i}", score=100 - i) for i in range(4)]
230 out = reddit_keyless._slot_priority("openclaw", posts)
231 assert out == posts
232
233 def test_empty_entity_falls_back_to_token_overlap(self):
234 # Pure intent-modifier topic yields no primary entity; fallback path
235 # must not raise and must keep every post.
236 posts = [self._titled(1, "Post one"), self._titled(2, "review of things")]
237 out = reddit_keyless._slot_priority("review", posts)
238 assert len(out) == 2
239 assert {p["url"] for p in out} == {p["url"] for p in posts}
240
241 def test_selftext_match_lands_in_match_tier(self):
242 body_match = self._titled(1, "Need help with my setup", score=2,
243 selftext="my openclaw agent keeps asking for ssh keys")
244 off_topic = self._titled(2, "Gemma 4 with QAT", score=700)
245 out = reddit_keyless._slot_priority("openclaw", [off_topic, body_match])
246 assert out[0] is body_match
247
248 def test_none_score_posts_do_not_break_partition(self):
249 p1 = self._titled(1, "openclaw tips")
250 p1["engagement"]["score"] = None
251 p2 = self._titled(2, "Gemma news")
252 p2["engagement"]["score"] = None
253 out = reddit_keyless._slot_priority("openclaw", [p2, p1])
254 assert out[0] is p1
255
256 def test_partition_never_raises(self):
257 posts = [self._titled(1, "openclaw tips", score=1)]
258 with mock.patch("lib.rerank._primary_entity", side_effect=Exception("boom")):
259 out = reddit_keyless._slot_priority("openclaw", posts)
260 assert out == posts
261
262 @staticmethod
263 def _titled_nc(i, title, score=0, ncmt=0, selftext=""):
264 """_titled variant that also sets a real comment count (both surfaces)."""
265 p = TestSlotPriority._titled(i, title, score=score, selftext=selftext)
266 p["num_comments"] = ncmt
267 p["engagement"]["num_comments"] = ncmt
268 return p
269
270 def test_comment_count_orders_within_match_tier(self):
271 # Two entity-matching posts: the low-score high-comment thread wins the slot.
272 high_comments = self._titled_nc(1, "openclaw thread with lots of discussion", score=1, ncmt=45)
273 low_comments = self._titled_nc(2, "openclaw thread, quiet", score=900, ncmt=3)
274 out = reddit_keyless._slot_priority("openclaw", [low_comments, high_comments])
275 assert out[0] is high_comments
276 assert out[1] is low_comments
277
278 def test_entity_match_tier_beats_comment_count(self):
279 # Entity priority is preserved: a miss with 100 comments still follows a
280 # match with 1 comment, regardless of discussion volume.
281 match = self._titled_nc(1, "openclaw tips", score=10, ncmt=1)
282 miss = self._titled_nc(2, "Gemma news", score=100, ncmt=100)
283 out = reddit_keyless._slot_priority("openclaw", [miss, match])
284 assert out[0] is match
285 assert out[1] is miss
286
287 def test_equal_comment_counts_preserve_incoming_order_stable(self):
288 # Stable tiebreak: equal comment counts preserve the incoming order. The
289 # score-first order is established by search_and_enrich's provisional
290 # sort before _slot_priority runs; _slot_priority must not re-sort ties.
291 p1 = self._titled_nc(1, "openclaw thread a", score=100, ncmt=5)
292 p2 = self._titled_nc(2, "openclaw thread b", score=50, ncmt=5)
293 out = reddit_keyless._slot_priority("openclaw", [p2, p1])
294 assert out[0] is p2
295 assert out[1] is p1
296
297 def test_unknown_comment_count_ties_with_zero(self):
298 # Missing/None comment count is treated as 0: it ties with a known-zero
299 # post (stable) and sorts below any positive-count post in its tier.
300 unknown = self._titled_nc(1, "openclaw unknown", score=100, ncmt=None)
301 positive = self._titled_nc(2, "openclaw positive", score=10, ncmt=3)
302 known_zero = self._titled_nc(3, "openclaw zero", score=5, ncmt=0)
303 out = reddit_keyless._slot_priority("openclaw", [known_zero, unknown, positive])
304 assert out[0] is positive
305 assert out[1:] == [known_zero, unknown]
306
307 def test_richest_thread_gets_slot_when_score_ranked_low(self):
308 # Issue #906 regression: a 45-comment thread ranked last by score must
309 # get an enrichment slot at default depth (limit 8) while a 4-comment
310 # thread above it in score order does not. All posts are in the same
311 # entity tier; there are more posts than slots so ordering matters.
312 posts = [
313 self._titled_nc(1, "openclaw thread one", score=1000, ncmt=4),
314 self._titled_nc(2, "openclaw thread two", score=900, ncmt=4),
315 self._titled_nc(3, "openclaw thread three", score=800, ncmt=4),
316 self._titled_nc(4, "openclaw thread four", score=700, ncmt=4),
317 self._titled_nc(5, "openclaw thread five", score=600, ncmt=6),
318 self._titled_nc(6, "openclaw thread six", score=500, ncmt=5),
319 self._titled_nc(7, "openclaw thread seven", score=300, ncmt=4),
320 self._titled_nc(9, "openclaw thread nine", score=250, ncmt=7),
321 self._titled_nc(10, "openclaw thread ten", score=200, ncmt=8),
322 self._titled_nc(11, "openclaw thread eleven", score=150, ncmt=9),
323 self._titled_nc(8, "openclaw thread eight", score=77, ncmt=45),
324 ]
325 enriched_urls = []
326
327 def _capture(url):
328 enriched_urls.append(url)
329 return {"top_comments": [], "comment_insights": [], "num_comments": None}
330
331 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
332 mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
333 side_effect=_capture):
334 reddit_keyless.search_and_enrich(
335 "openclaw", "2026-05-01", "2026-05-31", depth="default")
336 assert posts[10]["url"] in enriched_urls # 45-comment thread enriched
337 assert posts[6]["url"] not in enriched_urls # 4-comment thread above it skipped
338 assert len(enriched_urls) == reddit_keyless.ENRICH_LIMITS["default"]
339
340 def test_miss_tier_orders_by_comments_for_leftover_slots(self):
341 # Review finding #1 (validated): when the entity-match tier is smaller
342 # than ENRICH_LIMITS, leftover slots are filled from the miss tier in
343 # comment-count order. 1 match + 4 misses at quick depth (limit 4): the
344 # three most-commented misses get slots, the least-commented miss does not.
345 # Score order deliberately differs from comment order so this test
346 # discriminates the miss-tier sort from the old score-first order.
347 posts = [
348 self._titled_nc(1, "openclaw thread", score=100, ncmt=2),
349 self._titled_nc(2, "Gemma thread A", score=5, ncmt=30),
350 self._titled_nc(3, "Gemma thread B", score=40, ncmt=9),
351 self._titled_nc(4, "Gemma thread C", score=30, ncmt=2),
352 self._titled_nc(5, "Gemma thread D", score=20, ncmt=1),
353 ]
354 enriched_urls = []
355
356 def _capture(url):
357 enriched_urls.append(url)
358 return {"top_comments": [], "comment_insights": [], "num_comments": None}
359
360 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
361 mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
362 side_effect=_capture):
363 reddit_keyless.search_and_enrich(
364 "openclaw", "2026-05-01", "2026-05-31", depth="quick")
365 assert posts[0]["url"] in enriched_urls # entity match always slotted
366 assert posts[1]["url"] in enriched_urls # 30-comment miss (top miss)
367 assert posts[2]["url"] in enriched_urls # 9-comment miss
368 assert posts[3]["url"] in enriched_urls # 2-comment miss takes the last slot
369 assert posts[4]["url"] not in enriched_urls # 1-comment miss below the cut
370 assert len(enriched_urls) == reddit_keyless.ENRICH_LIMITS["quick"]
371
372
373 class TestScoredListingsFallback:
374 """_scored_listings falls back to the arctic-shift archive when the
375 shreddit listing partials return nothing (datacenter egress 403)."""
376
377 def test_arctic_fallback_when_shreddit_empty(self):
378 arctic_post = _scored(1, score=406)
379 with mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
380 return_value=[]), \
381 mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings",
382 return_value=[arctic_post]) as arctic:
383 out = reddit_keyless._scored_listings(["tea"], depth="quick", query="matcha")
384 assert out == [arctic_post]
385 arctic.assert_called_once_with(["tea"], depth="quick", query="matcha", sorts=None)
386
387 def test_shreddit_and_arctic_both_called_deduped(self):
388 """Shreddit and arctic are both called; arctic supplements missing posts."""
389 shreddit_post = _scored(1, score=42)
390 shreddit_post["subreddit"] = "tea"
391 arctic_post = _scored(2, score=100)
392 arctic_post["subreddit"] = "tea"
393 with mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
394 return_value=[shreddit_post]), \
395 mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings",
396 return_value=[arctic_post]) as arctic:
397 out = reddit_keyless._scored_listings(["tea"], depth="quick", query="matcha")
398 # Both shreddit and arctic posts should be in the result (deduped by URL).
399 assert len(out) == 2
400 urls = {p["url"] for p in out}
401 assert shreddit_post["url"] in urls
402 assert arctic_post["url"] in urls
403 arctic.assert_called_once()
404
405 def test_both_empty_returns_empty(self):
406 with mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
407 return_value=[]), \
408 mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings",
409 return_value=[]):
410 out = reddit_keyless._scored_listings(["tea"], depth="quick", query="matcha")
411 assert out == []
412
413 def test_never_raises_when_arctic_fails(self):
414 with mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
415 return_value=[]), \
416 mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings",
417 side_effect=Exception("boom")):
418 out = reddit_keyless._scored_listings(["tea"], depth="quick", query="matcha")
419 assert out == []
420
421 def test_dedicated_sorts_passed_through(self):
422 with mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
423 return_value=[]), \
424 mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings",
425 return_value=[]) as arctic:
426 reddit_keyless._scored_listings(
427 ["Kanye"], depth="default", query="Kanye", sorts=["top", "hot", "new"]
428 )
429 arctic.assert_called_once_with(
430 ["Kanye"], depth="default", query="Kanye", sorts=["top", "hot", "new"]
431 )
432
433 def test_arctic_supplements_all_subreddits(self):
434 """Arctic is called for all subreddits to supplement any failed sort lanes."""
435 shreddit_post = _scored(1, score=100)
436 shreddit_post["subreddit"] = "tea"
437 arctic_post_tea = _scored(2, score=200)
438 arctic_post_tea["subreddit"] = "tea"
439 arctic_post_coffee = _scored(3, score=150)
440 arctic_post_coffee["subreddit"] = "coffee"
441
442 def shreddit_side_effect(subs, **kwargs):
443 # Shreddit only returns posts for "tea", not "coffee".
444 return [shreddit_post] if "tea" in subs else []
445
446 with mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
447 side_effect=shreddit_side_effect), \
448 mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings",
449 return_value=[arctic_post_tea, arctic_post_coffee]) as arctic:
450 out = reddit_keyless._scored_listings(
451 ["tea", "coffee"], depth="quick", query="beverages"
452 )
453 # Arctic is called for ALL requested subreddits to supplement any failed sorts.
454 arctic.assert_called_once()
455 call_args = arctic.call_args
456 assert set(call_args[0][0]) == {"tea", "coffee"}, "arctic should be called for all subs"
457 # All posts should be in the result (deduped by URL).
458 urls = [p["url"] for p in out]
459 assert shreddit_post["url"] in urls
460 assert arctic_post_tea["url"] in urls
461 assert arctic_post_coffee["url"] in urls
462
462 lines PYTHON