File size: 9,908 Bytes
71f166b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
"""Fixtures partagées du harness de régression.

Trois axes :

1. **Corpus de référence** : 3 tailles (small / medium / large) ;
   les images sont générées synthétiquement à la première
   utilisation pour rester reproductibles cross-OS sans déposer de
   blob binaire dans git.
2. **Golden snapshots** : sortie capturée du legacy, mise en cache
   sous ``golden/<phase>/<corpus>/<module>.<ext>``.  Régénérée à
   l'usage avec ``pytest --regen-golden``.
3. **Comparateurs** : helpers d'égalité bit-for-bit, sémantique
   HTML, ensemble de Facts.  Vivent dans ``_helpers/``.

Le harness est exclu du run pytest par défaut via le marker
``regression`` (cf. ``pyproject.toml``) — il s'exécute en CI
dédié pour ne pas ralentir la boucle de dev locale.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Iterable

import pytest

HARNESS_ROOT = Path(__file__).resolve().parent
CORPORA_DIR = HARNESS_ROOT / "corpora"
GOLDEN_DIR = HARNESS_ROOT / "golden"


def pytest_addoption(parser: pytest.Parser) -> None:
    """Ajoute ``--regen-golden`` pour régénérer les snapshots."""
    parser.addoption(
        "--regen-golden",
        action="store_true",
        default=False,
        help=(
            "Régénère les golden snapshots du harness de régression "
            "depuis l'état legacy actuel.  À utiliser quand on accepte "
            "explicitement une régression intentionnelle (cf. "
            "docs/migration/regression-tolerances.md)."
        ),
    )


def pytest_configure(config: pytest.Config) -> None:
    """Enregistre le marker ``regression``."""
    config.addinivalue_line(
        "markers",
        "regression: tests de régression legacy ↔ rewrite ; exclus "
        "par défaut, opt-in via ``pytest -m regression``.",
    )


# ──────────────────────────────────────────────────────────────────
# Corpus
# ──────────────────────────────────────────────────────────────────


@pytest.fixture(scope="session")
def small_corpus_dir() -> Path:
    """Corpus *small* : 3 documents synthétiques.

    Génération unique à la première utilisation par session.  Les
    images sont des PNG noir-sur-blanc avec une chaîne lisible
    figée par document, ce qui garantit la reproductibilité de
    Tesseract cross-OS (à version de binaire constante, le rendu
    PIL est identique).
    """
    out = CORPORA_DIR / "small"
    out.mkdir(parents=True, exist_ok=True)
    _generate_synthetic_corpus(
        out,
        documents=[
            ("doc01", "BENEDICTUS DEUS"),
            ("doc02", "Anno Domini MCMXVII"),
            ("doc03", "Folio 23 recto"),
        ],
    )
    return out


@pytest.fixture(scope="session")
def medium_corpus_dir() -> Path:
    """Corpus *medium* : 30 documents synthétiques.

    Mêmes contraintes que ``small_corpus_dir`` ; le contenu varie
    pour exercer les statistiques sur un échantillon plus large.
    """
    out = CORPORA_DIR / "medium"
    out.mkdir(parents=True, exist_ok=True)
    docs = [
        (f"doc{i:03d}", f"Sample text number {i:03d}")
        for i in range(1, 31)
    ]
    _generate_synthetic_corpus(out, documents=docs)
    return out


# ──────────────────────────────────────────────────────────────────
# Golden snapshots
# ──────────────────────────────────────────────────────────────────


@pytest.fixture
def golden_path(request: pytest.FixtureRequest):
    """Factory de chemins de snapshot.

    Usage ::

        def test_phaseN_xxx(golden_path):
            path = golden_path("phase1", "small", "tesseract.txt")
            # path est garanti dans GOLDEN_DIR ; le caller doit
            # l'écrire (au régen) ou le lire (en assertion).

    Le chemin retourné est ``golden/<phase>/<corpus>/<filename>``.
    Le répertoire parent est créé si nécessaire.
    """

    def _make(phase: str, corpus: str, filename: str) -> Path:
        path = GOLDEN_DIR / phase / corpus / filename
        path.parent.mkdir(parents=True, exist_ok=True)
        return path

    return _make


@pytest.fixture
def regen_golden(request: pytest.FixtureRequest) -> bool:
    """``True`` si l'utilisateur a passé ``--regen-golden``."""
    return bool(request.config.getoption("--regen-golden"))


