返回 last30days-skill
test_render_comparison_multi.py
根目录 / tests / test_render_comparison_multi.py
1 """Tests for render.render_comparison_multi and emit_comparison_output."""
2
3 from __future__ import annotations
4
5 import json
6 import unittest
7
8 import last30days as cli
9 from lib import render, schema
10
11
12 def _build_report(topic: str, cluster_titles: list[str]) -> schema.Report:
13 query_plan = schema.QueryPlan(
14 intent="comparison",
15 freshness_mode="balanced_recent",
16 cluster_mode="debate",
17 raw_topic=topic,
18 subqueries=[
19 schema.SubQuery(
20 label="primary",
21 search_query=topic,
22 ranking_query=topic,
23 sources=["grounding"],
24 )
25 ],
26 source_weights={"grounding": 1.0},
27 )
28 clusters: list[schema.Cluster] = []
29 candidates: list[schema.Candidate] = []
30 for idx, title in enumerate(cluster_titles):
31 candidate_id = f"{topic.lower().replace(' ', '-')}-c{idx}"
32 item = schema.SourceItem(
33 source="grounding",
34 item_id=f"g-{candidate_id}",
35 title=f"{title} evidence",
36 body=f"Body for {title}",
37 url=f"https://example.test/{candidate_id}",
38 snippet=f"Snippet for {title}",
39 published_at="2026-04-20",
40 )
41 candidate = schema.Candidate(
42 candidate_id=candidate_id,
43 item_id=item.item_id,
44 source="grounding",
45 title=item.title,
46 url=item.url,
47 snippet=item.snippet,
48 subquery_labels=["primary"],
49 native_ranks={"grounding": idx + 1},
50 local_relevance=0.8 - idx * 0.1,
51 freshness=5,
52 engagement=10,
53 source_quality=0.9,
54 rrf_score=0.6 - idx * 0.05,
55 sources=["grounding"],
56 source_items=[item],
57 final_score=80.0 - idx * 5,
58 )
59 candidates.append(candidate)
60 clusters.append(
61 schema.Cluster(
62 cluster_id=f"cl-{idx}",
63 title=title,
64 candidate_ids=[candidate_id],
65 representative_ids=[candidate_id],
66 score=80.0 - idx * 5,
67 sources=["grounding"],
68 )
69 )
70 return schema.Report(
71 topic=topic,
72 range_from="2026-03-23",
73 range_to="2026-04-22",
74 generated_at="2026-04-22T00:00:00+00:00",
75 provider_runtime=schema.ProviderRuntime(
76 reasoning_provider="mock",
77 planner_model="mock-planner",
78 rerank_model="mock-rerank",
79 ),
80 query_plan=query_plan,
81 clusters=clusters,
82 ranked_candidates=candidates,
83 items_by_source={"grounding": [c.source_items[0] for c in candidates]},
84 errors_by_source={},
85 )
86
87
88 class RenderComparisonMultiTests(unittest.TestCase):
89 def test_three_entity_table(self):
90 reports = [
91 ("OpenAI", _build_report("OpenAI", ["GPT-5 drop", "API pricing cut"])),
92 ("Anthropic", _build_report("Anthropic", ["Claude 4.7 ship", "MCP rollout"])),
93 ("xAI", _build_report("xAI", ["Grok 4 release", "Memphis cluster"])),
94 ]
95 rendered = render.render_comparison_multi(reports)
96 # All three entities appear in the header
97 self.assertIn("OpenAI vs Anthropic vs xAI", rendered)
98 # Each entity has its own evidence section
99 self.assertIn("## OpenAI", rendered)
100 self.assertIn("## Anthropic", rendered)
101 self.assertIn("## xAI", rendered)
102 # Scaffold table header has a column per entity
103 self.assertIn("| Dimension | OpenAI | Anthropic | xAI |", rendered)
104 # No verdict row: the pitch-vs-pulse signal ships as synthesis prose,
105 # not a table axis (early drafts emitted a "Setting the narrative?" row)
106 self.assertNotIn("Setting the narrative?", rendered)
107 # "What it is" grounds in positioning fetched this run, never memory
108 self.assertIn("never from memory", rendered)
109 # Envelope scaffolding present
110 self.assertIn("EVIDENCE FOR SYNTHESIS", rendered)
111 self.assertIn("END OF last30days CANONICAL OUTPUT", rendered)
112
113 def test_two_entity_table_has_two_columns(self):
114 reports = [
115 ("Kanye West", _build_report("Kanye West", ["Donda 2 release"])),
116 ("Drake", _build_report("Drake", ["For All The Dogs"])),
117 ]
118 rendered = render.render_comparison_multi(reports)
119 self.assertIn("| Dimension | Kanye West | Drake |", rendered)
120 self.assertIn("## Kanye West", rendered)
121 self.assertIn("## Drake", rendered)
122
123 def test_empty_clusters_renders_placeholder(self):
124 reports = [
125 ("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
126 ("ObscureCompetitor", _build_report("ObscureCompetitor", [])),
127 ]
128 rendered = render.render_comparison_multi(reports)
129 self.assertIn("## ObscureCompetitor", rendered)
130 self.assertIn("no significant discussion this month", rendered)
131 # Main still has its cluster
132 self.assertIn("GPT-5 drop", rendered)
133
134 def test_warnings_aggregated_and_labeled(self):
135 report_a = _build_report("OpenAI", ["GPT-5 drop"])
136 report_b = _build_report("Anthropic", ["Claude 4.7"])
137 report_a.warnings.append("Brave quota exhausted")
138 report_b.warnings.append("Exa returned 0 results")
139 rendered = render.render_comparison_multi(
140 [("OpenAI", report_a), ("Anthropic", report_b)]
141 )
142 self.assertIn("[OpenAI] Brave quota exhausted", rendered)
143 self.assertIn("[Anthropic] Exa returned 0 results", rendered)
144
145 def test_raises_on_empty_input(self):
146 with self.assertRaises(ValueError):
147 render.render_comparison_multi([])
148
149 def test_context_emit_carries_warnings(self):
150 # render_context shows warnings for a single entity; comparison
151 # context mode dropped them, so --emit=context was the one supported
152 # output where a dropped entity or failed source was invisible.
153 report_a = _build_report("OpenAI", ["GPT-5 drop"])
154 report_b = _build_report("Anthropic", ["Claude 4.7"])
155 report_a.warnings.append(
156 "Comparison is incomplete: 1 of 3 entities failed and were dropped (xAI)."
157 )
158 report_b.warnings.append("Exa returned 0 results")
159 out = render.render_comparison_multi_context(
160 [("OpenAI", report_a), ("Anthropic", report_b)]
161 )
162 self.assertIn("Warnings:", out)
163 self.assertIn("[OpenAI] Comparison is incomplete", out)
164 self.assertIn("[Anthropic] Exa returned 0 results", out)
165 # Above the per-entity sections so it survives tail truncation.
166 self.assertLess(out.index("Warnings:"), out.index("## OpenAI"))
167
168 def test_context_emit_omits_empty_warnings_block(self):
169 out = render.render_comparison_multi_context(
170 [("OpenAI", _build_report("OpenAI", ["GPT-5 drop"]))]
171 )
172 self.assertNotIn("Warnings:", out)
173
174 def test_context_emit(self):
175 reports = [
176 ("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
177 ("Anthropic", _build_report("Anthropic", ["Claude 4.7"])),
178 ]
179 out = render.render_comparison_multi_context(reports)
180 self.assertIn("Comparison: OpenAI vs Anthropic", out)
181 self.assertIn("## OpenAI", out)
182 self.assertIn("## Anthropic", out)
183 self.assertIn("GPT-5 drop", out)
184
185
186 class ResolvedEntitiesBlockTests(unittest.TestCase):
187 def _build_with_resolved(self, label, topic, resolved):
188 r = _build_report(topic, ["Cluster A"])
189 if resolved is not None:
190 r.artifacts["resolved"] = resolved
191 return (label, r)
192
193 def test_block_emitted_when_any_entity_has_resolved(self):
194 reports = [
195 self._build_with_resolved("OpenAI", "OpenAI", {
196 "entity": "OpenAI",
197 "x_handle": "OpenAI",
198 "subreddits": ["OpenAI", "MachineLearning"],
199 "github_user": "openai",
200 "github_repos": ["openai/gpt"],
201 "context": "GPT-5 release signals are strong",
202 }),
203 self._build_with_resolved("Anthropic", "Anthropic", {
204 "entity": "Anthropic",
205 "x_handle": "AnthropicAI",
206 "subreddits": ["ClaudeAI"],
207 "github_user": "anthropics",
208 "github_repos": [],
209 "context": "",
210 }),
211 ]
212 rendered = render.render_comparison_multi(reports)
213 self.assertIn("## Resolved Entities", rendered)
214 self.assertIn("**OpenAI**: X @OpenAI", rendered)
215 self.assertIn("r/OpenAI, r/MachineLearning", rendered)
216 self.assertIn("@openai (openai/gpt)", rendered)
217 self.assertIn("**Anthropic**: X @AnthropicAI", rendered)
218 # Missing context renders as "-"
219 self.assertIn("Context: -", rendered)
220
221 def test_block_omitted_when_no_resolved_artifacts(self):
222 reports = [
223 self._build_with_resolved("A", "A", None),
224 self._build_with_resolved("B", "B", None),
225 ]
226 rendered = render.render_comparison_multi(reports)
227 self.assertNotIn("## Resolved Entities", rendered)
228
229 def test_missing_fields_render_as_dash(self):
230 reports = [
231 self._build_with_resolved("OpenAI", "OpenAI", {
232 "entity": "OpenAI",
233 "x_handle": "",
234 "subreddits": [],
235 "github_user": "",
236 "github_repos": [],
237 "context": "",
238 }),
239 ]
240 rendered = render.render_comparison_multi(reports)
241 self.assertIn("**OpenAI**: X - | Subs - | GitHub - | Context: -", rendered)
242
243 def test_long_context_truncated(self):
244 long = "a" * 200
245 reports = [
246 self._build_with_resolved("X", "X", {
247 "entity": "X",
248 "x_handle": "",
249 "subreddits": [],
250 "github_user": "",
251 "github_repos": [],
252 "context": long,
253 }),
254 ]
255 rendered = render.render_comparison_multi(reports)
256 # The truncate helper adds an ellipsis; context line should not show
257 # the full 200-char string.
258 self.assertNotIn("a" * 200, rendered)
259
260 def test_context_emit_includes_resolved_block(self):
261 reports = [
262 self._build_with_resolved("OpenAI", "OpenAI", {
263 "entity": "OpenAI",
264 "x_handle": "OpenAI",
265 "subreddits": ["OpenAI"],
266 "github_user": "",
267 "github_repos": [],
268 "context": "",
269 }),
270 ]
271 out = render.render_comparison_multi_context(reports)
272 self.assertIn("## Resolved Entities", out)
273 self.assertIn("**OpenAI**: X @OpenAI", out)
274
275 def test_subreddit_overflow_truncated(self):
276 reports = [
277 self._build_with_resolved("X", "X", {
278 "entity": "X",
279 "x_handle": "",
280 "subreddits": ["a", "b", "c", "d", "e", "f", "g"],
281 "github_user": "",
282 "github_repos": [],
283 "context": "",
284 }),
285 ]
286 rendered = render.render_comparison_multi(reports)
287 self.assertIn("r/a, r/b, r/c, r/d, r/e (+2)", rendered)
288
289
290 class EmitComparisonOutputTests(unittest.TestCase):
291 def test_json_emit_nests_per_entity(self):
292 reports = [
293 ("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
294 ("Anthropic", _build_report("Anthropic", ["Claude 4.7"])),
295 ]
296 out = cli.emit_comparison_output(reports, emit="json")
297 payload = json.loads(out)
298 self.assertTrue(payload["comparison"])
299 self.assertEqual(payload["entities"], ["OpenAI", "Anthropic"])
300 self.assertEqual(len(payload["reports"]), 2)
301 self.assertEqual(payload["reports"][0]["entity"], "OpenAI")
302 self.assertEqual(payload["schema_version"], "1.3")
303 self.assertIn("query", payload["reports"][0]["report"])
304
305 def test_compact_and_md_both_route_to_multi(self):
306 reports = [
307 ("A", _build_report("A", ["Thing A"])),
308 ("B", _build_report("B", ["Thing B"])),
309 ]
310 compact = cli.emit_comparison_output(reports, emit="compact")
311 md = cli.emit_comparison_output(reports, emit="md")
312 self.assertIn("| Dimension | A | B |", compact)
313 self.assertEqual(compact, md)
314
315 def test_context_emit_goes_to_context_renderer(self):
316 reports = [
317 ("A", _build_report("A", ["Thing A"])),
318 ("B", _build_report("B", ["Thing B"])),
319 ]
320 out = cli.emit_comparison_output(reports, emit="context")
321 self.assertIn("Comparison: A vs B", out)
322
323 def test_unsupported_emit_raises(self):
324 reports = [("A", _build_report("A", ["Thing A"]))]
325 with self.assertRaises(SystemExit):
326 cli.emit_comparison_output(reports, emit="xml")
327
328 if __name__ == "__main__":
329 unittest.main()
330
330 lines PYTHON