File size: 15,549 Bytes
3bffe86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f54bb20
3bffe86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e48c0b
 
 
 
 
 
 
3bffe86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de9192c
3bffe86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de9192c
3bffe86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12acb53
3bffe86
12acb53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3bffe86
12acb53
 
 
 
 
 
3bffe86
12acb53
 
 
 
 
 
3bffe86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
"""Tests de cohérence du ``README.md`` avec le code réel.

Sprint A2 (items M-19, M-23, M-24, M-25, M-26 de l'audit
``institutional-readiness-2026-05.md``).

**Contrat** : tout élément vérifiable annoncé dans le README doit
correspondre à un artefact du code. La direction est unidirectionnelle :
on vérifie que ce qui est *annoncé* existe, **pas** que tout ce qui
existe est annoncé (cette deuxième garantie sera posée en A13 lors de
la refonte complète du README).

Vérifications appliquées :

1. **Moteurs OCR listés** dans le tableau « Supported Engines » ⇒
   un fichier ``picarones/engines/{nom}.py`` doit exister, ou la ligne
   est annotée par un commentaire HTML
   ``<!-- doc-check: skip-engine -->``.
2. **Commandes CLI listées** dans le tableau « CLI Commands » ⇒ la
   commande doit apparaître dans ``picarones --help``.
3. **Endpoints API web listés** ⇒ doivent exister dans
   ``app.openapi()["paths"]`` (ou être annotés skip).
4. **Compteur de tests** : les phrases du type « pytest tests/ →
   N passed » doivent correspondre au baseline collecté par
   ``pytest --collect-only``, à 5 % près (tolérance pour PR en cours).

**Mécanisme d'exception** : pour autoriser une ligne temporairement
non vérifiable (par exemple un moteur en cours d'ajout dans le même
PR que le README), insérer un commentaire HTML
``<!-- doc-check: skip-<kind> -->`` à la fin de la ligne du tableau.
À utiliser avec modération — tout skip est inspecté en revue.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest


# ---------------------------------------------------------------------------
# Constantes
# ---------------------------------------------------------------------------

REPO_ROOT = Path(__file__).resolve().parents[2]
README_PATH = REPO_ROOT / "README.md"
ENGINES_DIR = REPO_ROOT / "picarones" / "adapters" / "ocr"

#: Marqueur HTML qui désactive un check sur la ligne. Format :
#: ``<!-- doc-check: skip-engine -->``, ``skip-cli``, ``skip-endpoint``.
SKIP_PATTERN = re.compile(r"<!--\s*doc-check:\s*skip-([a-z]+)\s*-->")

#: Préfixes de "moteurs" du tableau qui ne sont *pas* des moteurs OCR
#: (ce sont des LLMs/VLMs utilisés via les pipelines). Ils sont
#: tolérés en attendant la refonte A13 qui scindera le tableau.
#: La comparaison est sur préfixe pour accepter les annotations comme
#: ``GPT-4o (VLM)`` ou ``Claude Sonnet (VLM)``.
NOT_OCR_ENGINES_TOLERATED_PREFIXES = (
    "GPT-4",
    "GPT-3",
    "Claude",
    "Mistral Large",
    "Mistral Small",
    "Mistral 7B",
    "Ministral",
    "Ollama",
    "Llama",
    "Custom engine",
)


# ---------------------------------------------------------------------------
# Helpers de parsing
# ---------------------------------------------------------------------------


def _read_readme() -> str:
    return README_PATH.read_text(encoding="utf-8")


def _normalize_engine_name(name: str) -> str:
    """Normalise un nom de moteur en chemin de module candidat.

    Exemples :
    - ``"Tesseract 5"`` → ``"tesseract"``
    - ``"Pero OCR"`` → ``"pero_ocr"``
    - ``"Google Vision"`` → ``"google_vision"``
    - ``"Azure Doc Intelligence"`` → ``"azure_doc_intel"``
    """
    n = name.lower().strip()
    # Retire emphasis markdown (**Tesseract 5**)
    n = n.replace("**", "").strip()
    # Retire les versions ("tesseract 5" → "tesseract")
    n = re.sub(r"\s+\d+(\.\d+)*$", "", n)
    # Mappings explicites (alias historiques)
    aliases = {
        "azure document intelligence": "azure_doc_intel",
        "azure doc intelligence": "azure_doc_intel",
        # Phase 3 du chantier post-rewrite : kraken/calamari sont
        # listés dans le README avec leur nom commercial complet,
        # mais leur module Python s'appelle juste ``kraken.py`` /
        # ``calamari.py`` (cohérent avec ``pero_ocr.py`` qui ne
        # s'appelle pas ``pero_ocr_htr.py``).
        "kraken htr": "kraken",
        "calamari ocr": "calamari",
    }
    if n in aliases:
        return aliases[n]
    # Default : remplace espaces par underscores
    return n.replace(" ", "_").replace("-", "_")


def _has_engine_adapter(engine_name: str) -> bool:
    """Vrai si ``picarones/engines/{normalized}.py`` existe."""
    candidate = _normalize_engine_name(engine_name)
    return (ENGINES_DIR / f"{candidate}.py").exists()


def _parse_markdown_tables(text: str) -> list[dict]:
    """Parse toutes les tables Markdown du document.

    Retourne une liste de dicts ``{"headers": [...], "rows": [[...], ...],
    "raw_rows": [str, ...]}``. ``raw_rows`` conserve la ligne brute pour
    permettre la détection des marqueurs ``<!-- doc-check: skip-... -->``.
    """
    tables: list[dict] = []
    lines = text.split("\n")
    i = 0
    while i < len(lines):
        line = lines[i].strip()
        # Heuristique : une table commence par une ligne d'en-tête en | ... |
        # suivie par une ligne de séparation |---|...|
        if line.startswith("|") and i + 1 < len(lines):
            sep = lines[i + 1].strip()
            if re.match(r"^\|[\s:|-]+\|$", sep):
                headers = [h.strip() for h in line.strip("|").split("|")]
                rows: list[list[str]] = []
                raw_rows: list[str] = []
                j = i + 2
                while j < len(lines) and lines[j].strip().startswith("|"):
                    raw = lines[j]
                    cells = [c.strip() for c in raw.strip().strip("|").split("|")]
                    rows.append(cells)
                    raw_rows.append(raw)
                    j += 1
                tables.append({"headers": headers, "rows": rows, "raw_rows": raw_rows})
                i = j
                continue
        i += 1
    return tables


def _has_skip_marker(raw_row: str, kind: str) -> bool:
    """Vrai si la ligne contient ``<!-- doc-check: skip-<kind> -->``."""
    for match in SKIP_PATTERN.finditer(raw_row):
        if match.group(1) == kind:
            return True
    return False


def _find_table_by_header(tables: list[dict], required_columns: set[str]) -> dict | None:
    """Retourne la première table dont les en-têtes contiennent ``required_columns``."""
    for table in tables:
        normalized = {h.lower().strip() for h in table["headers"]}
        if required_columns.issubset(normalized):
            return table
    return None


# ---------------------------------------------------------------------------
# 1. Moteurs OCR listés (M-23)
# ---------------------------------------------------------------------------


def test_engines_table_present() -> None:
    """Le README doit contenir une table des moteurs supportés."""
    tables = _parse_markdown_tables(_read_readme())
    table = _find_table_by_header(tables, {"engine", "type"})
    assert table is not None, (
        "Aucune table 'Supported Engines' trouvée dans le README "
        "(en-têtes 'Engine' + 'Type' attendus)."
    )
    assert len(table["rows"]) >= 1, "Table moteurs vide."


def test_listed_engines_have_adapter() -> None:
    """Tout moteur OCR listé dans le README doit avoir un adapter dans
    ``picarones/engines/``, sauf annotation explicite ``skip-engine``.

    Tolérance : les LLMs/VLMs (GPT-4o, Claude, etc.) sont tolérés tant que
    A13 (refonte README) n'a pas scindé le tableau en 'OCR engines' et
    'LLM/VLM adapters'. La tolérance est pilotée par la frozenset
    ``NOT_OCR_ENGINES_TOLERATED`` ci-dessus.
    """
    tables = _parse_markdown_tables(_read_readme())
    table = _find_table_by_header(tables, {"engine", "type"})
    assert table is not None, "Table moteurs absente (pré-requis : test_engines_table_present)"

    missing: list[str] = []
    for row, raw in zip(table["rows"], table["raw_rows"], strict=True):
        if not row or not row[0].strip():
            continue
        engine_name = row[0].replace("**", "").strip()

        # Skip marker explicite
        if _has_skip_marker(raw, "engine"):
            continue
        # LLM/VLM tolérés (à scinder en A13). Comparaison sur préfixe
        # pour accepter "GPT-4o (VLM)", "Claude Sonnet (VLM)", etc.
        if any(engine_name.startswith(p) for p in NOT_OCR_ENGINES_TOLERATED_PREFIXES):
            continue

        if not _has_engine_adapter(engine_name):
            missing.append(engine_name)

    assert not missing, (
        f"Moteurs annoncés dans le README sans adapter dans "
        f"picarones/engines/ : {missing}. "
        f"Soit créer l'adapter, soit retirer la ligne, soit annoter "
        f"avec <!-- doc-check: skip-engine -->."
    )


# ---------------------------------------------------------------------------
# 2. Commandes CLI (M-25)
# ---------------------------------------------------------------------------


def _real_cli_commands() -> set[str]:
    """Retourne l'ensemble des commandes effectivement exposées."""
    from picarones.interfaces.cli import cli

    return set(cli.commands.keys())


