返回 last30days-skill
test_backend_descriptors.py
根目录 / tests / test_backend_descriptors.py
1 """U2: backend-chain descriptors with predicted selection (lib/backends.py).
2
3 Chained sources declare their routing once — imported from the definitions
4 lib/env.py already owns — and ``backends.resolve`` produces a truthful
5 "will use" prediction for alternative chains (X, YouTube, web search) plus
6 honest conditional wording for Reddit.
7
8 Covers the plan's U2 scenarios:
9 1. X with cookies present, bird healthy, no XAI key -> predicted ``bird``.
10 2. Pin var set to a later backend -> pin honored and marked pinned.
11 3. Preferred backend installed-but-unauthenticated does not shadow a
12 fully-usable fallback (collect-then-pick).
13 4. No backend usable -> tier error; prescription from the highest-priority
14 backend.
15 5. Paid lanes probe key presence ONLY — no network, no subprocess.
16 6. Reddit renders conditional wording (default + backfill), never a
17 computed winner; a scrapecreators pin renders as pinned.
18 7. Parity: descriptor prediction == pipeline's pre-failover X selection
19 (env.x_backend_chain()[0]) across three config permutations.
20 """
21
22 from unittest import mock
23
24 import pytest
25
26 from lib import backends, env, health, xurl_x
27
28
29 # ---------------------------------------------------------------------------
30 # Helpers
31 # ---------------------------------------------------------------------------
32
33 def _probe_dep(status_map=None, default_status=health.OK):
34 """Build a fake health.probe_dependency honoring a per-name status map."""
35 status_map = status_map or {}
36
37 def fake(name, timeout=health.PROBE_TIMEOUT):
38 status = status_map.get(name, default_status)
39 if status == health.OK:
40 return health.DependencyProbe(name=name, status=health.OK, detail=f"{name} 1.0.0")
41 return health.DependencyProbe(
42 name=name,
43 status=status,
44 detail=f"{name} probe simulated {status}",
45 prescription=f"reinstall {name}" if status != health.MISSING else f"install {name}",
46 owner_pkg_manager="brew",
47 )
48
49 return fake
50
51
52 def _x_env(
53 bird_installed=False,
54 xurl_installed=False,
55 xurl_authed=False,
56 node_status=health.OK,
57 ):
58 """Context managers configuring the X-chain probe environment.
59
60 ``xurl_authed`` drives BOTH auth surfaces consistently: the research-time
61 network check (``is_available``) and the doctor-path local evidence
62 (``stored_auth_status``/``has_stored_auth``) — a real machine where the
63 user logged in has both.
64 """
65 stored = (
66 (xurl_x.AUTH_OK, "stored OAuth credentials found in ~/.xurl")
67 if xurl_authed
68 else (xurl_x.AUTH_MISSING, "no token store at ~/.xurl")
69 )
70 return (
71 mock.patch("lib.bird_x.is_bird_installed", return_value=bird_installed),
72 mock.patch("lib.bird_x.set_credentials", lambda *a, **k: None),
73 mock.patch("lib.xurl_x.is_available", return_value=xurl_authed),
74 mock.patch(
75 "lib.backends.which",
76 lambda name: "/usr/local/bin/xurl" if (name == "xurl" and xurl_installed) else None,
77 ),
78 mock.patch("lib.health.probe_dependency", _probe_dep({"node": node_status})),
79 mock.patch("lib.xurl_x.stored_auth_status", return_value=stored),
80 mock.patch(
81 "lib.xurl_x.has_stored_auth",
82 return_value=xurl_installed and xurl_authed,
83 ),
84 )
85
86
87 def _resolve_x(config, **envkw):
88 with _stack(_x_env(**envkw)):
89 return backends.resolve("x", config)
90
91
92 class _stack:
93 """Enter/exit a tuple of context managers (contextlib.ExitStack, terse)."""
94
95 def __init__(self, ctxs):
96 self._ctxs = ctxs
97
98 def __enter__(self):
99 for c in self._ctxs:
100 c.__enter__()
101 return self
102
103 def __exit__(self, *exc):
104 for c in reversed(self._ctxs):
105 c.__exit__(*exc)
106 return False
107
108
109 # ---------------------------------------------------------------------------
110 # Descriptor registry: routing declared once, imported from env.py (KTD 6)
111 # ---------------------------------------------------------------------------
112
113 class TestDescriptorRegistry:
114 def test_x_chain_comes_from_env_definitions(self):
115 d = backends.get_descriptor("x")
116 assert d.mode == backends.MODE_ALTERNATIVE
117 assert tuple(s.name for s in d.backends) == env.X_BACKEND_ORDER
118 assert env.X_BACKEND_ORDER == ("xai", "bird", "xurl", "xquik")
119 assert d.pin_var == env.X_BACKEND_PIN_VAR == "LAST30DAYS_X_BACKEND"
120
121 def test_env_exposes_reddit_pin_constants(self):
122 assert env.REDDIT_BACKEND_PIN_VAR == "LAST30DAYS_REDDIT_BACKEND"
123 assert env.REDDIT_SC_MIN_ITEMS_VAR == "LAST30DAYS_REDDIT_SC_MIN_ITEMS"
124
125 def test_youtube_and_web_chains_declared_in_order(self):
126 yt = backends.get_descriptor("youtube")
127 assert tuple(s.name for s in yt.backends) == ("yt-dlp", "scrapecreators")
128 web = backends.get_descriptor("web")
129 assert tuple(s.name for s in web.backends) == (
130 "brave", "exa", "serper", "parallel", "keyless",
131 )
132 assert web.pin_flag == "--web-backend"
133
134 def test_reddit_is_conditional_and_lanes_are_not_chain_entries(self):
135 d = backends.get_descriptor("reddit")
136 assert d.mode == backends.MODE_CONDITIONAL
137 names = [s.name for s in d.backends]
138 # Internal keyless lanes are sub-probe detail, never chain entries.
139 for lane in ("rss", "listing", "arctic", "shreddit"):
140 assert lane not in names
141 assert names == ["public", "scrapecreators"]
142
143 def test_unknown_source_raises(self):
144 with pytest.raises(KeyError):
145 backends.get_descriptor("nope")
146 with pytest.raises(KeyError):
147 backends.resolve("nope", {})
148
149
150 # ---------------------------------------------------------------------------
151 # Scenario 1: cookies present, bird healthy, no XAI key -> bird predicted
152 # ---------------------------------------------------------------------------
153
154 class TestXPrediction:
155 def test_bird_predicted_with_cookies_and_no_xai_key(self):
156 config = {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"}
157 res = _resolve_x(config, bird_installed=True)
158 assert res.active_backend == "bird"
159 assert res.tier == backends.TIER_OK
160 assert res.pinned is False
161 # Chain rendered in declared order regardless of availability.
162 assert res.chain == list(env.X_BACKEND_ORDER)
163 assert [f.name for f in res.findings] == list(env.X_BACKEND_ORDER)
164 assert "will use: bird" in res.summary
165
166 # Scenario 2: pin var set to a later backend -> honored + marked pinned.
167 def test_pin_to_later_backend_honored_and_marked(self):
168 config = {
169 "AUTH_TOKEN": "dummy-token",
170 "CT0": "dummy-ct0",
171 "XQUIK_API_KEY": "dummy-key",
172 "LAST30DAYS_X_BACKEND": "xquik",
173 }
174 res = _resolve_x(config, bird_installed=True)
175 assert res.active_backend == "xquik"
176 assert res.pinned is True
177 assert res.pin == "xquik"
178 assert res.tier == backends.TIER_OK
179 assert "pinned" in res.summary
180
181 # Scenario 3: installed-but-unauthenticated preferred backend must not
182 # shadow a fully usable fallback (collect-then-pick).
183 def test_unauthenticated_preferred_does_not_shadow_usable_fallback(self):
184 config = {"XQUIK_API_KEY": "dummy-key"}
185 res = _resolve_x(config, xurl_installed=True, xurl_authed=False)
186 assert res.active_backend == "xquik"
187 assert res.tier == backends.TIER_OK
188 xurl = next(f for f in res.findings if f.name == "xurl")
189 assert not xurl.usable
190 assert "auth" in (xurl.detail + xurl.prescription).lower()
191
192 # Scenario 4: nothing usable -> error tier, highest-priority prescription.
193 def test_no_backend_usable_is_error_with_top_priority_prescription(self):
194 res = _resolve_x({})
195 assert res.active_backend is None
196 assert res.tier == backends.TIER_ERROR
197 assert "XAI_API_KEY" in res.prescription
198
199 def test_pinned_but_unusable_backend_is_error_with_its_prescription(self):
200 # Pin bird without cookies: env.x_backend_chain returns [] (pipeline
201 # raises); resolution mirrors that as an error carrying bird's fix.
202 config = {"LAST30DAYS_X_BACKEND": "bird"}
203 res = _resolve_x(config, bird_installed=True)
204 assert res.active_backend is None
205 assert res.pinned is True
206 assert res.tier == backends.TIER_ERROR
207 assert res.prescription # bird's cookie prescription, not xai's
208 assert "XAI_API_KEY" not in res.prescription
209
210 def test_broken_node_shim_makes_bird_unusable_and_falls_back(self):
211 # U1 integration: a stale node shim (BROKEN, not missing) must not
212 # let bird read as usable — the #692 class applied to chains.
213 config = {
214 "AUTH_TOKEN": "dummy-token",
215 "CT0": "dummy-ct0",
216 "XQUIK_API_KEY": "dummy-key",
217 }
218 res = _resolve_x(config, bird_installed=True, node_status=health.BROKEN)
219 assert res.active_backend == "xquik"
220 bird = next(f for f in res.findings if f.name == "bird")
221 assert bird.status == health.BROKEN
222 assert not bird.usable
223
224 def test_unconfigured_x_with_broken_node_is_unconfigured_not_node_error(self):
225 # F9: cookie presence is checked BEFORE the node runtime. With no X
226 # configuration at all, a broken node must not turn bird into a
227 # BROKEN finding carrying a node prescription — the honest state is
228 # "unconfigured, here is the cookie fix" (which doctor rolls up to
229 # tier off, since every finding is MISSING).
230 res = _resolve_x({}, node_status=health.BROKEN)
231 bird = next(f for f in res.findings if f.name == "bird")
232 assert bird.status == health.MISSING
233 assert "AUTH_TOKEN/CT0" in bird.detail
234 assert "cookie" in bird.prescription.lower()
235 assert "node" not in bird.prescription.lower()
236 # Doctor's off/unconfigured rollup keys on all-findings-MISSING.
237 assert all(f.status == health.MISSING for f in res.findings)
238
239 def test_cookies_present_broken_node_still_reads_broken(self):
240 # The inverse guard: once cookies ARE configured, a broken node is a
241 # real configured-but-broken state and must keep the node fix.
242 config = {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"}
243 res = _resolve_x(config, bird_installed=True, node_status=health.BROKEN)
244 bird = next(f for f in res.findings if f.name == "bird")
245 assert bird.status == health.BROKEN
246 assert "node" in bird.prescription.lower()
247
248
249 # ---------------------------------------------------------------------------
250 # Scenario 5: paid lanes probe key presence only — never network/subprocess
251 # ---------------------------------------------------------------------------
252
253 def _forbid_io():
254 def boom(*a, **k):
255 raise AssertionError("paid-lane probe attempted I/O")
256
257 return (
258 mock.patch("socket.socket", boom),
259 mock.patch("socket.create_connection", boom),
260 mock.patch("urllib.request.urlopen", boom),
261 mock.patch("subprocess.run", boom),
262 mock.patch("subprocess.Popen", boom),
263 )
264
265
266 class TestPaidLaneProbes:
267 PAID = [
268 ("x", "xai", "XAI_API_KEY"),
269 ("x", "xquik", "XQUIK_API_KEY"),
270 ("web", "serper", "SERPER_API_KEY"),
271 ("youtube", "scrapecreators", "SCRAPECREATORS_API_KEY"),
272 ("reddit", "scrapecreators", "SCRAPECREATORS_API_KEY"),
273 ]
274
275 def test_paid_lanes_are_flagged_paid(self):
276 for source, name, _key in self.PAID:
277 spec = next(
278 s for s in backends.get_descriptor(source).backends if s.name == name
279 )
280 assert spec.paid is True, f"{source}/{name} must be a paid (key-only) lane"
281
282 def test_key_presence_probe_makes_no_network_or_subprocess_calls(self):
283 ctxs = _forbid_io()
284 with ctxs[0], ctxs[1], ctxs[2], ctxs[3], ctxs[4]:
285 for source, name, key in self.PAID:
286 spec = next(
287 s for s in backends.get_descriptor(source).backends if s.name == name
288 )
289 present = spec.probe({key: "dummy-key"})
290 assert present.status == health.OK
291 absent = spec.probe({})
292 assert absent.status == health.MISSING
293 assert key in absent.prescription
294
295
296 # ---------------------------------------------------------------------------
297 # F1 + F10: the doctor-path xurl probe is LOCAL-ONLY (stored-token evidence,
298 # never a live `xurl whoami` — doctor's no-network guarantee) and typed.
299 # ---------------------------------------------------------------------------
300
301 class TestXurlLocalProbe:
302 def _spec(self):
303 return next(
304 s for s in backends.get_descriptor("x").backends if s.name == "xurl"
305 )
306
307 def _finding(self, stored, installed=True):
308 """Run the xurl probe under the forbid-all-I/O harness."""
309 ctxs = _forbid_io()
310 with ctxs[0], ctxs[1], ctxs[2], ctxs[3], ctxs[4], \
311 mock.patch(
312 "lib.backends.which",
313 lambda n: "/usr/local/bin/xurl" if installed else None,
314 ), \
315 mock.patch("lib.xurl_x.stored_auth_status", return_value=stored):
316 return self._spec().probe({})
317
318 def test_token_store_present_is_ok_without_network(self):
319 finding = self._finding(
320 (xurl_x.AUTH_OK, "stored OAuth credentials found in ~/.xurl")
321 )
322 assert finding.status == health.OK
323 assert "not live-verified" in finding.detail
324
325 def test_no_token_store_is_missing_with_auth_prescription(self):
326 finding = self._finding((xurl_x.AUTH_MISSING, "no token store at ~/.xurl"))
327 assert finding.status == health.MISSING
328 assert "not authenticated" in finding.detail
329 assert "xurl auth oauth2 login" in finding.prescription
330
331 def test_unreadable_token_store_is_error_tier(self):
332 # F10: binary resolvable but the token-store read fails -> typed
333 # ERROR (doctor's error tier), never "unconfigured".
334 finding = self._finding(
335 (
336 xurl_x.AUTH_ERROR,
337 "token store ~/.xurl unreadable: PermissionError: denied",
338 )
339 )
340 assert finding.status == health.ERROR
341 assert not finding.usable
342 assert "unreadable" in finding.detail
343
344 def test_binary_absent_stays_not_installed(self):
345 finding = self._finding((xurl_x.AUTH_OK, "irrelevant"), installed=False)
346 assert finding.status == health.MISSING
347 assert "not found on PATH" in finding.detail
348
349 def test_whole_doctor_path_x_probe_makes_no_network_or_subprocess(self, tmp_path):
350 """The full X chain resolution plus the safe get_x_source_status —
351 the exact X probes doctor runs — under the forbid-everything
352 harness. The token store is a REAL file so the genuine
353 stored_auth_status code path (filesystem only) is exercised."""
354 store = tmp_path / ".xurl"
355 store.write_text(
356 "apps:\n app:\n oauth2_tokens:\n me:\n oauth2:\n"
357 " access_token: dummy-not-real\n",
358 encoding="utf-8",
359 )
360 config = {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"}
361 bird_status = {
362 "installed": True,
363 "authenticated": True,
364 "username": "env AUTH_TOKEN",
365 "can_install": True,
366 }
367 ctxs = _forbid_io()
368 with ctxs[0], ctxs[1], ctxs[2], ctxs[3], ctxs[4], \
369 mock.patch(
370 "lib.xurl_x.is_available",
371 side_effect=AssertionError(
372 "doctor path ran the live `xurl whoami` network check"
373 ),
374 ), \
375 mock.patch("lib.xurl_x.token_store_path", return_value=store), \
376 mock.patch("lib.backends.which", lambda n: f"/usr/local/bin/{n}"), \
377 mock.patch(
378 "lib.xurl_x.shutil.which", lambda n: f"/usr/local/bin/{n}"
379 ), \
380 mock.patch("lib.health.probe_dependency", _probe_dep()), \
381 mock.patch("lib.bird_x.is_bird_installed", return_value=True), \
382 mock.patch("lib.bird_x.set_credentials", lambda *a, **k: None), \
383 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status):
384 res = backends.resolve("x", config)
385 status = env.get_x_source_status(config, probe=False)
386 xurl_finding = next(f for f in res.findings if f.name == "xurl")
387 assert xurl_finding.status == health.OK
388 assert "not live-verified" in xurl_finding.detail
389 assert status["xurl_available"] is True
390
391
392 # ---------------------------------------------------------------------------
393 # Scenario 6: Reddit conditional wording, never a computed winner
394 # ---------------------------------------------------------------------------
395
396 class TestRedditConditional:
397 def test_sc_key_present_renders_default_plus_backfill_no_winner(self):
398 res = backends.resolve("reddit", {"SCRAPECREATORS_API_KEY": "dummy-key"})
399 assert res.mode == backends.MODE_CONDITIONAL
400 assert res.active_backend is None # never a single computed winner
401 assert "will use" not in res.summary
402 low = res.conditional.lower()
403 assert "public keyless" in low
404 assert "default" in low
405 assert "scrapecreators backfill" in low
406 assert res.tier == backends.TIER_OK
407
408 def test_thinness_floor_appears_in_wording(self):
409 res = backends.resolve(
410 "reddit",
411 {"SCRAPECREATORS_API_KEY": "dummy-key", "LAST30DAYS_REDDIT_SC_MIN_ITEMS": "5"},
412 )
413 assert "5" in res.conditional
414 assert "floor" in res.conditional.lower()
415
416 def test_default_floor_zero_means_empty_only_wording(self):
417 res = backends.resolve("reddit", {"SCRAPECREATORS_API_KEY": "dummy-key"})
418 assert "nothing" in res.conditional.lower()
419
420 def test_malformed_floor_treated_as_default(self):
421 res = backends.resolve(
422 "reddit",
423 {"SCRAPECREATORS_API_KEY": "dummy-key", "LAST30DAYS_REDDIT_SC_MIN_ITEMS": "lots"},
424 )
425 assert "nothing" in res.conditional.lower()
426
427 def test_pinned_scrapecreators_renders_pin(self):
428 res = backends.resolve(
429 "reddit",
430 {
431 "SCRAPECREATORS_API_KEY": "dummy-key",
432 "LAST30DAYS_REDDIT_BACKEND": "scrapecreators",
433 },
434 )
435 assert res.pinned is True
436 assert res.pin == "scrapecreators"
437 low = res.conditional.lower()
438 assert "pinned" in low
439 assert "primary" in low
440 assert res.active_backend is None # still conditional, not a winner
441
442 def test_no_key_means_no_backfill_wording(self):
443 res = backends.resolve("reddit", {})
444 low = res.conditional.lower()
445 assert "public keyless" in low
446 assert "backfill" not in low or "no scrapecreators" in low
447 assert res.tier == backends.TIER_OK # public composite always reachable
448
449 def test_pin_without_key_is_ignored_like_the_pipeline(self):
450 # pipeline gates sc_first on has_sc_key; the pin alone changes nothing.
451 res = backends.resolve(
452 "reddit", {"LAST30DAYS_REDDIT_BACKEND": "scrapecreators"},
453 )
454 assert res.pinned is False
455 assert "primary" not in res.conditional.lower().split("pin ignored")[0]
456
457 def test_keyless_lanes_are_sub_probe_detail(self):
458 res = backends.resolve("reddit", {})
459 public = next(f for f in res.findings if f.name == "public")
460 for lane in ("rss", "listing", "arctic", "shreddit"):
461 assert lane in public.detail
462
463
464 # ---------------------------------------------------------------------------
465 # Scenario 7: parity with the pipeline's pre-failover X selection
466 # ---------------------------------------------------------------------------
467
468 class TestXParityWithPipeline:
469 """Descriptor prediction must equal env.x_backend_chain(config)[0] — the
470 exact expression pipeline._retrieve_stream uses as its pre-failover
471 primary (lib/pipeline.py, `chain = env.x_backend_chain(config)`)."""
472
473 def _assert_parity(self, config, **envkw):
474 with _stack(_x_env(**envkw)):
475 chain = env.x_backend_chain(config)
476 predicted = backends.resolve("x", config).active_backend
477 expected = chain[0] if chain else None
478 assert predicted == expected, (
479 f"prediction {predicted!r} != pipeline pre-failover {expected!r} "
480 f"for config keys {sorted(config)}"
481 )
482
483 def test_parity_xai_key_only(self):
484 self._assert_parity({"XAI_API_KEY": "dummy-key"})
485
486 def test_parity_cookies_and_bird_installed(self):
487 self._assert_parity(
488 {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"},
489 bird_installed=True,
490 )
491
492 def test_parity_pin_forces_xquik(self):
493 self._assert_parity(
494 {"XQUIK_API_KEY": "dummy-key", "LAST30DAYS_X_BACKEND": "xquik"},
495 )
496
497 def test_parity_nothing_configured(self):
498 self._assert_parity({})
499
500
501 # ---------------------------------------------------------------------------
502 # YouTube chain: yt-dlp -> ScrapeCreators
503 # ---------------------------------------------------------------------------
504
505 class TestYouTubeChain:
506 def test_ytdlp_healthy_wins(self):
507 with mock.patch("lib.health.probe_dependency", _probe_dep()):
508 res = backends.resolve("youtube", {"SCRAPECREATORS_API_KEY": "dummy-key"})
509 assert res.active_backend == "yt-dlp"
510 assert res.tier == backends.TIER_OK
511
512 def test_missing_ytdlp_falls_back_to_sc_key(self):
513 with mock.patch(
514 "lib.health.probe_dependency", _probe_dep({"yt-dlp": health.MISSING}),
515 ):
516 res = backends.resolve("youtube", {"SCRAPECREATORS_API_KEY": "dummy-key"})
517 assert res.active_backend == "scrapecreators"
518 assert res.tier == backends.TIER_OK
519
520 def test_neither_available_error_carries_ytdlp_prescription(self):
521 with mock.patch(
522 "lib.health.probe_dependency", _probe_dep({"yt-dlp": health.MISSING}),
523 ):
524 res = backends.resolve("youtube", {})
525 assert res.active_backend is None
526 assert res.tier == backends.TIER_ERROR
527 assert "yt-dlp" in res.prescription
528
529
530 # ---------------------------------------------------------------------------
531 # Web search chain: brave -> exa -> serper -> parallel -> keyless floor
532 # ---------------------------------------------------------------------------
533
534 class TestWebChain:
535 def test_brave_key_predicted_first(self):
536 res = backends.resolve(
537 "web", {"BRAVE_API_KEY": "dummy-key", "EXA_API_KEY": "dummy-key"},
538 )
539 assert res.active_backend == "brave"
540 assert res.tier == backends.TIER_OK
541
542 def test_keyless_floor_is_degraded_warn(self):
543 res = backends.resolve("web", {})
544 assert res.active_backend == "keyless"
545 assert res.tier == backends.TIER_WARN
546
547 def test_native_search_suppresses_keyless_floor(self):
548 res = backends.resolve("web", {"LAST30DAYS_NATIVE_SEARCH": "1"})
549 keyless = next(f for f in res.findings if f.name == "keyless")
550 assert not keyless.usable
551 assert res.active_backend is None
552
553 def test_pin_via_web_backend_flag(self):
554 res = backends.resolve(
555 "web", {"BRAVE_API_KEY": "dummy-key", "EXA_API_KEY": "dummy-key"}, pin="exa",
556 )
557 assert res.active_backend == "exa"
558 assert res.pinned is True
559 assert "pinned" in res.summary
560
561 def test_parity_with_grounding_auto_dispatch(self):
562 """resolve('web').active_backend must match the backend grounding's
563 auto branch actually dispatches to, per config permutation."""
564 from lib import grounding
565
566 def _auto_pick(config):
567 picked = {}
568
569 def rec(label):
570 def f(query, date_range, key, count=5):
571 picked["backend"] = label
572 return [], {"label": label}
573 return f
574
575 with mock.patch.object(grounding, "brave_search", rec("brave")), \
576 mock.patch.object(grounding, "exa_search", rec("exa")), \
577 mock.patch.object(grounding, "serper_search", rec("serper")), \
578 mock.patch.object(grounding, "parallel_search", rec("parallel")), \
579 mock.patch(
580 "lib.web_search_keyless.keyless_search",
581 lambda q, dr, cfg: (picked.__setitem__("backend", "keyless") or ([], {})),
582 ):
583 grounding.web_search("q", ("2026-06-04", "2026-07-04"), config, backend="auto")
584 return picked.get("backend")
585
586 for config in (
587 {"BRAVE_API_KEY": "dummy-key"},
588 {"SERPER_API_KEY": "dummy-key"},
589 {},
590 ):
591 assert backends.resolve("web", config).active_backend == _auto_pick(config)
592
593
594 # ---------------------------------------------------------------------------
595 # Rendering: prediction reads as will-use, never as past observation
596 # ---------------------------------------------------------------------------
597
598 class TestSummaryWording:
599 def test_alternative_summary_is_will_use(self):
600 res = backends.resolve("web", {"BRAVE_API_KEY": "dummy-key"})
601 assert res.summary.startswith("will use: brave")
602 assert "used" not in res.summary.split("will use")[1]
603
604 def test_error_summary_names_no_backend(self):
605 with mock.patch(
606 "lib.health.probe_dependency", _probe_dep({"yt-dlp": health.MISSING}),
607 ):
608 res = backends.resolve("youtube", {})
609 assert "will use" not in res.summary
610 assert "no usable backend" in res.summary.lower()
611
612 def test_conditional_summary_is_the_conditional_wording(self):
613 res = backends.resolve("reddit", {"SCRAPECREATORS_API_KEY": "dummy-key"})
614 assert res.summary == res.conditional
615
615 lines PYTHON