返回 last30days-skill
test_render_footer.py
根目录 / tests / test_render_footer.py
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
186 lines PYTHON