返回 last30days-skill
test_hackernews_comment_floor.py
根目录 / tests / test_hackernews_comment_floor.py
1 """Hacker News comments must survive the per-source top-comment floor.
2
3 HN comments arrive as ``{author, text, points}`` while every downstream reader
4 keys on ``score``/``excerpt``. Before the fix, ``_normalize_hackernews`` stored
5 them raw, so ``render._top_comments_list`` evaluated ``(c.get("score") or 0) >= 5``
6 against a key that was never present and rejected the entire source.
7 """
8
9 from unittest.mock import patch
10
11 from lib import hackernews, normalize, render, schema
12
13 FROM_DATE = "2026-06-26"
14 TO_DATE = "2026-07-26"
15
16
17 def _hn_item():
18 return {
19 "id": "42",
20 "title": "Show HN: a thing",
21 "url": "https://example.test/thing",
22 "hn_url": "https://news.ycombinator.com/item?id=42",
23 "author": "pg",
24 "date": "2026-07-20",
25 "engagement": {"points": 420, "comments": 2},
26 "top_comments": [
27 {
28 "author": "alice",
29 "text": "The single clearest explanation I have read.",
30 "points": 93,
31 },
32 {
33 "author": "bob",
34 "text": "Counterpoint: the benchmark skips the hard cases.",
35 "points": None,
36 },
37 ],
38 }
39
40
41 def _reddit_item():
42 return {
43 "id": "r1",
44 "title": "a reddit thread",
45 "url": "https://reddit.test/r1",
46 "author": "carol",
47 "date": "2026-07-20",
48 "engagement": {"upvotes": 300, "comments": 2},
49 "top_comments": [
50 {
51 "author": "alice",
52 "excerpt": "The single clearest explanation I have read.",
53 "score": 93,
54 },
55 {
56 "author": "bob",
57 "excerpt": "Counterpoint: the benchmark skips the hard cases.",
58 "score": 51,
59 },
60 ],
61 }
62
63
64 def test_hn_comments_are_remapped_to_the_shared_shape():
65 item = normalize._normalize_hackernews(
66 "hackernews", _hn_item(), 0, FROM_DATE, TO_DATE
67 )
68 comments = item.metadata["top_comments"]
69 assert comments, "HN comments should survive normalisation"
70 for comment in comments:
71 assert "score" in comment, (
72 f"expected the shared score key, got {sorted(comment)}"
73 )
74 assert "excerpt" in comment, (
75 f"expected the shared excerpt key, got {sorted(comment)}"
76 )
77 assert comments[0]["score"] == 93, "points must carry through as score"
78
79
80 def test_hn_comments_clear_the_per_source_floor():
81 item = normalize._normalize_hackernews(
82 "hackernews", _hn_item(), 0, FROM_DATE, TO_DATE
83 )
84 assert render._top_comments_list(item), (
85 "HN comments must not be filtered out by a floor keyed on a field the "
86 "source never populated"
87 )
88
89
90 def test_hn_floor_is_zero_because_hn_has_no_per_comment_points():
91 """The Algolia items endpoint returns points=null for every comment child.
92
93 Any positive threshold therefore rejects the whole source rather than
94 filtering it, so this constant is load-bearing rather than a tuning knob.
95 """
96 assert render._TOP_COMMENT_MIN_SCORE["hackernews"] == 0
97
98
99 def test_reddit_control_is_unchanged():
100 item = normalize._normalize_reddit("reddit", _reddit_item(), 0, FROM_DATE, TO_DATE)
101 assert len(render._top_comments_list(item)) == 2
102
103
104 def _candidate(source, item):
105 return schema.Candidate(
106 candidate_id="c1",
107 item_id=item.item_id,
108 source=source,
109 title=item.title,
110 url=item.url,
111 snippet="",
112 subquery_labels=[],
113 native_ranks={},
114 local_relevance=0.9,
115 freshness=1,
116 engagement=100,
117 source_quality=0.9,
118 rrf_score=1.0,
119 source_items=[item],
120 final_score=50.0,
121 explanation="llm-rerank",
122 )
123
124
125 def _report(item, candidate):
126 return schema.Report(
127 topic="HN comments",
128 range_from=FROM_DATE,
129 range_to=TO_DATE,
130 generated_at=f"{TO_DATE}T00:00:00+00:00",
131 provider_runtime=schema.ProviderRuntime(
132 reasoning_provider="local",
133 planner_model="local",
134 rerank_model="local",
135 ),
136 query_plan=schema.QueryPlan(
137 intent="general",
138 freshness_mode="balanced_recent",
139 cluster_mode="story",
140 raw_topic="HN comments",
141 subqueries=[
142 schema.SubQuery(
143 label="primary",
144 search_query="HN comments",
145 ranking_query="What are the top HN comments?",
146 sources=["hackernews"],
147 )
148 ],
149 source_weights={"hackernews": 1.0},
150 ),
151 clusters=[],
152 ranked_candidates=[candidate],
153 items_by_source={"hackernews": [item]},
154 errors_by_source={},
155 )
156
157
158 def _render_vote_paths(points):
159 raw = _hn_item()
160 with patch("lib.hackernews.http.request") as request:
161 request.return_value = {
162 "children": [
163 {
164 "author": "alice",
165 "text": raw["top_comments"][0]["text"],
166 "points": 93,
167 },
168 {
169 "author": "bob",
170 "text": raw["top_comments"][1]["text"],
171 "points": points,
172 },
173 ]
174 }
175 raw["top_comments"] = hackernews._fetch_item_comments("42")["comments"]
176 hn = normalize._normalize_hackernews(
177 "hackernews", raw, 0, FROM_DATE, TO_DATE
178 )
179 candidate = _candidate("hackernews", hn)
180 report = _report(hn, candidate)
181 return hn.metadata["top_comments"][1]["score"], {
182 "full": render.render_full(report),
183 "candidate": "\n".join(render._render_candidate(candidate, prefix="1.")),
184 "top_comments": "\n".join(render._render_top_comments(report)),
185 }
186
187
188 def test_absent_hn_vote_is_omitted_across_comment_renderers():
189 """A missing vote must not display a fabricated numeric measurement."""
190 normalized_score, rendered_paths = _render_vote_paths(None)
191 assert normalized_score is None
192 for path, rendered in rendered_paths.items():
193 assert "(0 points)" not in rendered, f"{path}:\n{rendered}"
194 assert "(93 points)" in rendered, f"{path} dropped a real vote count"
195
196
197 def test_explicit_zero_hn_vote_is_rendered_across_comment_renderers():
198 """A measured zero remains visible in every zero-admitting comment path."""
199 normalized_score, rendered_paths = _render_vote_paths(0)
200 assert normalized_score == 0
201 for path, rendered in rendered_paths.items():
202 assert "(0 points)" in rendered, f"{path} hid an explicit numeric zero"
203
204
205 def test_best_takes_uses_normalized_hn_comment_excerpt():
206 """Best Takes should display HN comment text, not only the story title."""
207 first_raw = _hn_item()
208 first_raw["title"] = "Show HN: a deliberately long story title for testing"
209 first_raw["top_comments"] = [
210 {"author": "alice", "text": "A sharp HN take.", "points": None}
211 ]
212 second_raw = _hn_item()
213 second_raw["id"] = "43"
214 second_raw["title"] = "Ask HN: another deliberately long story title for testing"
215 second_raw["top_comments"] = [
216 {"author": "bob", "text": "Another good take.", "points": None}
217 ]
218 first = _candidate(
219 "hackernews",
220 normalize._normalize_hackernews(
221 "hackernews", first_raw, 0, FROM_DATE, TO_DATE
222 ),
223 )
224 second = _candidate(
225 "hackernews",
226 normalize._normalize_hackernews(
227 "hackernews", second_raw, 1, FROM_DATE, TO_DATE
228 ),
229 )
230 first.fun_score = 80.0
231 second.fun_score = 80.0
232
233 rendered = "\n".join(
234 render._render_best_takes([first, second], threshold=70.0, vote_weight=0.0)
235 )
236 assert "A sharp HN take." in rendered
237 assert "Another good take." in rendered
238
239
240 def test_best_takes_uses_hn_comment_longer_than_story_title():
241 """HN comment excerpts remain the take even when longer than the title."""
242 first_raw = _hn_item()
243 first_raw["title"] = "Short story"
244 first_raw["top_comments"] = [
245 {
246 "author": "alice",
247 "text": "This longer Hacker News comment is the actual sharp take.",
248 "points": None,
249 }
250 ]
251 second_raw = _hn_item()
252 second_raw["id"] = "43"
253 second_raw["top_comments"] = [
254 {"author": "bob", "text": "Another good take.", "points": None}
255 ]
256 first = _candidate(
257 "hackernews",
258 normalize._normalize_hackernews(
259 "hackernews", first_raw, 0, FROM_DATE, TO_DATE
260 ),
261 )
262 second = _candidate(
263 "hackernews",
264 normalize._normalize_hackernews(
265 "hackernews", second_raw, 1, FROM_DATE, TO_DATE
266 ),
267 )
268 first.fun_score = 80.0
269 second.fun_score = 80.0
270
271 rendered = "\n".join(
272 render._render_best_takes([first, second], threshold=70.0, vote_weight=0.0)
273 )
274 assert "This longer Hacker News comment is the actual sharp take." in rendered
275 assert '"Short story"' not in rendered
276
277
278 def test_best_takes_uses_hn_excerpt_retained_by_fused_candidate():
279 """A non-HN representative must not hide its retained HN comment."""
280 reddit = normalize._normalize_reddit(
281 "reddit", _reddit_item(), 0, FROM_DATE, TO_DATE
282 )
283 hn_raw = _hn_item()
284 hn_raw["top_comments"] = [
285 {"author": "alice", "text": "The retained HN take.", "points": 93}
286 ]
287 hn = normalize._normalize_hackernews(
288 "hackernews", hn_raw, 0, FROM_DATE, TO_DATE
289 )
290 fused = _candidate("reddit", reddit)
291 fused.source_items = [reddit, hn]
292 assert fused.source != "hackernews"
293 control_raw = _reddit_item()
294 control_raw["id"] = "r2"
295 control_raw["title"] = "a separate reddit thread"
296 control = _candidate(
297 "reddit",
298 normalize._normalize_reddit(
299 "reddit", control_raw, 1, FROM_DATE, TO_DATE
300 ),
301 )
302 fused.fun_score = 80.0
303 control.fun_score = 80.0
304
305 with patch(
306 "lib.render.signals.normalized_comment_vote",
307 wraps=render.signals.normalized_comment_vote,
308 ) as normalized_vote:
309 top_comments = "\n".join(
310 render._render_top_comments(
311 _report(reddit, fused),
312 candidates=[fused, control],
313 )
314 )
315 rendered = "\n".join(
316 render._render_best_takes([fused, control], threshold=70.0, vote_weight=0.0)
317 )
318 assert "The retained HN take." in rendered
319 fused_line = next(
320 line for line in rendered.splitlines() if "The retained HN take." in line
321 )
322 assert "-- Hacker News " in fused_line
323 assert "Reddit" not in fused_line
324 assert "r/" not in fused_line
325 retained_line = next(
326 line for line in top_comments.splitlines() if "The retained HN take." in line
327 )
328 assert "— alice (93 points)" in retained_line
329 assert "upvotes" not in retained_line
330 assert "u/alice" not in retained_line
331 assert any(
332 call.args == ("hackernews", 93) for call in normalized_vote.call_args_list
333 )
334
334 lines PYTHON