返回 last30days-skill
test_reddit_shreddit.py
根目录 / tests / test_reddit_shreddit.py
1 """Tests for scripts/lib/reddit_shreddit.py — keyless shreddit comment scrape."""
2
3 from pathlib import Path
4 from unittest import mock
5
6 from lib import reddit_shreddit as rs
7
8 FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "reddit_shreddit_comments_sample.html"
9
10
11 def _html():
12 return FIXTURE.read_text(encoding="utf-8")
13
14
15 class TestExtractPostRef:
16 def test_extracts_sub_and_id(self):
17 ref = rs.extract_post_ref("https://www.reddit.com/r/Rakuten/comments/1taeiw0/title/")
18 assert ref == ("Rakuten", "1taeiw0")
19
20 def test_non_thread_url_returns_none(self):
21 assert rs.extract_post_ref("https://www.reddit.com/r/Rakuten/") is None
22 assert rs.extract_post_ref("") is None
23
24 def test_svc_url_shape(self):
25 # sort=top guarantees the highest-scored comments land on page 1.
26 assert rs._svc_url("Rakuten", "1taeiw0") == (
27 "https://www.reddit.com/svc/shreddit/comments/r/Rakuten/t3_1taeiw0?sort=top"
28 )
29
30
31 class TestParseComments:
32 """parse_comments reads <shreddit-comment> elements into scored dicts."""
33
34 def test_happy_path(self):
35 comments = rs.parse_comments(_html())
36 assert len(comments) >= 1
37 for c in comments:
38 assert isinstance(c["score"], int)
39 assert c["author"] and c["author"] not in ("[deleted]", "[removed]")
40 assert c["body"]
41
42 def test_sorted_by_score_desc(self):
43 scores = [c["score"] for c in rs.parse_comments(_html())]
44 assert scores == sorted(scores, reverse=True)
45
46 def test_deleted_and_removed_filtered(self):
47 authors = [c["author"] for c in rs.parse_comments(_html())]
48 assert "[deleted]" not in authors and "[removed]" not in authors
49
50 def test_negative_score_retained(self):
51 scores = [c["score"] for c in rs.parse_comments(_html())]
52 assert -7 in scores # synthetic downvoted-but-real comment
53
54 def test_limit_honored(self):
55 assert len(rs.parse_comments(_html(), limit=2)) == 2
56
57 def test_body_text_extracted(self):
58 bodies = [c["body"] for c in rs.parse_comments(_html())]
59 assert any("$750" in b or "pending" in b for b in bodies)
60
61 def test_comment_url_built(self):
62 for c in rs.parse_comments(_html()):
63 if c["url"]:
64 assert c["url"].startswith("https://reddit.com/r/")
65
66 def test_empty_html_returns_empty(self):
67 assert rs.parse_comments("") == []
68 assert rs.parse_comments("<html>no comments here</html>") == []
69
70
71 class TestBotFilter:
72 """Bot comments occupy top-comment slots without carrying community signal."""
73
74 @staticmethod
75 def _comment_html(author, thing_id="t1_botfilter1", score=999):
76 return (
77 f'<shreddit-comment author="{author}" thingId="{thing_id}" '
78 f'score="{score}" permalink="/r/test/comments/1/x/{thing_id}/">'
79 f'</shreddit-comment>'
80 f'<div id="{thing_id}-post-rtjson-content">'
81 f'<p>I will be messaging you in 3 days to remind you of this link.</p>'
82 f'</div>'
83 )
84
85 def test_known_bots_dropped(self):
86 for bot in ("RemindMeBot", "AutoModerator", "sneakpeekbot"):
87 assert rs.parse_comments(self._comment_html(bot)) == [], bot
88
89 def test_bot_match_is_case_insensitive(self):
90 assert rs.parse_comments(self._comment_html("remindmebot")) == []
91
92 def test_separator_suffix_bots_dropped(self):
93 for bot in ("some-random-bot", "subreddit_bot"):
94 assert rs.parse_comments(self._comment_html(bot)) == [], bot
95
96 def test_camelcase_bots_dropped(self):
97 # The separator-free convention is the common one on Reddit.
98 for bot in ("WikiTextBot", "RepostSleuthBot", "RemindMeBot2"):
99 assert rs.parse_comments(self._comment_html(bot)) == [], bot
100
101 def test_human_authors_kept(self):
102 # Names merely ending in "bot" are people, not bots. The capital B in
103 # the camelCase rule is what separates "WikiTextBot" from "Talbot".
104 for human in ("Talbot", "abbot", "u_bothell_local", "MSRS-",
105 "TheBotanist", "Botany101", "Robotics_fan"):
106 out = rs.parse_comments(self._comment_html(human))
107 assert len(out) == 1, human
108 assert out[0]["author"] == human
109
110 def test_bot_does_not_displace_human_from_slot(self):
111 # The reported failure was a bot *taking a slot*, not merely appearing:
112 # it outscores the humans, so it wins the ranking before truncation.
113 html = (self._comment_html("RemindMeBot", thing_id="t1_bot", score=999)
114 + self._comment_html("real_person", thing_id="t1_human", score=5))
115 out = rs.parse_comments(html, limit=1)
116 assert [c["author"] for c in out] == ["real_person"]
117
118 def test_is_bot_author_handles_blank(self):
119 assert rs._is_bot_author("") is False
120 assert rs._is_bot_author(None) is False
121
122
123 class TestTotalComments:
124 def test_reads_total(self):
125 assert rs._total_comments(_html()) == 14
126
127 def test_missing_returns_none(self):
128 assert rs._total_comments("<html></html>") is None
129
130
131 class TestFetchComments:
132 """fetch_comments wires URL -> svc fetch -> parse, never raising."""
133
134 def test_happy_path(self):
135 url = "https://www.reddit.com/r/Rakuten/comments/1taeiw0/title/"
136 with mock.patch.object(rs.http, "get_text", return_value=_html()) as m:
137 out = rs.fetch_comments(url)
138 # svc endpoint, not .json
139 assert "/svc/shreddit/comments/" in m.call_args[0][0]
140 assert ".json" not in m.call_args[0][0]
141 assert out["num_comments"] == 14
142 assert len(out["top_comments"]) >= 1
143 first = out["top_comments"][0]
144 assert {"score", "date", "author", "excerpt", "url"} <= set(first.keys())
145 assert isinstance(out["comment_insights"], list)
146
147 def test_bad_url_returns_empty(self):
148 out = rs.fetch_comments("https://www.reddit.com/r/Rakuten/")
149 assert out["top_comments"] == [] and out["num_comments"] is None
150
151 def test_fetch_failure_returns_empty(self):
152 url = "https://www.reddit.com/r/Rakuten/comments/1taeiw0/title/"
153 with mock.patch.object(rs.http, "get_text", return_value=None):
154 out = rs.fetch_comments(url)
155 assert out["top_comments"] == [] and out["num_comments"] is None
156
157
158 class TestEnrichmentBudget:
159 """Busy topics enrich more threads and carry more comments per thread."""
160
161 def test_enrich_limits_by_depth(self):
162 assert rs.ENRICH_LIMITS == {"quick": 4, "default": 8, "deep": 12}
163
164 def test_parse_comments_returns_up_to_twelve(self):
165 html = "".join(
166 f'<shreddit-comment author="user{i}" thingId="t1_c{i}" score="{100 - i}" '
167 f'permalink="/r/test/comments/1/x/t1_c{i}/"></shreddit-comment>'
168 f'<div id="t1_c{i}-post-rtjson-content"><p>comment number {i} body text</p></div>'
169 for i in range(30)
170 )
171 out = rs.parse_comments(html)
172 assert len(out) == rs.MAX_COMMENTS == 12
173 assert [c["score"] for c in out] == list(range(100, 88, -1))
174
174 lines PYTHON