Spaces:
Sleeping
Sleeping
| """Sprint S8.7 β couverture finale des petits fichiers pour | |
| faire passer Codecov patch coverage > 95%. | |
| Cibles : | |
| - ``_workflows._validate_cer_threshold`` (CLI callback validation). | |
| - ``module_policy._is_module_subclass`` AttributeError fallback + | |
| introspection inputs/outputs failure. | |
| - ``history`` router : ``query`` et ``detect_regression`` qui | |
| lΓ¨vent β warning + continue dΓ©gradΓ©. | |
| - ``security.validate_image_safe`` branche ``except Exception`` | |
| générique (lignes 239-242) sur erreur Pillow hétérogène. | |
| """ | |
| from __future__ import annotations | |
| import click | |
| import pytest | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CLI _validate_cer_threshold β validation callback | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestValidateCERThresholdCallback: | |
| """``--fail-if-cer-above`` doit Γͺtre en fraction β [0, 1]. | |
| Avant le fix, l'ancienne sΓ©mantique acceptait des pourcentages | |
| (15.0 = 15 %) ; on Γ©choue maintenant bruyamment sur les | |
| valeurs > 1 pour empΓͺcher la mauvaise interprΓ©tation.""" | |
| def _run_callback(self, value): | |
| from picarones.interfaces.cli._workflows import ( | |
| _validate_cer_threshold, | |
| ) | |
| return _validate_cer_threshold(ctx=None, param=None, value=value) | |
| def test_none_passes_through(self) -> None: | |
| assert self._run_callback(None) is None | |
| def test_valid_fraction_returned(self) -> None: | |
| assert self._run_callback(0.15) == 0.15 | |
| def test_zero_accepted(self) -> None: | |
| assert self._run_callback(0.0) == 0.0 | |
| def test_one_accepted_at_boundary(self) -> None: | |
| assert self._run_callback(1.0) == 1.0 | |
| def test_negative_value_rejected(self) -> None: | |
| with pytest.raises(click.BadParameter, match=">= 0|β₯ 0"): | |
| self._run_callback(-0.1) | |
| def test_legacy_percent_value_rejected(self) -> None: | |
| """Valeur > 1 (ancienne sΓ©mantique pourcentage) doit lever | |
| avec un message qui explique la migration.""" | |
| with pytest.raises(click.BadParameter) as exc_info: | |
| self._run_callback(15.0) | |
| msg = str(exc_info.value) | |
| assert "fraction" in msg | |
| assert "15.0" in msg or "0.15" in msg | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # module_policy β defensive paths | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestModulePolicyDefensive: | |
| def test_is_base_module_no_mro_returns_false(self) -> None: | |
| """Couvre lignes 220-221 β un objet sans ``__mro__`` | |
| accessible doit retourner False sans planter.""" | |
| from picarones.evaluation.metrics.module_policy import ( | |
| _is_base_module, | |
| ) | |
| class TrulyBroken: | |
| def __mro__(self): # type: ignore[override] | |
| raise AttributeError("simulated absent __mro__") | |
| # ``_is_base_module`` accΓ¨de Γ ``cls.__mro__`` directement β | |
| # on doit lui passer une instance dont l'accès lève. | |
| result = _is_base_module(TrulyBroken()) | |
| assert result is False | |
| def test_audit_module_introspection_failure_falls_back( | |
| self, caplog, | |
| ) -> None: | |
| """Couvre lignes 284-292 β si l'accΓ¨s Γ | |
| ``output_types`` lève (manifest custom property qui plante), | |
| ``audit_module`` retombe sur listes vides + log debug.""" | |
| from picarones.evaluation.metrics.module_policy import ( | |
| ModuleManifest, audit_module, | |
| ) | |
| class BadManifestModule: | |
| input_types = "this-should-be-iterable-but-isnt-an-iterable-of-types" | |
| def output_types(self): | |
| # ``getattr(cls, "output_types", None)`` cΓ΄tΓ© audit | |
| # accΓ¨de au descriptor β property.__get__ avec cls=None | |
| # ne lève pas, mais l'itération ``for t in attr_out`` | |
| # plus tard plantera (str pas itΓ©rable de types). | |
| raise RuntimeError("manifest cassΓ© simulΓ©") | |
| manifest = ModuleManifest( | |
| name="bad", version="1.0", author="t", license="MIT", | |
| description="test bad", | |
| input_types=[], output_types=[], | |
| ) | |
| with caplog.at_level("DEBUG"): | |
| result = audit_module(BadManifestModule, manifest) | |
| # ``audit_module`` retourne un ``AuditResult`` mΓͺme avec un | |
| # manifest cassΓ© β c'est tout l'intΓ©rΓͺt de la dΓ©fense. | |
| assert result is not None | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # history router β dΓ©gradation gracieuse | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestHistoryRouterDegraded: | |
| def _app(self): | |
| from fastapi import FastAPI | |
| from picarones.interfaces.web.routers import history as h | |
| app = FastAPI() | |
| app.include_router(h.router) | |
| return app | |
| def test_query_failure_returns_empty_targets_with_warning( | |
| self, monkeypatch, caplog, | |
| ) -> None: | |
| """Quand ``BenchmarkHistory.query`` lève (DB corrompue, | |
| schΓ©ma migrΓ©), on log un warning et on retourne une liste | |
| vide de rΓ©gressions plutΓ΄t que de planter en 500. Couvre | |
| lignes 52-56.""" | |
| from fastapi.testclient import TestClient | |
| from picarones.evaluation.metrics import history as eval_history | |
| # Mock BenchmarkHistory.query pour lever. | |
| def raising_query(*args, **kwargs): | |
| raise RuntimeError("DB schema mismatch simulΓ©") | |
| monkeypatch.setattr( | |
| eval_history.BenchmarkHistory, "query", raising_query, | |
| ) | |
| app = self._app() | |
| with caplog.at_level("WARNING"): | |
| with TestClient(app) as client: | |
| r = client.get("/api/history/regressions") | |
| assert r.status_code == 200, r.text | |
| # Sans moteur explicite + query qui plante β liste vide. | |
| assert r.json()["regressions"] == [] | |
| # Warning Γ©mis. | |
| assert any( | |
| "Γ©numΓ©ration" in rec.message.lower() or "moteurs" in rec.message.lower() | |
| for rec in caplog.records | |
| ) | |
| def test_detect_regression_failure_continues_to_next_engine( | |
| self, monkeypatch, caplog, | |
| ) -> None: | |
| """Quand ``detect_regression`` lève pour un moteur, on log | |
| un warning et on continue avec les suivants. Couvre | |
| lignes 62-66.""" | |
| from fastapi.testclient import TestClient | |
| from picarones.evaluation.metrics import history as eval_history | |
| def raising_detect(self, *, engine, threshold): | |
| raise RuntimeError(f"detect_regression KO pour {engine}") | |
| monkeypatch.setattr( | |
| eval_history.BenchmarkHistory, | |
| "detect_regression", | |
| raising_detect, | |
| ) | |
| app = self._app() | |
| with caplog.at_level("WARNING"): | |
| with TestClient(app) as client: | |
| r = client.get( | |
| "/api/history/regressions", | |
| params={"engine": "tesseract"}, | |
| ) | |
| assert r.status_code == 200, r.text | |
| # detect a plantΓ© β pas de rΓ©sultat dans la liste. | |
| assert r.json()["regressions"] == [] | |
| assert any( | |
| "detect_regression" in rec.message or "tesseract" in rec.message | |
| for rec in caplog.records | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # security.validate_image_safe β branche Exception gΓ©nΓ©rique | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestValidateImageGenericException: | |
| """Pillow lève un panel d'exceptions hétérogènes (SyntaxError | |
| sur GIF malformΓ©, OSError sur TIFF corrompu, AttributeError | |
| interne, etc.) β toutes doivent Γͺtre transformΓ©es en | |
| ``ValueError`` propre via la branche ``except Exception``. | |
| Couvre lignes 239-242.""" | |
| def test_generic_pillow_failure_wrapped_in_value_error( | |
| self, monkeypatch, | |
| ) -> None: | |
| from PIL import Image | |
| from picarones.interfaces.web.security import validate_image_safe | |
| # Mock Image.open pour retourner un objet dont ``verify()`` | |
| # lève une OSError (typique TIFF corrompu). | |
| class FakeImg: | |
| def __enter__(self): | |
| return self | |
| def __exit__(self, *args): | |
| pass | |
| def verify(self): | |
| raise OSError("simulated corrupt TIFF") | |
| monkeypatch.setattr(Image, "open", lambda *args, **kwargs: FakeImg()) | |
| with pytest.raises(ValueError, match="OSError|erreur de dΓ©codage"): | |
| validate_image_safe(b"any-bytes", filename="corrupt.tiff") | |