def assert_golden_match(
    actual: str | bytes,
    golden_path: Path,
    *,
    regen: bool,
    encoding: str = "utf-8",
) -> None:
    """Compare ``actual`` au contenu de ``golden_path``.

    Si ``regen=True`` ou si le fichier golden n'existe pas, écrit
    ``actual`` au lieu de comparer.  Échoue sinon en cas de
    divergence.
    """
    if isinstance(actual, str):
        if regen or not golden_path.exists():
            golden_path.write_text(actual, encoding=encoding)
            return
        expected = golden_path.read_text(encoding=encoding)
        assert actual == expected, (
            f"Golden mismatch sur {golden_path}.\n"
            f"--- expected ---\n{expected[:500]}\n"
            f"--- actual ---\n{actual[:500]}\n"
            f"\nRégénérer avec ``pytest --regen-golden`` si la "
            "régression est intentionnelle (cf. "
            "regression-tolerances.md)."
        )
    else:
        if regen or not golden_path.exists():
            golden_path.write_bytes(actual)
            return
        expected_b = golden_path.read_bytes()
        assert actual == expected_b, (
            f"Golden mismatch (bytes) sur {golden_path}.\n"
            "Régénérer avec ``pytest --regen-golden`` si "
            "intentionnel."
        )


# ──────────────────────────────────────────────────────────────────
# Comparateurs sémantiques
# ──────────────────────────────────────────────────────────────────


def assert_floats_equal(
    actual: float,
    expected: float,
    *,
    eps: float = 1e-9,
    label: str = "value",
) -> None:
    """Égalité flottante au ε près (cf. regression-tolerances.md)."""
    assert abs(actual - expected) <= eps, (
        f"{label}: actual={actual!r} expected={expected!r} "
        f"diff={abs(actual - expected):.3e} > eps={eps:.0e}"
    )


def assert_set_equal(
    actual: Iterable[Any],
    expected: Iterable[Any],
    *,
    label: str = "set",
) -> None:
    """Égalité ensembliste (ordre ignoré).

    Utilisé typiquement pour les `Pareto front`, l'ensemble des
    Facts narratifs, l'ensemble des lignes CSV.
    """
    a = set(actual)
    e = set(expected)
    missing = e - a
    extra = a - e
    assert not (missing or extra), (
        f"{label}: ensembles différents.\n"
        f"  manquants ({len(missing)}): {sorted(missing)[:10]}\n"
        f"  en trop  ({len(extra)}): {sorted(extra)[:10]}"
    )


def assert_json_semantic_equal(
    actual: dict | list,
    expected: dict | list,
    *,
    label: str = "json",
) -> None:
    """Égalité JSON : sérialisation déterministe puis diff.

    Les deux structures sont sérialisées via
    ``json.dumps(sort_keys=True, ensure_ascii=False, indent=2)``
    avant comparaison — l'ordre des clés ne compte pas, le
    whitespace non plus.
    """
    a = json.dumps(actual, sort_keys=True, ensure_ascii=False, indent=2)
    e = json.dumps(expected, sort_keys=True, ensure_ascii=False, indent=2)
    assert a == e, (
        f"{label}: JSON différents.\n--- expected ---\n{e[:500]}\n"
        f"--- actual ---\n{a[:500]}"
    )


# ──────────────────────────────────────────────────────────────────
# Corpus generation (synthetic)
# ──────────────────────────────────────────────────────────────────


def _generate_synthetic_corpus(
    out_dir: Path,
    *,
    documents: list[tuple[str, str]],
) -> None:
    """Génère un corpus synthétique : pour chaque ``(doc_id, text)``,
    écrit ``out_dir/<doc_id>.png`` (image avec le texte rendu) et
    ``out_dir/<doc_id>.gt.txt`` (la GT).

    Idempotent : si tous les fichiers existent, ne fait rien.
    """
    pytest.importorskip("PIL")
    # Pillow expose ``Image``, ``ImageDraw``, ``ImageFont`` comme
    # **sous-modules**, pas comme attributs du package ``PIL`` ;
    # ``import PIL`` seul ne les attache pas.  Imports explicites
    # ici (Pillow est une dep optionnelle du harness — d'où le
    # ``importorskip`` et le déport en local).
    from PIL import Image, ImageDraw, ImageFont

    for doc_id, text in documents:
        png = out_dir / f"{doc_id}.png"
        gt = out_dir / f"{doc_id}.gt.txt"
        if png.exists() and gt.exists():
            continue
        img = Image.new("RGB", (600, 100), color="white")
        draw = ImageDraw.Draw(img)
        try:
            font = ImageFont.truetype("DejaVuSans-Bold.ttf", size=32)
        except OSError:
            font = ImageFont.load_default()
        draw.text((20, 30), text, fill="black", font=font)
        img.save(png)
        gt.write_text(text, encoding="utf-8")