Claude commited on
Commit
11f7ef2
·
unverified ·
1 Parent(s): 2f951ac

test(sprint-S8.7): patch coverage push on small files (93.64% → ~95%)

Browse files

Cible : les 10 fichiers avec 1-2 lignes patch manquantes —
ROI maximal car chaque ligne couverte rapporte ~0.07 pt en
patch coverage.

Tests ajoutés (+12)
-------------------

1. ``adapters/corpus/_http.py`` 96% → 100% (4 tests) :
- ``validate_http_url`` : empty hostname (line 120),
unsupported scheme, blocked loopback, URL légitime.
- ``download_url`` : ``extra_headers`` mergé dans la
requête (line 165) — testé avec patch d'``urllib.request.urlopen``
qui capture les headers.

2. ``adapters/corpus/gallica.py`` (1 test) :
- ``_fetch_url`` avec ``delay_between_requests > 0`` →
``time.sleep(delay)`` invoqué (line 161).

3. ``app/services/dependencies.py`` 86% → 94% (4 tests) :
- ``capture_system_binaries_lock`` retourne dict non vide.
- ``_safe_capture_binary_version`` : binaire absent → None.
- Empty output → None (lines 81-84).
- ``subprocess.SubprocessError`` → None + log debug.

4. ``adapters/llm/base.py`` 97% → 99% (2 tests) :
- Retry exponentiel sur ``TimeoutError`` (retryable) : 2
échecs puis succès, 2 warnings émis (lines 352-358).
- ``ValueError`` non-retryable → 1 seule tentative + result
avec error renseigné.

Régression : 4543 passed, 0 failed, ruff clean.
Patch coverage estimée : 93.64% → ~95%.

