| 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(self): |
| 150 | reports = [ |
| 151 | ("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])), |
| 152 | ("Anthropic", _build_report("Anthropic", ["Claude 4.7"])), |
| 153 | ] |
| 154 | out = render.render_comparison_multi_context(reports) |
| 155 | self.assertIn("Comparison: OpenAI vs Anthropic", out) |
| 156 | self.assertIn("## OpenAI", out) |
| 157 | self.assertIn("## Anthropic", out) |
| 158 | self.assertIn("GPT-5 drop", out) |
| 159 | |
| 160 | |
| 161 | class ResolvedEntitiesBlockTests(unittest.TestCase): |
| 162 | def _build_with_resolved(self, label, topic, resolved): |
| 163 | r = _build_report(topic, ["Cluster A"]) |
| 164 | if resolved is not None: |
| 165 | r.artifacts["resolved"] = resolved |
| 166 | return (label, r) |
| 167 | |
| 168 | def test_block_emitted_when_any_entity_has_resolved(self): |
| 169 | reports = [ |
| 170 | self._build_with_resolved("OpenAI", "OpenAI", { |
| 171 | "entity": "OpenAI", |
| 172 | "x_handle": "OpenAI", |
| 173 | "subreddits": ["OpenAI", "MachineLearning"], |
| 174 | "github_user": "openai", |
| 175 | "github_repos": ["openai/gpt"], |
| 176 | "context": "GPT-5 release signals are strong", |
| 177 | }), |
| 178 | self._build_with_resolved("Anthropic", "Anthropic", { |
| 179 | "entity": "Anthropic", |
| 180 | "x_handle": "AnthropicAI", |
| 181 | "subreddits": ["ClaudeAI"], |
| 182 | "github_user": "anthropics", |
| 183 | "github_repos": [], |
| 184 | "context": "", |
| 185 | }), |
| 186 | ] |
| 187 | rendered = render.render_comparison_multi(reports) |
| 188 | self.assertIn("## Resolved Entities", rendered) |
| 189 | self.assertIn("**OpenAI**: X @OpenAI", rendered) |
| 190 | self.assertIn("r/OpenAI, r/MachineLearning", rendered) |
| 191 | self.assertIn("@openai (openai/gpt)", rendered) |
| 192 | self.assertIn("**Anthropic**: X @AnthropicAI", rendered) |
| 193 | # Missing context renders as "-" |
| 194 | self.assertIn("Context: -", rendered) |
| 195 | |
| 196 | def test_block_omitted_when_no_resolved_artifacts(self): |
| 197 | reports = [ |
| 198 | self._build_with_resolved("A", "A", None), |
| 199 | self._build_with_resolved("B", "B", None), |
| 200 | ] |
| 201 | rendered = render.render_comparison_multi(reports) |
| 202 | self.assertNotIn("## Resolved Entities", rendered) |
| 203 | |
| 204 | def test_missing_fields_render_as_dash(self): |
| 205 | reports = [ |
| 206 | self._build_with_resolved("OpenAI", "OpenAI", { |
| 207 | "entity": "OpenAI", |
| 208 | "x_handle": "", |
| 209 | "subreddits": [], |
| 210 | "github_user": "", |
| 211 | "github_repos": [], |
| 212 | "context": "", |
| 213 | }), |
| 214 | ] |
| 215 | rendered = render.render_comparison_multi(reports) |
| 216 | self.assertIn("**OpenAI**: X - | Subs - | GitHub - | Context: -", rendered) |
| 217 | |
| 218 | def test_long_context_truncated(self): |
| 219 | long = "a" * 200 |
| 220 | reports = [ |
| 221 | self._build_with_resolved("X", "X", { |
| 222 | "entity": "X", |
| 223 | "x_handle": "", |
| 224 | "subreddits": [], |
| 225 | "github_user": "", |
| 226 | "github_repos": [], |
| 227 | "context": long, |
| 228 | }), |
| 229 | ] |
| 230 | rendered = render.render_comparison_multi(reports) |
| 231 | # The truncate helper adds an ellipsis; context line should not show |
| 232 | # the full 200-char string. |
| 233 | self.assertNotIn("a" * 200, rendered) |
| 234 | |
| 235 | def test_context_emit_includes_resolved_block(self): |
| 236 | reports = [ |
| 237 | self._build_with_resolved("OpenAI", "OpenAI", { |
| 238 | "entity": "OpenAI", |
| 239 | "x_handle": "OpenAI", |
| 240 | "subreddits": ["OpenAI"], |
| 241 | "github_user": "", |
| 242 | "github_repos": [], |
| 243 | "context": "", |
| 244 | }), |
| 245 | ] |
| 246 | out = render.render_comparison_multi_context(reports) |
| 247 | self.assertIn("## Resolved Entities", out) |
| 248 | self.assertIn("**OpenAI**: X @OpenAI", out) |
| 249 | |
| 250 | def test_subreddit_overflow_truncated(self): |
| 251 | reports = [ |
| 252 | self._build_with_resolved("X", "X", { |
| 253 | "entity": "X", |
| 254 | "x_handle": "", |
| 255 | "subreddits": ["a", "b", "c", "d", "e", "f", "g"], |
| 256 | "github_user": "", |
| 257 | "github_repos": [], |
| 258 | "context": "", |
| 259 | }), |
| 260 | ] |
| 261 | rendered = render.render_comparison_multi(reports) |
| 262 | self.assertIn("r/a, r/b, r/c, r/d, r/e (+2)", rendered) |
| 263 | |
| 264 | |
| 265 | class EmitComparisonOutputTests(unittest.TestCase): |
| 266 | def test_json_emit_nests_per_entity(self): |
| 267 | reports = [ |
| 268 | ("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])), |
| 269 | ("Anthropic", _build_report("Anthropic", ["Claude 4.7"])), |
| 270 | ] |
| 271 | out = cli.emit_comparison_output(reports, emit="json") |
| 272 | payload = json.loads(out) |
| 273 | self.assertTrue(payload["comparison"]) |
| 274 | self.assertEqual(payload["entities"], ["OpenAI", "Anthropic"]) |
| 275 | self.assertEqual(len(payload["reports"]), 2) |
| 276 | self.assertEqual(payload["reports"][0]["entity"], "OpenAI") |
| 277 | self.assertEqual(payload["schema_version"], "1.2") |
| 278 | self.assertIn("query", payload["reports"][0]["report"]) |
| 279 | |
| 280 | def test_compact_and_md_both_route_to_multi(self): |
| 281 | reports = [ |
| 282 | ("A", _build_report("A", ["Thing A"])), |
| 283 | ("B", _build_report("B", ["Thing B"])), |
| 284 | ] |
| 285 | compact = cli.emit_comparison_output(reports, emit="compact") |
| 286 | md = cli.emit_comparison_output(reports, emit="md") |
| 287 | self.assertIn("| Dimension | A | B |", compact) |
| 288 | self.assertEqual(compact, md) |
| 289 | |
| 290 | def test_context_emit_goes_to_context_renderer(self): |
| 291 | reports = [ |
| 292 | ("A", _build_report("A", ["Thing A"])), |
| 293 | ("B", _build_report("B", ["Thing B"])), |
| 294 | ] |
| 295 | out = cli.emit_comparison_output(reports, emit="context") |
| 296 | self.assertIn("Comparison: A vs B", out) |
| 297 | |
| 298 | def test_unsupported_emit_raises(self): |
| 299 | reports = [("A", _build_report("A", ["Thing A"]))] |
| 300 | with self.assertRaises(SystemExit): |
| 301 | cli.emit_comparison_output(reports, emit="xml") |
| 302 | |
| 303 | if __name__ == "__main__": |
| 304 | unittest.main() |
| 305 |