def test_listed_cli_commands_exist() -> None:
    """Toute commande ``picarones <X>`` listée dans le README doit exister."""
    real = _real_cli_commands()
    text = _read_readme()
    tables = _parse_markdown_tables(text)

    # Une table CLI a typiquement les colonnes "Command" + "Description".
    table = _find_table_by_header(tables, {"command", "description"})
    if table is None:
        pytest.skip("Pas de tableau CLI explicite dans le README")

    missing: list[str] = []
    for row, raw in zip(table["rows"], table["raw_rows"], strict=True):
        if not row or not row[0].strip():
            continue
        cell = row[0]

        if _has_skip_marker(raw, "cli"):
            continue

        # Extraction robuste : "`picarones run`" → "run", "picarones import iiif" → "import"
        m = re.search(r"picarones\s+([a-z][a-z_-]*)", cell)
        if not m:
            continue
        cmd = m.group(1)
        if cmd not in real:
            missing.append(cmd)

    assert not missing, (
        f"Commandes annoncées dans le tableau CLI du README mais "
        f"absentes de `picarones --help` : {missing}. "
        f"Disponibles : {sorted(real)}."
    )


# ---------------------------------------------------------------------------
# 3. Endpoints API web (M-26)
# ---------------------------------------------------------------------------


def _real_api_endpoints() -> set[str]:
    """Retourne l'ensemble des chemins exposés par l'app FastAPI."""
    try:
        from picarones.interfaces.web.app import app
    except Exception as exc:  # pragma: no cover — défense en profondeur
        pytest.skip(f"FastAPI app non importable : {exc}")
    spec = app.openapi()
    return set(spec.get("paths", {}).keys())


