返回 last30days-skill
test_reddit.py
根目录 / tests / test_reddit.py
1 import unittest
2
3 from lib.reddit import (
4 _extract_date,
5 _extract_score,
6 _extract_subreddit_name,
7 _normalize_reddit_id,
8 _total_engagement,
9 enrich_with_comments,
10 )
11
12
13 class TestExtractSubredditName(unittest.TestCase):
14 def test_from_string(self):
15 self.assertEqual("openclaw", _extract_subreddit_name("openclaw"))
16
17 def test_from_dict_with_name(self):
18 self.assertEqual(
19 "openclaw",
20 _extract_subreddit_name({"id": "t5_ghydwa", "name": "openclaw"}),
21 )
22
23 def test_from_dict_with_display_name(self):
24 self.assertEqual(
25 "LocalLLM",
26 _extract_subreddit_name({"display_name": "LocalLLM"}),
27 )
28
29 def test_from_dict_name_preferred_over_display_name(self):
30 self.assertEqual(
31 "name_wins",
32 _extract_subreddit_name({"name": "name_wins", "display_name": "display"}),
33 )
34
35 def test_empty_string(self):
36 self.assertEqual("", _extract_subreddit_name(""))
37
38 def test_empty_dict(self):
39 self.assertEqual("", _extract_subreddit_name({}))
40
41 def test_strips_whitespace(self):
42 self.assertEqual("test", _extract_subreddit_name(" test "))
43
44
45 class TestExtractScore(unittest.TestCase):
46 def test_ups(self):
47 self.assertEqual(42, _extract_score({"ups": 42}))
48
49 def test_score_field(self):
50 self.assertEqual(77, _extract_score({"score": 77}))
51
52 def test_votes(self):
53 self.assertEqual(99, _extract_score({"votes": 99}))
54
55 def test_ups_preferred_over_votes(self):
56 self.assertEqual(10, _extract_score({"ups": 10, "votes": 99}))
57
58 def test_missing(self):
59 self.assertEqual(0, _extract_score({}))
60
61 def test_zero_preserved(self):
62 self.assertEqual(0, _extract_score({"ups": 0}))
63
64 def test_zero_ups_does_not_fall_through(self):
65 # ups=0 should be returned, not fall through to score
66 self.assertEqual(0, _extract_score({"ups": 0, "score": 5}))
67
68
69 class TestExtractDate(unittest.TestCase):
70 def test_unix_timestamp(self):
71 self.assertEqual("2024-05-03", _extract_date({"created_utc": 1714694957}))
72
73 def test_iso_string(self):
74 result = _extract_date({"created_at": "2024-05-03T01:09:17.620000+0000"})
75 self.assertEqual("2024-05-03", result)
76
77 def test_iso_with_z_suffix(self):
78 result = _extract_date({"created_at": "2024-05-03T01:09:17Z"})
79 self.assertEqual("2024-05-03", result)
80
81 def test_created_utc_preferred(self):
82 result = _extract_date({"created_utc": 1714694957, "created_at": "2025-01-01T00:00:00Z"})
83 self.assertEqual("2024-05-03", result)
84
85 def test_missing(self):
86 self.assertIsNone(_extract_date({}))
87
88
89 class TestNormalizeRedditId(unittest.TestCase):
90 def test_strips_t3_prefix(self):
91 self.assertEqual("abc123", _normalize_reddit_id("t3_abc123"))
92
93 def test_no_prefix(self):
94 self.assertEqual("abc123", _normalize_reddit_id("abc123"))
95
96 def test_empty(self):
97 self.assertEqual("", _normalize_reddit_id(""))
98
99 def test_none(self):
100 self.assertEqual("", _normalize_reddit_id(None))
101
102
103 class TestTotalEngagement(unittest.TestCase):
104 def test_score_plus_comments(self):
105 item = {"engagement": {"score": 100, "num_comments": 50}}
106 self.assertEqual(150, _total_engagement(item))
107
108 def test_high_comments_low_score(self):
109 item = {"engagement": {"score": 1, "num_comments": 1387}}
110 self.assertEqual(1388, _total_engagement(item))
111
112 def test_missing_engagement(self):
113 self.assertEqual(0, _total_engagement({}))
114
115 def test_none_values(self):
116 item = {"engagement": {"score": None, "num_comments": None}}
117 self.assertEqual(0, _total_engagement(item))
118
119 def test_score_only(self):
120 item = {"engagement": {"score": 42}}
121 self.assertEqual(42, _total_engagement(item))
122
123
124 class TestEnrichSelectsTopEngagement(unittest.TestCase):
125 """Verify enrich_with_comments picks threads by total engagement, not list order."""
126
127 def test_high_comment_thread_enriched_over_low_engagement(self):
128 """A thread with 1387 comments but score:1 should be enriched before
129 a thread with score:5 and 0 comments."""
130 from unittest.mock import patch
131
132 items = [
133 # Low engagement thread (first in list)
134 {
135 "id": "R1",
136 "url": "https://www.reddit.com/r/test/comments/low",
137 "engagement": {"score": 5, "num_comments": 0},
138 },
139 # High engagement thread (second in list)
140 {
141 "id": "R2",
142 "url": "https://www.reddit.com/r/test/comments/high",
143 "engagement": {"score": 1, "num_comments": 1387},
144 },
145 # Medium engagement
146 {
147 "id": "R3",
148 "url": "https://www.reddit.com/r/test/comments/med",
149 "engagement": {"score": 50, "num_comments": 10},
150 },
151 ]
152
153 enriched_urls = []
154
155 def mock_fetch_comments(url, token):
156 enriched_urls.append(url)
157 return [{"body": "Great thread!", "ups": 10, "author": "testuser"}]
158
159 # Only allow 1 enrichment to prove selection order matters
160 with patch("lib.reddit.fetch_post_comments", side_effect=mock_fetch_comments):
161 result = enrich_with_comments(items, token="fake", depth="quick")
162
163 # With quick depth (3 enrichments), all 3 should be enriched.
164 # But the key assertion: the high-comment thread (R2) must be included.
165 self.assertIn(
166 "https://www.reddit.com/r/test/comments/high",
167 enriched_urls,
168 "High-comment thread should always be selected for enrichment",
169 )
170
171 def test_enrichment_order_by_engagement(self):
172 """With a budget of 1, only the highest-engagement thread gets enriched."""
173 from unittest.mock import patch
174
175 items = [
176 {
177 "id": "R1",
178 "url": "https://www.reddit.com/r/test/comments/a",
179 "engagement": {"score": 200, "num_comments": 5},
180 },
181 {
182 "id": "R2",
183 "url": "https://www.reddit.com/r/test/comments/b",
184 "engagement": {"score": 1, "num_comments": 1500},
185 },
186 ]
187
188 enriched_urls = []
189
190 def mock_fetch_comments(url, token):
191 enriched_urls.append(url)
192 return [{"body": "Comment", "ups": 5, "author": "user"}]
193
194 # Override DEPTH_CONFIG to allow only 1 enrichment
195 custom_config = {"comment_enrichments": 1}
196 with patch("lib.reddit.DEPTH_CONFIG", {"test": custom_config, "default": custom_config}), \
197 patch("lib.reddit.fetch_post_comments", side_effect=mock_fetch_comments):
198 enrich_with_comments(items, token="fake", depth="test")
199
200 # R2 has 1501 total engagement vs R1's 205 -- R2 should be picked
201 self.assertEqual(len(enriched_urls), 1)
202 self.assertEqual(
203 enriched_urls[0],
204 "https://www.reddit.com/r/test/comments/b",
205 )
206
207
208 class TestEnrichmentBudget(unittest.TestCase):
209 """Tests for the enrichment time budget in enrich_with_comments()."""
210
211 def _make_items(self, n):
212 return [
213 {"url": f"https://reddit.com/r/test/comments/{i}/post", "score": 100 - i, "num_comments": 50,
214 "engagement": {"score": 100 - i, "num_comments": 50}}
215 for i in range(n)
216 ]
217
218 def test_all_complete_within_budget(self):
219 """When enrichment is fast, all items get comments."""
220 from unittest.mock import patch
221 items = self._make_items(3)
222 fast_comments = [{"body": "Great post!", "score": 42, "author": "user1"}]
223
224 with patch("lib.reddit.fetch_post_comments", return_value=fast_comments):
225 result = enrich_with_comments(items, "fake-token", depth="quick", budget_seconds=60)
226
227 enriched = [i for i in result if i.get("top_comments")]
228 self.assertEqual(len(enriched), 3)
229
230 def test_budget_zero_returns_items_unenriched(self):
231 """With budget=0, items are returned without enrichment (not discarded)."""
232 import time as _time
233 from unittest.mock import patch
234
235 items = self._make_items(3)
236
237 def slow_fetch(url, token):
238 _time.sleep(2)
239 return [{"body": "comment", "score": 10, "author": "u"}]
240
241 with patch("lib.reddit.fetch_post_comments", side_effect=slow_fetch):
242 result = enrich_with_comments(items, "fake-token", depth="quick", budget_seconds=0)
243
244 # All 3 items returned (not discarded)
245 self.assertEqual(len(result), 3)
246
247 def test_empty_items_returns_immediately(self):
248 result = enrich_with_comments([], "fake-token", depth="default", budget_seconds=60)
249 self.assertEqual(result, [])
250
251 def test_exceptions_dont_crash(self):
252 """If enrichment raises, items are returned without comments."""
253 from unittest.mock import patch
254 items = self._make_items(3)
255
256 with patch("lib.reddit.fetch_post_comments", side_effect=ConnectionError("boom")):
257 result = enrich_with_comments(items, "fake-token", depth="quick", budget_seconds=60)
258
259 self.assertEqual(len(result), 3)
260 enriched = [i for i in result if i.get("top_comments")]
261 self.assertEqual(len(enriched), 0)
262
263 if __name__ == "__main__":
264 unittest.main()
265
266
267 def test_window_to_time_filter_rounds_up_for_rolling_buckets(monkeypatch):
268 """Recent calendar windows need the next Reddit rolling bucket (Greptile on #860)."""
269 from datetime import date
270 from lib import reddit
271
272 class _FrozenDateTime:
273 @staticmethod
274 def now(tz=None):
275 class _D:
276 @staticmethod
277 def date():
278 return date(2026, 7, 24)
279 return _D()
280
281 monkeypatch.setattr(reddit, "datetime", _FrozenDateTime)
282 assert reddit._window_to_time_filter("2026-07-23", "2026-07-24") == "week"
283 assert reddit._window_to_time_filter("2026-07-17", "2026-07-24") == "month"
284 assert reddit._window_to_time_filter("2026-06-24", "2026-07-24") == "month"
285 # Just past the month bucket (+1 slack) needs year.
286 assert reddit._window_to_time_filter("2026-06-23", "2026-07-24") == "year"
287
288
289 def test_window_to_time_filter_covers_historical_from_date(monkeypatch):
290 """Short historical windows must still reach from_date (Greptile follow-up on #860)."""
291 from datetime import date
292 from lib import reddit
293
294 class _FrozenDateTime:
295 @staticmethod
296 def now(tz=None):
297 class _D:
298 @staticmethod
299 def date():
300 return date(2026, 7, 24)
301 return _D()
302
303 monkeypatch.setattr(reddit, "datetime", _FrozenDateTime)
304 # One-day request ending two weeks ago: span alone would pick "week" and
305 # miss the entire range; age of from_date requires "month".
306 assert reddit._window_to_time_filter("2026-07-09", "2026-07-10") == "month"
307
307 lines PYTHON