Picarones / tests /integration /test_s8_small_files_coverage.py
Claude
test(sprint-S8.7): final small-file patch coverage push (93% β†’ ~96%)
2f951ac unverified
Raw
History Blame
9.88 kB
"""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:
@property
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"
@property
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")