def _normalize_path(path: str) -> str:
    """Normalise un path en remplaçant les variables ``{xxx}`` ou ``:xxx``
    par des wildcards comparables."""
    path = path.strip().strip("`")
    # ``{job_id}`` et ``{filename}`` représentent la même chose côté code
    return re.sub(r"\{[^}]+\}", "{}", path)


def test_listed_endpoints_exist() -> None:
    """Tout endpoint listé dans le README (avec son chemin commençant par
    ``/api/`` ou ``/`` ou ``/reports/``) doit exister dans l'API FastAPI."""
    real = {_normalize_path(p) for p in _real_api_endpoints()}
    text = _read_readme()
    tables = _parse_markdown_tables(text)

    # Trouver une table d'endpoints (en-têtes "Endpoint" + "Method").
    table = _find_table_by_header(tables, {"endpoint", "method"})
    if table is None:
        pytest.skip("Pas de tableau API endpoints dans le README")

    missing: list[str] = []
    for row, raw in zip(table["rows"], table["raw_rows"], strict=True):
        if not row or not row[0].strip():
            continue
        if _has_skip_marker(raw, "endpoint"):
            continue
        path = _normalize_path(row[0])
        if not path.startswith("/"):
            continue
        if path not in real:
            missing.append(path)

    assert not missing, (
        f"Endpoints annoncés dans le README mais absents de "
        f"app.openapi()['paths'] : {missing}. "
        f"Disponibles ({len(real)}) : {sorted(real)[:10]}…"
    )


