返回 last30days-skill
test_registers.py
根目录 / tests / test_registers.py
1 from __future__ import annotations
2
3 import hashlib
4 import re
5 import sys
6
7 import pytest
8
9 import last30days as cli
10 from lib import env, html_render, registers, render, schema
11
12
13 SOURCES = [
14 "reddit",
15 "github",
16 "youtube",
17 "tiktok",
18 "instagram",
19 "hackernews",
20 "polymarket",
21 "grounding",
22 "x",
23 "arxiv",
24 "jobs",
25 "bluesky",
26 ]
27
28
29 def fixture_report() -> schema.Report:
30 candidates: list[schema.Candidate] = []
31 clusters: list[schema.Cluster] = []
32 items_by_source: dict[str, list[schema.SourceItem]] = {}
33 for index, source in enumerate(SOURCES, start=1):
34 item = schema.SourceItem(
35 item_id=f"item-{index}",
36 source=source,
37 title=f"{source} signal with a detailed audience-ready headline {index}",
38 body=f"Evidence body for {source}.",
39 url=f"https://example.com/{source}/{index}",
40 author=f"voice{index}",
41 container="community",
42 published_at="2026-07-09",
43 date_confidence="high",
44 engagement={"score": 1000 - index, "likes": 2000 - index},
45 snippet=f"Technical and community evidence from {source}.",
46 metadata={
47 "top_comments": [
48 {
49 "excerpt": f"Memorable community reaction number {index}.",
50 "score": 1000 - index,
51 "author": f"commenter{index}",
52 "url": f"https://example.com/{source}/{index}#comment",
53 }
54 ]
55 },
56 )
57 candidate = schema.Candidate(
58 candidate_id=f"candidate-{index}",
59 item_id=item.item_id,
60 source=source,
61 title=item.title,
62 url=item.url,
63 snippet=item.snippet,
64 subquery_labels=["primary"],
65 native_ranks={f"primary:{source}": index},
66 local_relevance=0.95,
67 freshness=95,
68 engagement=90,
69 source_quality=1.0,
70 rrf_score=0.02,
71 sources=[source],
72 source_items=[item],
73 rerank_score=95,
74 final_score=101 - index,
75 fun_score=90,
76 fun_explanation="high-signal phrasing",
77 )
78 cluster = schema.Cluster(
79 cluster_id=f"cluster-{index}",
80 title=f"{source} storyline {index}",
81 candidate_ids=[candidate.candidate_id],
82 representative_ids=[candidate.candidate_id],
83 sources=[source],
84 score=101 - index,
85 )
86 candidates.append(candidate)
87 clusters.append(cluster)
88 items_by_source[source] = [item]
89
90 return schema.Report(
91 topic="audience register research",
92 range_from="2026-06-10",
93 range_to="2026-07-10",
94 generated_at="2026-07-10T12:00:00Z",
95 provider_runtime=schema.ProviderRuntime(
96 reasoning_provider="local",
97 planner_model="fixture",
98 rerank_model="fixture",
99 ),
100 query_plan=schema.QueryPlan(
101 intent="general",
102 freshness_mode="strict_recent",
103 cluster_mode="story",
104 raw_topic="audience register research",
105 subqueries=[
106 schema.SubQuery(
107 label="primary",
108 search_query="audience register research",
109 ranking_query="What matters?",
110 sources=SOURCES,
111 )
112 ],
113 source_weights={source: 1.0 for source in SOURCES},
114 ),
115 clusters=clusters,
116 ranked_candidates=candidates,
117 items_by_source=items_by_source,
118 errors_by_source={},
119 artifacts={"pre_research_flags_present": True},
120 )
121
122
123 def _headings(output: str) -> list[str]:
124 return re.findall(r"^## (.+)$", output, flags=re.MULTILINE)
125
126
127 def _cluster_count(output: str) -> int:
128 evidence = output.split("## Ranked Evidence Clusters", 1)[1]
129 evidence = evidence.split("\n## ", 1)[0]
130 return len(re.findall(r"^### \d+\.", evidence, flags=re.MULTILINE))
131
132
133 def _bullet_count(output: str, heading: str) -> int:
134 section = output.split(f"## {heading}", 1)[1]
135 section = section.split("\n## ", 1)[0]
136 return len(re.findall(r'^- "', section, flags=re.MULTILINE))
137
138
139 @pytest.mark.parametrize(
140 ("name", "expected_order", "cluster_budget", "comment_budget"),
141 [
142 (
143 "exec",
144 ["Stats", "Ranked Evidence Clusters", "Source Coverage", "Best Takes", "Top Community Comments"],
145 5,
146 3,
147 ),
148 (
149 "dev",
150 ["Ranked Evidence Clusters", "Source Coverage", "Stats", "Top Community Comments", "Best Takes"],
151 10,
152 4,
153 ),
154 (
155 "creator",
156 ["Best Takes", "Top Community Comments", "Stats", "Ranked Evidence Clusters", "Source Coverage"],
157 6,
158 # The register's own top_comments budget. It used to read 6 only
159 # because the comments pool was limited to the 6 visible clusters;
160 # the pool now spans every floor-clearing cluster.
161 8,
162 ),
163 ],
164 )
165 def test_registers_control_section_order_and_budgets(
166 name: str,
167 expected_order: list[str],
168 cluster_budget: int,
169 comment_budget: int,
170 ):
171 output = render.render_compact(fixture_report(), register=name)
172
173 headings = _headings(output)
174 assert [heading for heading in headings if heading in expected_order] == expected_order
175 assert _cluster_count(output) == cluster_budget
176 assert _bullet_count(output, "Top Community Comments") == comment_budget
177
178
179 def test_emphasis_weights_promote_audience_specific_sources():
180 report = fixture_report()
181
182 dev = render.render_compact(report, register="dev")
183 creator = render.render_compact(report, register="creator")
184
185 assert "### 1. github storyline" in dev
186 assert "### 1. tiktok storyline" in creator
187
188
189 def test_creator_register_leads_markdown_and_html_with_best_takes():
190 report = fixture_report()
191
192 markdown = render.render_compact(report, register="creator")
193 html = html_render.render_html(report, register="creator")
194
195 assert markdown.index("## Best Takes") < markdown.index("## Ranked Evidence Clusters")
196 assert html.index("<h2>Best Takes</h2>") < html.index("<h2>Ranked Evidence Clusters</h2>")
197
198
199 def test_default_register_is_byte_identical_when_omitted(monkeypatch):
200 monkeypatch.setattr(render, "_render_badge", lambda: ["fixed badge", ""])
201 monkeypatch.setattr(render, "_skill_version", lambda: "fixture")
202 report = fixture_report()
203
204 implicit = render.render_compact(report)
205 explicit = render.render_compact(report, register="default")
206
207 assert implicit == explicit
208 assert hashlib.sha256(implicit.encode()).hexdigest() == (
209 # Hash includes #886's linked evidence URLs, #890's Hacker News
210 # comment-rendering changes, the quiet footer (no outcome text, no
211 # ## Source Errors in compact), and the comments pool reading every
212 # floor-clearing cluster.
213 "81fdfc85643d124f2c06ff0bac8956c30280c652436bc4a58dbfc37718be71e1"
214 )
215
216
217 def test_eli5_is_renderer_equivalent_to_default():
218 report = fixture_report()
219
220 assert render.render_compact(report, register="eli5") == render.render_compact(
221 report, register="default"
222 )
223
224
225 def test_cli_and_env_register_resolution():
226 args = cli.build_parser().parse_args(["topic", "--register", "exec"])
227 assert args.register == "exec"
228 assert cli._audience_register_for_run(args, {}, None).name == "exec"
229
230 args = cli.build_parser().parse_args(["topic"])
231 assert cli._audience_register_for_run(
232 args, {"LAST30DAYS_REGISTER": "creator"}, None
233 ).name == "creator"
234 assert cli._audience_register_for_run(
235 args, {"ELI5_MODE": "true"}, None
236 ).name == "eli5"
237 assert cli._audience_register_for_run(
238 args, {"LAST30DAYS_REGISTER": "default", "ELI5_MODE": "true"}, None
239 ).name == "default"
240
241
242 def test_last30days_register_round_trips_from_process_env(monkeypatch, tmp_path):
243 monkeypatch.setenv("LAST30DAYS_CONFIG_DIR", str(tmp_path))
244 monkeypatch.setenv("LAST30DAYS_REGISTER", "dev")
245 monkeypatch.setattr(env, "CONFIG_DIR", tmp_path)
246 monkeypatch.setattr(env, "CONFIG_FILE", tmp_path / "does-not-exist.env")
247 monkeypatch.setattr(env, "_load_keychain", lambda *args, **kwargs: {})
248 monkeypatch.setattr(env, "_load_pass", lambda *args, **kwargs: {})
249
250 assert env.get_config()["LAST30DAYS_REGISTER"] == "dev"
251
252
253 def test_registers_do_not_shape_drill_output():
254 args = cli.build_parser().parse_args(["--drill", "cluster 1"])
255
256 assert cli._audience_register_for_run(
257 args, {"LAST30DAYS_REGISTER": "creator"}, None
258 ).name == "default"
259
260
261 def test_registers_do_not_shape_comparison_output():
262 args = cli.build_parser().parse_args(
263 ["alpha", "vs", "beta", "--register=creator"]
264 )
265
266 assert cli._audience_register_for_run(args, {}, None).name == "default"
267
268
269 @pytest.mark.parametrize(
270 "topic",
271 [
272 "alpha/beta",
273 "alpha compared to beta",
274 "difference between alpha and beta",
275 ],
276 )
277 def test_registers_use_canonical_comparison_detection(topic):
278 args = cli.build_parser().parse_args([topic])
279
280 assert cli._audience_register_for_run(
281 args, {"LAST30DAYS_REGISTER": "board"}, None
282 ).name == "default"
283
284
285 def test_registered_html_excludes_source_failure_diagnostics():
286 report = fixture_report()
287 report.source_status["x"] = schema.SourceOutcome(
288 source="x",
289 state=schema.RATE_LIMITED,
290 detail="HTTP 429 after retry budget",
291 fix_hint="doctor",
292 )
293 report.errors_by_source["x"] = "private source error diagnostic"
294
295 html = html_render.render_html(report, register="creator")
296
297 assert "Partial Coverage" not in html
298 assert "Source Errors" not in html
299 assert "private source error diagnostic" not in html
300
301
302 def test_unknown_register_errors_cleanly():
303 with pytest.raises(ValueError, match="unknown audience register"):
304 registers.get_register("board")
305
306 args = cli.build_parser().parse_args(["topic"])
307 with pytest.raises(ValueError, match="unknown audience register"):
308 cli._audience_register_for_run(
309 args, {"LAST30DAYS_REGISTER": "board"}, None
310 )
311
312 with pytest.raises(SystemExit) as exc:
313 cli.build_parser().parse_args(["topic", "--register", "board"])
314 assert exc.value.code == 2
315
316
317 def test_unknown_configured_register_fails_before_retrieval(monkeypatch, capsys):
318 monkeypatch.setattr(
319 cli.env,
320 "get_config",
321 lambda **_kwargs: {"LAST30DAYS_REGISTER": "board"},
322 )
323 monkeypatch.setattr(
324 cli.pipeline,
325 "diagnose",
326 lambda *_args, **_kwargs: pytest.fail("retrieval preflight should not run"),
327 )
328 monkeypatch.setattr(sys, "argv", ["last30days.py", "test topic"])
329
330 assert cli.main() == 2
331 assert "unknown audience register 'board'" in capsys.readouterr().err
332
333
334 def test_creator_best_takes_honor_source_emphasis():
335 from lib import registers, render
336
337 audience = registers.get_register("creator")
338 assert audience.emphasis_weights, "creator preset must define emphasis weights"
339 # TikTok emphasis must exceed baseline sources like hackernews.
340 assert audience.emphasis_for("tiktok") > audience.emphasis_for("hackernews")
341
342
343 def test_best_takes_ranking_applies_source_weights():
344 from lib import render, schema
345
346 def candidate(cid, source, fun):
347 item = schema.SourceItem(
348 item_id=cid, source=source, title=f"take {cid}", body="b",
349 url=f"https://{source}/{cid}", published_at="2026-07-01",
350 snippet="s", engagement={"likes": 10},
351 )
352 return schema.Candidate(
353 candidate_id=cid, item_id=cid, source=source, title=f"take {cid}",
354 url=item.url, snippet="s", subquery_labels=["primary"],
355 native_ranks={f"primary:{source}": 1}, local_relevance=0.9,
356 freshness=90, engagement=10, source_quality=0.5, rrf_score=0.1,
357 final_score=90, cluster_id="cl", source_items=[item],
358 fun_score=80.0,
359 )
360
361 hn = candidate("hn1", "hackernews", 80.0)
362 tt = candidate("tt1", "tiktok", 80.0)
363 weights = {"tiktok": 1.5, "hackernews": 1.0}
364 lines = render._render_best_takes(
365 [hn, tt], limit=2, threshold=70.0,
366 source_weight=lambda source: weights.get(source, 1.0),
367 )
368 body = "\n".join(lines)
369 assert body.index("TikTok") < body.index("Hacker News") or body.index("tiktok") < body.index("hackernews") if "tiktok" in body.lower() else True
370 # Structural assertion: the tiktok take renders before the HN take.
371 tt_pos = body.lower().find("tiktok")
372 hn_pos = body.lower().find("hacker")
373 assert tt_pos != -1 and hn_pos != -1
374 assert tt_pos < hn_pos
375
375 lines PYTHON