File size: 9,877 Bytes
2f951ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
"""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")