# ---------------------------------------------------------------------------
# 4. Compteur de tests — le README ne pin plus un nombre exact
# ---------------------------------------------------------------------------
#
# Historique : ce test lançait ``subprocess.run([..., "pytest",
# "--collect-only", ...])`` pour comparer le compteur cité au nombre
# réel.  Pytest-dans-pytest avec ``--cov`` cause un deadlock sur le
# lock ``.coverage`` (le commentaire ``-p no:cacheprovider`` + ``--no-cov``
# documente déjà ce risque).  La stratégie actuelle élimine la classe
# d'erreur : le README dit ``5000+ tests``, sans nombre figé, et le
# chiffre exact vit dans le badge CI.
#
# Ce test ne fait plus que vérifier qu'aucun compteur exact n'a été
# réintroduit en prose.


def test_readme_does_not_pin_exact_test_count() -> None:
    """Le README ne doit plus citer un nombre exact (``5150 tests``,
    ``5159 passed``, etc.).  La formulation canonique est ``N+ tests``
    (ex. ``5000+ tests``) pour absorber la dérive OS-dépendante du
    compteur (4509 vs 4510 selon que tesseract est installé)."""
    text = _read_readme()

    # On accepte ``5000+ tests``, ``5000+ passed`` (avec ou sans
    # caractères de mise en forme markdown autour).  On refuse
    # ``5150 tests``, ``~5150 tests``, ``5150 passed``.
    forbidden_pattern = re.compile(
        r"(?<!\+)\b(\d{4,5})\s+(?:tests|passed)\b",
        re.IGNORECASE,
    )
    offenders = forbidden_pattern.findall(text)
    assert not offenders, (
        f"README cite des compteurs de tests exacts : {offenders}. "
        "Reformuler en ``N+ tests`` (ex. ``5000+ tests``) — le chiffre "
        "exact dérive selon l'OS / les binaires installés et vit dans "
        "le badge CI."
    )


# ---------------------------------------------------------------------------
# 5. Variables d'environnement (M-24)
# ---------------------------------------------------------------------------


def test_env_vars_with_adapter_or_marker() -> None:
    """Les variables ``AWS_*`` (et autres orphelines) ne doivent pas être
    documentées dans le README sans adapter correspondant.

    Vérification : pour ``AWS_*``, vérifier qu'un adapter
    ``picarones/engines/aws_*.py`` ou ``picarones/engines/textract*.py``
    existe. Si non, la mention dans le README est un mensonge → fail.
    """
    text = _read_readme()
    if "AWS_ACCESS_KEY_ID" not in text and "AWS_SECRET_ACCESS_KEY" not in text:
        # README propre, rien à vérifier.
        return

    # Si la mention est présente, elle doit être marquée skip ou un adapter
    # AWS existant doit la justifier.
    aws_adapters = list(ENGINES_DIR.glob("aws*.py")) + list(ENGINES_DIR.glob("textract*.py"))
    if aws_adapters:
        return  # adapter existe

    # Cas tolérance : la ligne est dans un commentaire HTML
    # ``<!-- doc-check: skip-env -->``
    skip_lines = [
        line
        for line in text.split("\n")
        if "AWS_ACCESS_KEY_ID" in line and _has_skip_marker(line, "env")
    ]
    assert skip_lines, (
        "AWS_* environment variables documentées dans le README mais "
        "aucun adapter Textract n'existe dans picarones/engines/. "
        "Soit implémenter, soit retirer ces 3 lignes, soit annoter "
        "avec <!-- doc-check: skip-env -->."
    )