| 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_with_warning(): |
| 159 | """A source that returned SOME items but then failed stays in the footer |
| 160 | with its ⚠ suffix - only zero-item sources are dropped.""" |
| 161 | ig_item = schema.SourceItem( |
| 162 | item_id="ig1", |
| 163 | source="instagram", |
| 164 | title="A reel", |
| 165 | body="caption", |
| 166 | url="https://instagram.com/reel/1", |
| 167 | ) |
| 168 | report = _report( |
| 169 | items_by_source={"reddit": [_reddit_item()], "instagram": [ig_item]}, |
| 170 | source_status={ |
| 171 | "reddit": schema.SourceOutcome(source="reddit", state=health.OK, items_returned=1), |
| 172 | "instagram": schema.SourceOutcome( |
| 173 | source="instagram", |
| 174 | state=schema.PARTIAL, |
| 175 | items_returned=1, |
| 176 | detail="HTTP 400: Bad Request", |
| 177 | fix_hint="doctor", |
| 178 | ), |
| 179 | }, |
| 180 | ) |
| 181 | |
| 182 | text = render.render_compact(report) |
| 183 | |
| 184 | assert "📸 Instagram: 1 reel" in text |
| 185 | assert "⚠" in text # partial suffix preserved on the populated line |
| 186 |