返回 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 out = reddit_keyless._discover("topic", "default", ["test"])
38 assert len(out) == 2
39 rss.assert_called_once()
40
41 def test_listing_scores_backfill_rss_posts(self):
42 # RSS finds post 1 (no score); listing card for post 1 carries the score.
43 rss_post = _post(1)
44 listing_post = _scored(1, score=52692, ncmt=1743)
45 with mock.patch.object(reddit_keyless.reddit_rss, "search_rss",
46 return_value=[rss_post]), \
47 mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
48 return_value=[listing_post]):
49 out = reddit_keyless._discover("topic", "default", ["test"])
50 # listing post (scored) is kept; RSS dup of same url is dropped
51 assert len(out) == 1
52 assert out[0]["engagement"]["score"] == 52692
53 assert out[0]["num_comments"] == 1743
54
55 def test_scores_flow_to_distinct_rss_posts(self):
56 # Distinct RSS post whose id matches a listing card gets backfilled.
57 rss_post = _post(7) # url .../000007/...
58 listing_post = _scored(7, score=999)
59 listing_post["url"] = "https://www.reddit.com/r/test/comments/zzzzzz/other/"
60 with mock.patch.object(reddit_keyless.reddit_rss, "search_rss",
61 return_value=[rss_post]), \
62 mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
63 return_value=[listing_post]):
64 out = reddit_keyless._discover("topic", "default", ["test"])
65 backfilled = [p for p in out if p["url"] == rss_post["url"]][0]
66 assert backfilled["engagement"]["score"] == 999
67
68 def test_bare_query_does_not_merge_listing_discovery(self):
69 # No subreddits provided: derived-subreddit listings must NOT be added as
70 # results (avoids flooding with off-topic high-upvote posts) — only used
71 # to backfill scores onto the keyword-matched RSS posts.
72 rss_post = _post(1) # on-topic keyword match
73 offtopic_listing = _scored(99, score=88888) # high score, unrelated sub
74 offtopic_listing["url"] = "https://www.reddit.com/r/random/comments/zzz999/x/"
75 with mock.patch.object(reddit_keyless.reddit_rss, "search_rss",
76 return_value=[rss_post]), \
77 mock.patch.object(reddit_keyless, "_top_subreddits", return_value=["random"]), \
78 mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings",
79 return_value=[offtopic_listing]):
80 out = reddit_keyless._discover("topic", "default", None)
81 urls = [p["url"] for p in out]
82 assert rss_post["url"] in urls
83 assert offtopic_listing["url"] not in urls # not merged as discovery
84
85 def test_discover_never_raises_returns_empty(self):
86 with mock.patch.object(reddit_keyless.reddit_rss, "search_rss", return_value=[]), \
87 mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", return_value=[]):
88 assert reddit_keyless._discover("t", "default", None) == []
89
90
91 class TestSearchAndEnrich:
92 """Full pipeline: discover -> date filter -> rank -> enrich -> reindex."""
93
94 def _patch_enrich_passthrough(self):
95 return mock.patch.object(
96 reddit_keyless.reddit_shreddit, "fetch_comments",
97 return_value={"top_comments": [], "comment_insights": [], "num_comments": None},
98 )
99
100 def test_returns_empty_when_no_discovery(self):
101 with mock.patch.object(reddit_keyless, "_discover", return_value=[]):
102 assert reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31") == []
103
104 def test_date_filter_keeps_in_range_and_unknown(self):
105 posts = [_post(1, date="2026-05-10"), _post(2, date="2020-01-01"),
106 _post(3, date=None)]
107 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
108 self._patch_enrich_passthrough():
109 out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31")
110 titles = {p["title"] for p in out}
111 assert "Post 1" in titles and "Post 3" in titles
112 assert "Post 2" not in titles
113
114 def test_reindexes_ids(self):
115 posts = [_post(1), _post(2), _post(3)]
116 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
117 self._patch_enrich_passthrough():
118 out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31")
119 assert [p["id"] for p in out] == ["R1", "R2", "R3"]
120
121 def test_enrichment_attaches_comments(self):
122 posts = [_post(1)]
123 enriched = {
124 "top_comments": [{"score": 9, "date": "2026-05-19", "author": "a",
125 "excerpt": "great", "url": "https://reddit.com/x"}],
126 "comment_insights": ["great point about X"],
127 "num_comments": 14,
128 }
129 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
130 mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
131 return_value=enriched):
132 out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31")
133 assert out[0]["top_comments"][0]["score"] == 9
134 assert out[0]["num_comments"] == 14
135 assert out[0]["engagement"]["num_comments"] == 14
136
137 def test_enrichment_failure_keeps_posts(self):
138 posts = [_post(i) for i in range(8)]
139 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
140 mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
141 side_effect=Exception("svc down")):
142 out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31")
143 assert len(out) == 8 # all posts retained despite enrichment failure
144
145 def test_only_top_n_enriched_by_depth(self):
146 posts = [_post(i, rel=1.0 - i / 100) for i in range(10)]
147 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
148 mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
149 return_value={"top_comments": [], "comment_insights": [],
150 "num_comments": None}) as fc:
151 reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31", depth="quick")
152 # quick depth enriches only top 3 posts
153 assert fc.call_count == reddit_keyless.ENRICH_LIMITS["quick"]
154
155
156 class TestSlotPriority:
157 """Enrichment slot selection prefers entity-matching posts (R1-R3)."""
158
159 @staticmethod
160 def _titled(i, title, score=0, selftext=""):
161 p = _post(i)
162 p["title"] = title
163 p["selftext"] = selftext
164 p["score"] = score
165 p["engagement"]["score"] = score
166 return p
167
168 def test_on_topic_low_score_beats_off_topic_high_score(self):
169 # 3 off-topic monsters + 2 on-topic small threads; quick depth = 3 slots.
170 posts = [
171 self._titled(1, "Stop asking what model to run", score=2662),
172 self._titled(2, "RTX 4090 PSA", score=2068),
173 self._titled(3, "Gemma 4 release", score=997),
174 self._titled(4, "My OpenClaw self-migrated", score=73),
175 self._titled(5, "Using openclaw with Claude API key is so expensive", score=47),
176 ]
177 enriched_urls = []
178
179 def _capture(url):
180 enriched_urls.append(url)
181 return {"top_comments": [], "comment_insights": [], "num_comments": None}
182
183 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
184 mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
185 side_effect=_capture):
186 reddit_keyless.search_and_enrich(
187 "openclaw", "2026-05-01", "2026-05-31", depth="quick")
188 assert posts[3]["url"] in enriched_urls
189 assert posts[4]["url"] in enriched_urls
190 assert len(enriched_urls) == reddit_keyless.ENRICH_LIMITS["quick"]
191
192 def test_slot_priority_grounds_on_head_token_not_full_phrase(self):
193 # Mirrors rerank's head-token grounding: a post naming the brand head
194 # ("Stripe") lands in the match tier even without the trailing search
195 # descriptor ("payments"), so it is not buried under an unrelated
196 # high-upvote post that never names the brand.
197 head_only = self._titled(1, "Stripe is friendly to 'friendly fraud'", score=5)
198 off_topic = self._titled(2, "PayPal raises dispute fees again", score=900)
199 out = reddit_keyless._slot_priority("Stripe payments", [off_topic, head_only])
200 assert out[0] is head_only
201 assert out[1] is off_topic
202
203 def test_intent_modifier_topic_prioritizes_head_token_match(self):
204 # Intent-modifier topics still partition by the brand head token: the
205 # on-entity post wins over a high-upvote post that never names the brand.
206 on_topic = self._titled(1, "Hermes Agent v0.13 is great", score=1)
207 off_topic = self._titled(2, "LangGraph tutorial walkthrough", score=900)
208 out = reddit_keyless._slot_priority("Hermes Agent review", [off_topic, on_topic])
209 assert out[0] is on_topic
210
211 def test_all_miss_keeps_score_order_and_full_slots(self):
212 posts = [self._titled(i, f"Gemma thread {i}", score=1000 - i) for i in range(5)]
213 out = reddit_keyless._slot_priority("openclaw", posts)
214 assert out == posts # order unchanged
215 with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \
216 mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments",
217 return_value={"top_comments": [], "comment_insights": [],
218 "num_comments": None}) as fc:
219 reddit_keyless.search_and_enrich(
220 "openclaw", "2026-05-01", "2026-05-31", depth="quick")
221 assert fc.call_count == reddit_keyless.ENRICH_LIMITS["quick"]
222
223 def test_same_tier_order_preserved(self):
224 posts = [self._titled(i, f"openclaw thread {i}", score=100 - i) for i in range(4)]
225 out = reddit_keyless._slot_priority("openclaw", posts)
226 assert out == posts
227
228 def test_empty_entity_falls_back_to_token_overlap(self):
229 # Pure intent-modifier topic yields no primary entity; fallback path
230 # must not raise and must keep every post.
231 posts = [self._titled(1, "Post one"), self._titled(2, "review of things")]
232 out = reddit_keyless._slot_priority("review", posts)
233 assert len(out) == 2
234 assert {p["url"] for p in out} == {p["url"] for p in posts}
235
236 def test_selftext_match_lands_in_match_tier(self):
237 body_match = self._titled(1, "Need help with my setup", score=2,
238 selftext="my openclaw agent keeps asking for ssh keys")
239 off_topic = self._titled(2, "Gemma 4 with QAT", score=700)
240 out = reddit_keyless._slot_priority("openclaw", [off_topic, body_match])
241 assert out[0] is body_match
242
243 def test_none_score_posts_do_not_break_partition(self):
244 p1 = self._titled(1, "openclaw tips")
245 p1["engagement"]["score"] = None
246 p2 = self._titled(2, "Gemma news")
247 p2["engagement"]["score"] = None
248 out = reddit_keyless._slot_priority("openclaw", [p2, p1])
249 assert out[0] is p1
250
251 def test_partition_never_raises(self):
252 posts = [self._titled(1, "openclaw tips", score=1)]
253 with mock.patch("lib.rerank._primary_entity", side_effect=Exception("boom")):
254 out = reddit_keyless._slot_priority("openclaw", posts)
255 assert out == posts
256
256 lines PYTHON