tests/integration/test_s8_misc_coverage.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint S8.7 — derniers wins coverage (lignes 1-2 manquantes
2
+ dans plusieurs petits fichiers de la patch).
3
+
4
+ Cibles :
5
+ - ``adapters/corpus/_http.validate_http_url`` : ``hostname`` vide.
6
+ - ``adapters/corpus/_http.download_url`` : header custom via
7
+ ``extra_headers``.
8
+ - ``adapters/corpus/gallica`` : politesse ``time.sleep(delay)``
9
+ quand ``delay > 0``.
10
+ - ``app/services/dependencies.capture_system_binaries_lock`` :
11
+ vrai retour avec binaire dispo (ligne 110) + fallback empty
12
+ output (lignes 81-84).
13
+ - ``adapters/llm/base.execute`` : retry exponentiel sur erreur
14
+ retryable (lignes 353-358) — testé via une fake fonction
15
+ ``complete`` qui lève deux fois puis réussit.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from unittest.mock import patch, MagicMock
22
+
23
+ import pytest
24
+
25
+
26
+ # ──────────────────────────────────────────────────────────────────────
27
+ # _http.py — validate_http_url + download_url avec extra_headers
28
+ # ──────────────────────────────────────────────────────────────────────
29
+
30
+
31
+ class TestValidateHTTPUrl:
32
+ def test_empty_hostname_rejected(self) -> None:
33
+ """Couvre ligne 120 — ``http:///path`` (sans host) → rejet."""
34
+ from picarones.adapters.corpus._http import validate_http_url
35
+
36
+ with pytest.raises(ValueError, match="hostname"):
37
+ validate_http_url("http:///some/path")
38
+
39
+ def test_unsupported_scheme_rejected(self) -> None:
40
+ from picarones.adapters.corpus._http import validate_http_url
41
+
42
+ with pytest.raises(ValueError, match="Schéma"):
43
+ validate_http_url("ftp://example.com/file")
44
+
45
+ def test_blocked_loopback_rejected(self) -> None:
46
+ from picarones.adapters.corpus._http import validate_http_url
47
+
48
+ with pytest.raises(ValueError, match="refusé|loopback|interne"):
49
+ validate_http_url("http://127.0.0.1/admin")
50
+
51
+ def test_legitimate_url_accepted(self) -> None:
52
+ """Contrôle positif : un URL public valide ne lève pas."""
53
+ from picarones.adapters.corpus._http import validate_http_url
54
+
55
+ validate_http_url("https://gallica.bnf.fr/ark:/12148/foo")
56
+ # no raise
57
+
58
+
59
+ class TestDownloadURLWithExtraHeaders:
60
+ def test_extra_headers_merged_into_request(self) -> None:
61
+ """Couvre ligne 165 — ``extra_headers`` doit être mergé
62
+ dans le dict de headers de la requête."""
63
+ from picarones.adapters.corpus import _http as http_mod
64
+
65
+ captured = {}
66
+
67
+ def fake_urlopen(req, timeout=None):
68
+ captured["headers"] = dict(req.headers)
69
+ mock_resp = MagicMock()
70
+ mock_resp.read.return_value = b"ok"
71
+ mock_resp.__enter__ = lambda self: mock_resp
72
+ mock_resp.__exit__ = lambda *a: None
73
+ return mock_resp
74
+
75
+ with patch.object(http_mod.urllib.request, "urlopen", fake_urlopen):
76
+ http_mod.download_url(
77
+ "https://gallica.bnf.fr/foo",
78
+ user_agent="picarones-test/1.0",
79
+ extra_headers={"X-Custom": "value-42"},
80
+ retries=1,
81
+ )
82
+ assert captured["headers"].get("X-custom") == "value-42", (
83
+ f"extra_headers pas mergé : {captured['headers']!r}"
84
+ )
85
+
86
+
87
+ # ──────────────────────────────────────────────────────────────────────
88
+ # gallica.py — politesse delay
89
+ # ──────────────────────────────────────────────────────────────────────
90
+
91
+
92
+ class TestGallicaDelay:
93
+ def test_delay_triggers_sleep_when_positive(self, monkeypatch) -> None:
94
+ """Couvre ligne 161 — ``time.sleep(self.delay)`` quand
95
+ ``delay > 0`` (politesse anti rate-limit Gallica)."""
96
+ from picarones.adapters.corpus.gallica import GallicaClient
97
+
98
+ sleep_calls: list[float] = []
99
+
100
+ def fake_sleep(duration):
101
+ sleep_calls.append(duration)
102
+
103
+ monkeypatch.setattr(time, "sleep", fake_sleep)
104
+
105
+ client = GallicaClient(delay_between_requests=0.5)
106
+
107
+ # ``_fetch_url`` importe ``download_url`` au call-time depuis
108
+ # ``_http`` — c'est cette référence qu'on patche.
109
+ def fake_download(url, **kwargs):
110
+ return b'<srw:searchRetrieveResponse xmlns:srw="x"></srw:searchRetrieveResponse>'
111
+
112
+ monkeypatch.setattr(
113
+ "picarones.adapters.corpus._http.download_url",
114
+ fake_download,
115
+ )
116
+
117
+ client.search(title="hugo", max_results=1)
118
+ assert 0.5 in sleep_calls, (
119
+ f"sleep(0.5) attendu, appels : {sleep_calls}"
120
+ )
121
+
122
+
123
+ # ──────────────────────────────────────────────────────────────────────
124
+ # dependencies.py — capture_system_binaries_lock
125
+ # ──────────────────────────────────────────────────────────────────────
126
+
127
+
128
+ class TestSystemBinariesLock:
129
+ def test_returns_dict_with_available_binary(self) -> None:
130
+ """Couvre ligne 110 — quand un binaire est dans PATH et
131
+ retourne une version, il est ajouté au lock dict."""
132
+ from picarones.app.services.dependencies import (
133
+ capture_system_binaries_lock,
134
+ )
135
+
136
+ lock = capture_system_binaries_lock()
137
+ # Python est garanti d'être dispo (on est en train de
138
+ # l'exécuter). ``tesseract`` peut être absent localement.
139
+ assert isinstance(lock, dict)
140
+ # Tous les binaires détectés ont une version non vide.
141
+ for binary, version in lock.items():
142
+ assert version, f"{binary} a une version vide dans le lock"
143
+
144
+ def test_safe_capture_returns_none_when_binary_absent(self) -> None:
145
+ from picarones.app.services.dependencies import (
146
+ _safe_capture_binary_version,
147
+ )
148
+
149
+ result = _safe_capture_binary_version(
150
+ "definitely-not-a-real-binary-xyz-12345",
151
+ )
152
+ assert result is None
153
+
154
+ def test_safe_capture_returns_none_on_empty_output(
155
+ self, monkeypatch, tmp_path,
156
+ ) -> None:
157
+ """Couvre lignes 81-84 — si le binaire répond avec une
158
+ chaîne vide, on retourne ``None`` (pas une chaîne vide)."""
159
+ from picarones.app.services import dependencies as deps_mod
160
+
161
+ # Mock shutil.which pour faire croire que le binaire existe.
162
+ monkeypatch.setattr(
163
+ "shutil.which", lambda b: "/usr/bin/fake-empty-output"
164
+ if b == "fake-empty-output" else None,
165
+ )
166
+
167
+ # Mock subprocess.run pour retourner stdout vide.
168
+ class FakeResult:
169
+ stdout = " " # whitespace only
170
+ stderr = ""
171
+
172
+ monkeypatch.setattr(
173
+ "subprocess.run", lambda *args, **kwargs: FakeResult(),
174
+ )
175
+
176
+ result = deps_mod._safe_capture_binary_version("fake-empty-output")
177
+ assert result is None
178
+
179
+ def test_safe_capture_subprocess_error_returns_none(
180
+ self, monkeypatch, caplog,
181
+ ) -> None:
182
+ """Couvre la branche ``except OSError`` lignes 76-80."""
183
+ import subprocess
184
+
185
+ from picarones.app.services import dependencies as deps_mod
186
+
187
+ monkeypatch.setattr(
188
+ "shutil.which", lambda b: "/usr/bin/fake-crashing",
189
+ )
190
+
191
+ def raising_run(*args, **kwargs):
192
+ raise subprocess.SubprocessError("simulated crash")
193
+
194
+ monkeypatch.setattr("subprocess.run", raising_run)
195
+
196
+ with caplog.at_level("DEBUG"):
197
+ result = deps_mod._safe_capture_binary_version(
198
+ "fake-crashing",
199
+ )
200
+ assert result is None
201
+ assert any("échouée" in rec.message for rec in caplog.records)
202
+
203
+
204
+ # ──────────────────────────────────────────────────────────────────────
205
+ # llm/base.py — retry exponentiel sur erreur retryable
206
+ # ──────────────────────────────────────────────────────────────────────
207
+
208
+
209
+ class TestBaseLLMAdapterRetry:
210
+ """``BaseLLMAdapter.complete()`` (lignes 329-371) gère le retry
211
+ interne : sur erreur retryable (TimeoutError, 5xx, rate-limit),
212
+ backoff exponentiel ; sur erreur non-retryable, sortie immédiate.
213
+ Le helper ``_call(prompt, image_b64)`` est le point d'extension
214
+ SDK-specific qu'on mocke ici."""
215
+
216
+ def test_retry_on_retryable_error_then_success(
217
+ self, monkeypatch, caplog,
218
+ ) -> None:
219
+ """Couvre lignes 352-358 — ``_call`` lève ``TimeoutError``
220
+ (retryable), backoff exponentiel, puis réussit."""
221
+ from picarones.adapters.llm.openai_adapter import OpenAIAdapter
222
+
223
+ adapter = OpenAIAdapter(model="gpt-4o")
224
+ # Désactive le sleep réel sinon le test dure ~4s (backoff 2^1
225
+ # + 2^2).
226
+ monkeypatch.setattr(time, "sleep", lambda d: None)
227
+
228
+ call_count = {"n": 0}
229
+
230
+ def fake_call(prompt, image_b64=None):
231
+ call_count["n"] += 1
232
+ if call_count["n"] < 3:
233
+ raise TimeoutError("simulated timeout")
234
+ return "recovered"
235
+
236
+ adapter._call = fake_call
237
+ # 3 retries = 4 tentatives totales possibles.
238
+ adapter.config["max_retries"] = 3
239
+ adapter.config["retry_backoff"] = 2.0
240
+
241
+ with caplog.at_level("WARNING"):
242
+ result = adapter.complete("dummy prompt")
243
+ assert result.text == "recovered"
244
+ # 3 tentatives : 1 initiale + 2 retries.
245
+ assert call_count["n"] == 3
246
+ # 2 warnings émis (1 par retry).
247
+ retry_warnings = [
248
+ rec for rec in caplog.records if "retryable" in rec.message
249
+ ]
250
+ assert len(retry_warnings) >= 2
251
+
252
+ def test_non_retryable_error_breaks_immediately(
253
+ self, monkeypatch,
254
+ ) -> None:
255
+ """Une exception non-retryable (``ValueError`` par ex.) sort
256
+ de la boucle au 1er échec (ligne 360 — ``else: break``)."""
257
+ from picarones.adapters.llm.openai_adapter import OpenAIAdapter
258
+
259
+ adapter = OpenAIAdapter(model="gpt-4o")
260
+ monkeypatch.setattr(time, "sleep", lambda d: None)
261
+
262
+ call_count = {"n": 0}
263
+
264
+ def fake_call(prompt, image_b64=None):
265
+ call_count["n"] += 1
266
+ raise ValueError("non-retryable error")
267
+
268
+ adapter._call = fake_call
269
+ adapter.config["max_retries"] = 3
270
+
271
+ result = adapter.complete("dummy")
272
+ # ValueError n'est pas retryable → 1 seule tentative.
273
+ assert call_count["n"] == 1
274
+ # Le LLMResult retourné a ``error`` renseigné.
275
+ assert result.error is not None
276
+ assert "non-retryable" in result.error