返回 last30days-skill
test_prescriptions.py
根目录 / tests / test_prescriptions.py
1 """Tests for the fix-prescription registry (doctor plan U3, KTD 7).
2
3 One registry maps (source, failure mode) to its remediation in BOTH
4 natural-language and direct-CLI forms. Consumers: the doctor command (U4)
5 and lib/quality_nudge.py, which builds its fix text from the same entries
6 so the two surfaces cannot drift.
7 """
8
9 import re
10 from pathlib import Path
11 from unittest.mock import patch
12
13 import pytest
14
15 from lib import health, prescriptions
16
17
18 REPO_ROOT = Path(__file__).resolve().parent.parent
19 CONFIGURATION_MD = REPO_ROOT / "CONFIGURATION.md"
20
21 # A CLI fix must start with a runnable token: the engine invocation, an env
22 # assignment (optionally exported), or a documented binary name.
23 RUNNABLE = re.compile(
24 r"^(?:python3 \S*last30days\.py\b"
25 r"|[A-Z][A-Z0-9_]*="
26 r"|export [A-Z][A-Z0-9_]*="
27 r"|(?:brew|pipx|pip|scoop|npx|npm|xurl|yt-dlp|docker) )"
28 )
29
30 # The seed failure inventory from the plan (U3 approach section).
31 SEED_INVENTORY = {
32 ("x", "cookies_missing"),
33 ("x", "cookies_expired"),
34 ("scrapecreators", "key_missing"),
35 ("bluesky", "app_password_missing"),
36 ("youtube", "transcription_key_missing"),
37 ("digg", "pp_cli_missing"),
38 ("digg", "pp_cli_off_path"),
39 ("digg", "pp_cli_broken"),
40 ("youtube", "ytdlp_missing"),
41 ("youtube", "ytdlp_stale"),
42 ("youtube", "ytdlp_broken"),
43 ("truthsocial", "token_missing"),
44 ("xiaohongshu", "service_unreachable"),
45 }
46
47
48 def _configuration_md_slugs():
49 """GitHub-style anchor slugs for every CONFIGURATION.md heading."""
50 slugs = set()
51 for line in CONFIGURATION_MD.read_text(encoding="utf-8").splitlines():
52 m = re.match(r"#{1,6}\s+(.*)", line)
53 if not m:
54 continue
55 text = re.sub(r"[^\w\s-]", "", m.group(1).lower()).strip()
56 slugs.add(text.replace(" ", "-"))
57 return slugs
58
59
60 # ---------------------------------------------------------------------------
61 # Scenario 1: completeness lint over every registered entry
62 # ---------------------------------------------------------------------------
63
64 class TestCompletenessLint:
65 def test_seed_inventory_is_registered(self):
66 assert SEED_INVENTORY <= set(prescriptions.REGISTRY)
67
68 def test_every_entry_has_cause_and_both_fix_forms(self):
69 for key, entry in prescriptions.REGISTRY.items():
70 assert entry.cause.strip(), f"{key}: empty cause"
71 assert entry.fix_nl.strip(), f"{key}: empty natural-language fix"
72 assert entry.fix_cli.strip(), f"{key}: empty CLI fix"
73
74 def test_every_cli_form_starts_with_a_runnable_token(self):
75 for key, entry in prescriptions.REGISTRY.items():
76 for cli in (entry.fix_cli, *entry.alt_cli):
77 assert RUNNABLE.match(cli), f"{key}: not runnable: {cli!r}"
78
79 def test_nl_fix_never_duplicates_the_cli_string_verbatim(self):
80 for key, entry in prescriptions.REGISTRY.items():
81 assert entry.fix_nl.strip() != entry.fix_cli.strip(), key
82
83 def test_registry_keys_match_entry_fields(self):
84 for (source, failure), entry in prescriptions.REGISTRY.items():
85 assert entry.source == source
86 assert entry.failure == failure
87
88 def test_anchors_point_at_real_configuration_md_headings(self):
89 slugs = _configuration_md_slugs()
90 for key, entry in prescriptions.REGISTRY.items():
91 if entry.anchor:
92 assert entry.anchor in slugs, (
93 f"{key}: anchor #{entry.anchor} not a CONFIGURATION.md heading"
94 )
95
96 def test_no_secret_looking_values(self):
97 """Placeholders only - no copy-pasteable live credentials."""
98 secretish = re.compile(r"(sk-[A-Za-z0-9]{16,}|gsk_[A-Za-z0-9]{16,}|xox[bap]-)")
99 for key, entry in prescriptions.REGISTRY.items():
100 blob = " ".join((entry.cause, entry.fix_nl, entry.fix_cli, *entry.alt_cli))
101 assert not secretish.search(blob), key
102
103
104 # ---------------------------------------------------------------------------
105 # Documented CLI forms for the flagship entries
106 # ---------------------------------------------------------------------------
107
108 class TestDocumentedCliForms:
109 def test_x_cookie_fixes_use_setup_with_browser_cookie_consent(self):
110 expected = "python3 skills/last30days/scripts/last30days.py setup --allow-browser-cookies"
111 assert prescriptions.get("x", "cookies_missing").fix_cli == expected
112 assert prescriptions.get("x", "cookies_expired").fix_cli == expected
113
114 def test_scrapecreators_fix_is_the_github_device_flow(self):
115 entry = prescriptions.get("scrapecreators", "key_missing")
116 assert entry.fix_cli == "python3 skills/last30days/scripts/last30days.py setup --github"
117
118 def test_ytdlp_install_and_reinstall_reference_u1_health_strings(self):
119 """Binary-class fixes reference U1's tables instead of restating them."""
120 install, reinstall = health._MANAGER_PRESCRIPTIONS["yt-dlp"]["brew"]
121 assert prescriptions.get("youtube", "ytdlp_missing").fix_cli == install
122 assert prescriptions.get("youtube", "ytdlp_broken").fix_cli == reinstall
123
124 def test_digg_install_references_u1_printing_press_command(self):
125 entry = prescriptions.get("digg", "pp_cli_missing")
126 assert entry.fix_cli == health._pp_install_cmd("digg-pp-cli")
127
128
129 # ---------------------------------------------------------------------------
130 # Scenario 2: quality_nudge text derives from the same registry entries
131 # ---------------------------------------------------------------------------
132
133 def _nudge(config_overrides=None, result_overrides=None, ytdlp_installed=False):
134 from lib.quality_nudge import compute_quality_score
135 from lib import youtube_yt
136
137 config = {
138 "AUTH_TOKEN": None,
139 "CT0": None,
140 "XAI_API_KEY": None,
141 "XQUIK_API_KEY": None,
142 "SCRAPECREATORS_API_KEY": None,
143 }
144 config.update(config_overrides or {})
145 results = {"x_error": None, "youtube_error": None, "reddit_error": None}
146 results.update(result_overrides or {})
147 with patch.object(youtube_yt, "is_ytdlp_installed", return_value=ytdlp_installed):
148 return compute_quality_score(config, results)
149
150
151 class TestSharedWithQualityNudge:
152 def test_x_cookie_expired_nudge_is_built_from_the_registry_entry(self):
153 entry = prescriptions.get("x", "cookies_expired")
154 q = _nudge(
155 config_overrides={"AUTH_TOKEN": "tok123"},
156 result_overrides={"x_error": "401 unauthorized"},
157 ytdlp_installed=True,
158 )
159 assert q["nudge_text"] is not None
160 assert entry.fix_nl in q["nudge_text"]
161
162 def test_x_cookie_missing_nudge_is_built_from_the_registry_entry(self):
163 entry = prescriptions.get("x", "cookies_missing")
164 q = _nudge(ytdlp_installed=True)
165 assert entry.fix_nl in q["nudge_text"]
166
167 def test_ytdlp_missing_nudge_uses_registry_cli(self):
168 entry = prescriptions.get("youtube", "ytdlp_missing")
169 q = _nudge(config_overrides={"AUTH_TOKEN": "tok123"}, ytdlp_installed=False)
170 assert entry.fix_cli in q["nudge_text"]
171
172 def test_ytdlp_stale_degraded_nudge_uses_registry_cli_forms(self):
173 entry = prescriptions.get("youtube", "ytdlp_stale")
174 q = _nudge(
175 config_overrides={"AUTH_TOKEN": "tok123"},
176 ytdlp_installed=True,
177 result_overrides={
178 "youtube_videos_count": 6,
179 "youtube_transcripts_count": 0,
180 },
181 )
182 assert entry.fix_cli in q["nudge_text"]
183 for alt in entry.alt_cli:
184 assert alt in q["nudge_text"]
185
186 def test_quality_nudge_source_no_longer_hardcodes_fix_strings(self):
187 """The migrated fix strings must live in the registry only.
188
189 Trigger logic legitimately still reads credential names (e.g.
190 ``config.get("XAI_API_KEY")``); this guards the FIX text.
191 """
192 source = (
193 REPO_ROOT / "skills/last30days/scripts/lib/quality_nudge.py"
194 ).read_text(encoding="utf-8")
195 assert "brew " not in source
196 assert "api.x.ai" not in source
197 assert "log into x.com" not in source
198 assert "yt-dlp: brew" not in source
199
200
201 # ---------------------------------------------------------------------------
202 # Scenario 3: unregistered failure -> generic fallback, no crash
203 # ---------------------------------------------------------------------------
204
205 class TestFallback:
206 def test_lookup_returns_none_for_unregistered(self):
207 assert prescriptions.lookup("linkedin", "flux_capacitor_missing") is None
208
209 def test_get_returns_generic_configuration_md_pointer(self):
210 entry = prescriptions.get("linkedin", "flux_capacitor_missing")
211 assert "CONFIGURATION.md" in entry.fix_nl
212 assert RUNNABLE.match(entry.fix_cli)
213 assert entry.source == "linkedin"
214 assert entry.failure == "flux_capacitor_missing"
215
216 def test_get_returns_registered_entry_when_present(self):
217 assert prescriptions.get("x", "cookies_missing") is prescriptions.REGISTRY[
218 ("x", "cookies_missing")
219 ]
220
221
222 # ---------------------------------------------------------------------------
223 # Composition with U1 dependency probes (health.DependencyProbe)
224 # ---------------------------------------------------------------------------
225
226 class TestDependencyProbeComposition:
227 def test_ok_probe_needs_no_prescription(self):
228 probe = health.DependencyProbe(name="yt-dlp", status=health.OK, detail="2026.06.01")
229 assert prescriptions.for_dependency_probe(probe) is None
230
231 def test_probe_prescription_wins_the_cli_form(self):
232 """U1's machine-aware string (pipx owner here) overrides the static CLI."""
233 probe = health.DependencyProbe(
234 name="yt-dlp",
235 status=health.BROKEN,
236 detail="yt-dlp resolves to /x/yt-dlp but won't execute: stale shim",
237 prescription="pipx reinstall yt-dlp",
238 )
239 entry = prescriptions.for_dependency_probe(probe)
240 assert entry is not None
241 assert entry.fix_cli == "pipx reinstall yt-dlp"
242 # Registry vocabulary (NL form) is retained.
243 assert entry.fix_nl == prescriptions.get("youtube", "ytdlp_broken").fix_nl
244
245 def test_digg_off_path_probe_maps_to_the_path_entry(self):
246 probe = health.DependencyProbe(
247 name="digg-pp-cli",
248 status=health.MISSING,
249 detail=(
250 "digg-pp-cli is installed at /home/u/.local/bin/digg-pp-cli but "
251 "that directory is not on this process's PATH"
252 ),
253 prescription='add $HOME/.local/bin to PATH (e.g. export PATH="$HOME/.local/bin:$PATH") so digg-pp-cli resolves',
254 off_path=True,
255 )
256 entry = prescriptions.for_dependency_probe(probe)
257 assert entry is not None
258 assert entry.failure == "pp_cli_off_path"
259
260 def test_digg_broken_probe_maps_to_the_reinstall_entry(self):
261 """An installed-but-broken digg binary must get reinstall-framed
262 text, never the never-installed "install it" entry (F6); the
263 probe's own prescription wins the CLI form, mirroring
264 test_probe_prescription_wins_the_cli_form."""
265 reinstall = f"re-run the Printing Press install: {health.pp_install_cmd('digg')}"
266 probe = health.DependencyProbe(
267 name="digg-pp-cli",
268 status=health.BROKEN,
269 detail=(
270 "digg-pp-cli resolves to /home/u/.local/bin/digg-pp-cli "
271 "but won't execute"
272 ),
273 prescription=reinstall,
274 )
275 entry = prescriptions.for_dependency_probe(probe)
276 assert entry is not None
277 assert entry.failure == "pp_cli_broken"
278 assert entry.fix_cli == reinstall
279 # Registry vocabulary (NL form) is retained and reinstall-framed.
280 assert entry.fix_nl == prescriptions.get("digg", "pp_cli_broken").fix_nl
281 assert "reinstall" in entry.fix_nl
282 assert entry.fix_nl != prescriptions.get("digg", "pp_cli_missing").fix_nl
283
284 def test_digg_timeout_probe_also_maps_to_the_reinstall_entry(self):
285 probe = health.DependencyProbe(
286 name="digg-pp-cli",
287 status=health.TIMEOUT,
288 detail="digg-pp-cli --version timed out",
289 prescription="reinstall digg-pp-cli",
290 )
291 entry = prescriptions.for_dependency_probe(probe)
292 assert entry is not None
293 assert entry.failure == "pp_cli_broken"
294
295 def test_unregistered_dependency_wraps_the_probe(self):
296 probe = health.DependencyProbe(
297 name="ffmpeg",
298 status=health.MISSING,
299 detail="ffmpeg not found on PATH",
300 prescription="brew install ffmpeg",
301 )
302 entry = prescriptions.for_dependency_probe(probe)
303 assert entry is not None
304 assert entry.fix_cli == "brew install ffmpeg"
305 assert entry.fix_nl # still has a natural-language form
306
307
308 # ---------------------------------------------------------------------------
309 # Composition with U2 backend findings (lib/backends.py)
310 # ---------------------------------------------------------------------------
311
312 class TestBackendComposition:
313 def test_bird_cookie_prescription_embeds_the_registry_cli(self):
314 from lib import backends, bird_x
315
316 entry = prescriptions.get("x", "cookies_missing")
317 ok_node = health.DependencyProbe(name="node", status=health.OK, detail="v22.0.0")
318 with patch.object(bird_x, "is_bird_installed", return_value=True), \
319 patch.object(health, "probe_dependency", return_value=ok_node):
320 finding = backends._X_PROBES["bird"]({})
321 assert finding.status == health.MISSING
322 assert entry.fix_cli in finding.prescription
323
324 def test_scrapecreators_prescription_embeds_the_registry_cli(self):
325 from lib import backends
326
327 entry = prescriptions.get("scrapecreators", "key_missing")
328 finding = backends._SC_SPEC.probe({})
329 assert finding.status == health.MISSING
330 assert entry.fix_cli in finding.prescription
331 # test_backend_descriptors requires the key name to stay present.
332 assert "SCRAPECREATORS_API_KEY" in finding.prescription
333
334
335 class TestAltCliArityPin:
336 """Greptile PR review: quality_nudge composes YouTube nudges from the two
337 platform alternates on the ytdlp entries. The consumer is now tolerant of
338 any arity (degrades wording instead of crashing), and this pin keeps the
339 wording rich: both entries must keep at least the scoop + pip alternates."""
340
341 @pytest.mark.parametrize("failure", ["ytdlp_missing", "ytdlp_stale"])
342 def test_ytdlp_entries_keep_two_platform_alternates(self, failure):
343 entry = prescriptions.get("youtube", failure)
344 assert len(entry.alt_cli) >= 2, (
345 f"youtube/{failure} lost a platform alternate; quality_nudge "
346 "wording degrades (tolerant, but fix the entry or the prose)"
347 )
348
348 lines PYTHON