| 1 | """Footer rendering: non-populated sources are dropped from the emoji tree. |
| 2 | |
| 3 | Covers U1 of the doctor-classification-and-footer-noresults plan. A source that |
| 4 | returned zero items - whether it completed cleanly (NO_RESULTS) or failed |
| 5 | (RATE_LIMITED / UNREACHABLE) - must not appear as its own emoji-tree line. The |
| 6 | failure signal stays visible in the evidence blocks (## Partial Coverage / |
| 7 | ## Source Coverage) so synthesis still sees it (R7), just not in the user-facing |
| 8 | footer. |
| 9 | """ |
| 10 | |
| 11 | from lib import health, render, schema |
| 12 | |
| 13 | |
| 14 | def _report(*, items_by_source=None, source_status=None, errors_by_source=None): |
| 15 | return schema.Report( |
| 16 | topic="test topic", |
| 17 | range_from="2026-06-10", |
| 18 | range_to="2026-07-10", |
| 19 | generated_at="2026-07-10T18:22:03Z", |
| 20 | provider_runtime=schema.ProviderRuntime( |
| 21 | reasoning_provider="gemini", |
| 22 | planner_model="test-planner", |
| 23 | rerank_model="test-reranker", |
| 24 | ), |
| 25 | query_plan=schema.QueryPlan( |
| 26 | intent="general", |
| 27 | freshness_mode="balanced_recent", |
| 28 | cluster_mode="story", |
| 29 | raw_topic="test topic", |
| 30 | subqueries=[ |
| 31 | schema.SubQuery( |
| 32 | label="primary", |
| 33 | search_query="test topic", |
| 34 | ranking_query="test topic", |
| 35 | sources=["reddit"], |
| 36 | ) |
| 37 | ], |
| 38 | source_weights={"reddit": 1.0}, |
| 39 | ), |
| 40 | clusters=[], |
| 41 | ranked_candidates=[], |
| 42 | items_by_source=items_by_source or {}, |
| 43 | errors_by_source=errors_by_source or {}, |
| 44 | source_status=source_status or {}, |
| 45 | ) |
| 46 | |
| 47 | |
| 48 | def _reddit_item(): |
| 49 | return schema.SourceItem( |
| 50 | item_id="r1", |
| 51 | source="reddit", |
| 52 | title="A thread", |
| 53 | body="body", |
| 54 | url="https://reddit.com/r/test/comments/1", |
| 55 | ) |
| 56 | |
| 57 | |
| 58 | def test_footer_omits_clean_no_results_sources(): |
| 59 | report = _report( |
| 60 | items_by_source={"reddit": [_reddit_item()]}, |
| 61 | source_status={ |
| 62 | "reddit": schema.SourceOutcome(source="reddit", state=health.OK, items_returned=1), |
| 63 | "jobs": schema.SourceOutcome(source="jobs", state=schema.NO_RESULTS), |
| 64 | "polymarket": schema.SourceOutcome(source="polymarket", state=schema.NO_RESULTS), |
| 65 | "youtube": schema.SourceOutcome(source="youtube", state=schema.NO_RESULTS), |
| 66 | }, |
| 67 | ) |
| 68 | |
| 69 | text = render.render_compact(report) |
| 70 | |
| 71 | # Populated source stays. |
| 72 | assert "🟠 Reddit: 1 thread" in text |
| 73 | # Clean zero-result sources do not get a footer line. |
| 74 | assert "Jobs: no results" not in text |
| 75 | assert "Polymarket: no results" not in text |
| 76 | assert "YouTube: no results" not in text |
| 77 | |
| 78 | |
| 79 | def test_footer_omits_errored_zero_item_source_but_keeps_evidence(): |
| 80 | report = _report( |
| 81 | items_by_source={"reddit": [_reddit_item()]}, |
| 82 | source_status={ |
| 83 | "reddit": schema.SourceOutcome(source="reddit", state=health.OK, items_returned=1), |
| 84 | "x": schema.SourceOutcome( |
| 85 | source="x", |
| 86 | state=schema.RATE_LIMITED, |
| 87 | detail="HTTP 429 after retry budget", |
| 88 | fix_hint="doctor", |
| 89 | ), |
| 90 | }, |
| 91 | errors_by_source={"x": "HTTP 429 after retry budget"}, |
| 92 | ) |
| 93 | |
| 94 | text = render.render_compact(report) |
| 95 | |
| 96 | # The failed zero-item source is dropped from the emoji-tree footer. |
| 97 | assert "🔵 X: rate-limited" not in text |
| 98 | # ... but its failure is still visible to synthesis in the evidence blocks (R7). |
| 99 | assert "## Partial Coverage" in text |
| 100 | assert "Do not interpret a failed source as no discussion" in text |
| 101 | |
| 102 | |
| 103 | def test_footer_preserves_save_path_when_all_sources_empty(): |
| 104 | # Every source returned zero items -> no source lines, but the durable |
| 105 | # raw-file citation must still render (regression guard for the U1 loop |
| 106 | # removal, which previously suppressed the whole footer incl. save path). |
| 107 | report = _report( |
| 108 | source_status={ |
| 109 | "jobs": schema.SourceOutcome(source="jobs", state=schema.NO_RESULTS), |
| 110 | "x": schema.SourceOutcome( |
| 111 | source="x", state=schema.RATE_LIMITED, detail="429", fix_hint="doctor" |
| 112 | ), |
| 113 | }, |
| 114 | ) |
| 115 | footer = render._render_emoji_footer(report, "/tmp/l30d-scratch/topic-raw.md") |
| 116 | |
| 117 | text = "\n".join(footer) |
| 118 | assert "✅ All agents reported back!" in text |
| 119 | assert "Raw results saved to /tmp/l30d-scratch/topic-raw.md" in text |
| 120 | # No per-source line for the zero-item sources. |
| 121 | assert "Jobs" not in text |
| 122 | assert "rate-limited" not in text |
| 123 | |
| 124 | |
| 125 | def test_footer_empty_with_no_save_path_returns_nothing(): |
| 126 | report = _report( |
| 127 | source_status={"jobs": schema.SourceOutcome(source="jobs", state=schema.NO_RESULTS)}, |
| 128 | ) |
| 129 | assert render._render_emoji_footer(report, None) == [] |
| 130 | |
| 131 | |
| 132 | def test_library_block_carries_explainer_when_populated(): |
| 133 | report = _report(items_by_source={"reddit": [_reddit_item()]}) |
| 134 | report.library_context = [ |
| 135 | schema.LibraryContext( |
| 136 | topic="test topic", |
| 137 | published_date="2026-07-01", |
| 138 | headline="a prior finding", |
| 139 | summary="a prior finding", |
| 140 | source_kind="brief", |
| 141 | ) |
| 142 | ] |
| 143 | |
| 144 | text = render.render_compact(report) |
| 145 | |
| 146 | assert "## From your library" in text |
| 147 | assert "Prior saved runs" in text |
| 148 | assert "LAST30DAYS_LIBRARY_CONTEXT=off" in text |
| 149 | |
| 150 | |
| 151 | def test_library_block_and_explainer_absent_when_empty(): |
| 152 | report = _report(items_by_source={"reddit": [_reddit_item()]}) |
| 153 | text = render.render_compact(report) |
| 154 | assert "## From your library" not in text |
| 155 | assert "Prior saved runs" not in text |
| 156 | |
| 157 | |
| 158 | def test_footer_keeps_partial_populated_source_without_warning_text(): |
| 159 | """A source that returned SOME items but then failed stays in the footer |
| 160 | as counts only: run diagnostics live in doctor --postmortem, the saved raw |
| 161 | file, and the model-facing ## Partial Coverage note, never on the |
| 162 | user-facing conclusion surface.""" |
| 163 | ig_item = schema.SourceItem( |
| 164 | item_id="ig1", |
| 165 | source="instagram", |
| 166 | title="A reel", |
| 167 | body="caption", |
| 168 | url="https://instagram.com/reel/1", |
| 169 | ) |
| 170 | report = _report( |
| 171 | items_by_source={"reddit": [_reddit_item()], "instagram": [ig_item]}, |
| 172 | source_status={ |
| 173 | "reddit": schema.SourceOutcome(source="reddit", state=health.OK, items_returned=1), |
| 174 | "instagram": schema.SourceOutcome( |
| 175 | source="instagram", |
| 176 | state=schema.PARTIAL, |
| 177 | items_returned=1, |
| 178 | detail="HTTP 400: Bad Request", |
| 179 | fix_hint="doctor", |
| 180 | ), |
| 181 | }, |
| 182 | ) |
| 183 | |
| 184 | text = render.render_compact(report) |
| 185 | |
| 186 | assert "📸 Instagram: 1 reel" in text |
| 187 | footer = text.split("✅ All agents reported back!", 1)[1] |
| 188 | assert "⚠" not in footer |
| 189 | assert "run doctor" not in footer |
| 190 | assert "## Partial Coverage" in text |
| 191 | assert "Instagram" in text.split("## Partial Coverage", 1)[1].split("\n\n", 1)[0] or "Instagram partial" in text |
| 192 | |
| 193 | |
| 194 | def test_footer_auth_failed_populated_source_has_no_warning_text(): |
| 195 | ig_item = schema.SourceItem( |
| 196 | item_id="ig1", |
| 197 | source="instagram", |
| 198 | title="A reel", |
| 199 | body="caption", |
| 200 | url="https://instagram.com/reel/1", |
| 201 | ) |
| 202 | report = _report( |
| 203 | items_by_source={"reddit": [_reddit_item()], "instagram": [ig_item]}, |
| 204 | source_status={ |
| 205 | "reddit": schema.SourceOutcome(source="reddit", state=health.OK, items_returned=1), |
| 206 | "instagram": schema.SourceOutcome( |
| 207 | source="instagram", |
| 208 | state=schema.AUTH_FAILED, |
| 209 | items_returned=1, |
| 210 | detail="HTTP 401", |
| 211 | fix_hint="doctor", |
| 212 | ), |
| 213 | }, |
| 214 | ) |
| 215 | |
| 216 | text = render.render_compact(report) |
| 217 | footer = text.split("✅ All agents reported back!", 1)[1] |
| 218 | |
| 219 | assert "📸 Instagram: 1 reel" in footer |
| 220 | assert "⚠" not in footer |
| 221 | assert "auth-failed" in text.split("## Partial Coverage", 1)[1] |
| 222 | |
| 223 | |
| 224 | def test_compact_drops_source_failure_warnings_and_source_errors_block(): |
| 225 | report = _report( |
| 226 | items_by_source={"reddit": [_reddit_item()]}, |
| 227 | source_status={ |
| 228 | "reddit": schema.SourceOutcome(source="reddit", state=health.OK, items_returned=1), |
| 229 | "jobs": schema.SourceOutcome(source="jobs", state=schema.UNREACHABLE, items_returned=0, detail="DNS"), |
| 230 | }, |
| 231 | ) |
| 232 | report.warnings = [ |
| 233 | "Some sources failed: jobs", |
| 234 | "Some sources returned partial results (degraded): reddit", |
| 235 | "Evidence is thin for this topic.", |
| 236 | ] |
| 237 | report.errors_by_source = {"jobs": "URL Error: nodename nor servname provided"} |
| 238 | |
| 239 | compact = render.render_compact(report) |
| 240 | assert "## Source Errors" not in compact |
| 241 | assert "Some sources failed" not in compact |
| 242 | assert "returned partial results" not in compact |
| 243 | assert "Evidence is thin for this topic." in compact |
| 244 | |
| 245 | full = render.render_full(report) |
| 246 | assert "## Source Errors" in full |
| 247 | |
| 248 | payload = schema.to_dict(report) |
| 249 | assert payload["warnings"] == report.warnings |
| 250 | assert payload["errors_by_source"] == {"jobs": "URL Error: nodename nor servname provided"} |
| 251 | assert payload["source_status"]["jobs"]["state"] == schema.UNREACHABLE |
| 252 | |
| 253 | |
| 254 | def test_footer_carries_freshness_verdict(): |
| 255 | """The freshness verdict reaches the footer, not just the report body.""" |
| 256 | stale = [ |
| 257 | schema.SourceItem( |
| 258 | item_id=f"r{i}", |
| 259 | source="reddit", |
| 260 | title=f"Old thread {i}", |
| 261 | body="body", |
| 262 | url=f"https://reddit.com/r/test/comments/{i}", |
| 263 | published_at="2026-06-12", |
| 264 | date_confidence="high", |
| 265 | ) |
| 266 | for i in range(6) |
| 267 | ] |
| 268 | report = _report( |
| 269 | items_by_source={"reddit": stale}, |
| 270 | source_status={ |
| 271 | "reddit": schema.SourceOutcome(source="reddit", state=health.OK, items_returned=6), |
| 272 | }, |
| 273 | ) |
| 274 | |
| 275 | text = render.render_compact(report) |
| 276 | |
| 277 | assert "🕒" in text |
| 278 | assert "from the last 7 days" in text |
| 279 | |
| 280 | |
| 281 | def test_footer_freshness_line_absent_when_evidence_is_recent(): |
| 282 | fresh = [ |
| 283 | schema.SourceItem( |
| 284 | item_id=f"r{i}", |
| 285 | source="reddit", |
| 286 | title=f"Fresh thread {i}", |
| 287 | body="body", |
| 288 | url=f"https://reddit.com/r/test/comments/{i}", |
| 289 | published_at="2026-07-08", |
| 290 | date_confidence="high", |
| 291 | ) |
| 292 | for i in range(6) |
| 293 | ] |
| 294 | report = _report( |
| 295 | items_by_source={"reddit": fresh}, |
| 296 | source_status={ |
| 297 | "reddit": schema.SourceOutcome(source="reddit", state=health.OK, items_returned=6), |
| 298 | }, |
| 299 | ) |
| 300 | |
| 301 | assert "🕒" not in render.render_compact(report) |
| 302 | |
| 303 | |
| 304 | def test_raw_results_only_footer_includes_freshness_line(): |
| 305 | """AE3: raw-results-only footer still includes the freshness line. |
| 306 | |
| 307 | When every source returns zero items (so source_lines would be empty and |
| 308 | body starts empty) but a save_path is provided, the freshness verdict |
| 309 | must still appear alongside the raw-results line. Without the fix, |
| 310 | `if freshness_line and body:` would be False because body was empty. |
| 311 | """ |
| 312 | report = _report( |
| 313 | items_by_source={}, |
| 314 | source_status={}, |
| 315 | ) |
| 316 | |
| 317 | footer = render._render_emoji_footer(report, "/tmp/l30d-scratch/topic-raw.md") |
| 318 | text = "\n".join(footer) |
| 319 | |
| 320 | assert "🕒" in text |
| 321 | assert "no usable dated evidence" in text |
| 322 | assert "Raw results saved to /tmp/l30d-scratch/topic-raw.md" in text |
| 323 |