test: couvrir les modules purs du pipeline (96 nouveaux tests)

Suite de tests unitaires pour tous les modules pipeline qui ne dépendent
pas du VLM — utiles pour garantir la non-régression après refactor et
servir de spec vivante de chaque fonction.

Fichiers :
- tests/test_json_utils.py   (20 tests) : parse_json_output + toutes les
  stratégies de récupération (fences, virgules manquantes, boucles vides,
  fermeture JSON, fallback _raw/_parse_error)
- tests/test_deskew.py       (11 tests) : détection Hough + correction,
  image synthétique + fixtures cache réel
- tests/test_checkboxes.py   (17 tests) : parse_ghs_injustifie,
  dark_ratio, inner_frac, et ground truth visuel sur 17 dossiers
  (mapping hash→OGC résolu au runtime pour éviter les constantes fragiles)
- tests/test_validation.py   (18 tests) : _check_cim10/ccam/ghm/ghs,
  cross-checks GHM↔GHS, annotate sur JSON vide et complet,
  preservation de l'input (copie défensive)
- tests/test_schema.py       (8 tests)  : clean_dossier retire les champs
  debug, préserve les champs métier, compacte la validation, ne modifie
  pas l'input
- tests/test_zones_config.py (8 tests)  : load/save round-trip, merge
  avec defaults, résilience JSON corrompu, get_zone

Total : 107 tests, 5.1 s d'exécution, tous passent. Aucune dépendance
GPU, s'exécutent en CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-24 23:29:23 +02:00
parent d326524e49
commit 3a87751444
7 changed files with 913 additions and 0 deletions

160
tests/test_checkboxes.py Normal file
View File

@@ -0,0 +1,160 @@
"""Tests unitaires pour pipeline.checkboxes."""
from __future__ import annotations
import numpy as np
import pytest
from PIL import Image
from pipeline.checkboxes import (
AMBIGU_MARGIN,
CheckboxZones,
RECUEIL_ACCORD_DESACCORD,
dark_ratio,
detect_accord_desaccord,
parse_ghs_injustifie,
)
# ============================================================
# parse_ghs_injustifie
# ============================================================
class TestParseGhsInjustifie:
@pytest.mark.parametrize("raw,expected", [
("0", "0"),
("1", "1"),
("0 SE 1 2 3 4 ATU FFM FSD", "0"),
("1 SE 2 ATU", "1"),
(" 0 ", "0"),
("", ""),
(None, ""),
("SE 1 2 3 4 ATU FFM FSD", ""), # pas de chiffre de tête
("abc", ""),
("2 SE 1", ""), # 2 n'est ni 0 ni 1
])
def test_cas_varies(self, raw, expected):
assert parse_ghs_injustifie(raw) == expected
# ============================================================
# dark_ratio (avec images synthétiques)
# ============================================================
def _solid_image(w: int, h: int, gray_value: int = 255) -> Image.Image:
arr = np.full((h, w), gray_value, dtype=np.uint8)
return Image.fromarray(arr, mode="L").convert("RGB")
def _image_with_dark_square(w: int, h: int,
square_bbox: tuple[float, float, float, float]) -> Image.Image:
"""Image blanche avec un carré noir dans la zone bbox (coords relatives)."""
arr = np.full((h, w), 255, dtype=np.uint8)
x1, y1, x2, y2 = square_bbox
arr[int(y1*h):int(y2*h), int(x1*w):int(x2*w)] = 0
return Image.fromarray(arr, mode="L").convert("RGB")
class TestDarkRatio:
def test_image_blanche(self):
img = _solid_image(100, 100, 255)
ratio = dark_ratio(img, (0.2, 0.2, 0.8, 0.8))
assert ratio == 0.0
def test_image_noire(self):
img = _solid_image(100, 100, 0)
ratio = dark_ratio(img, (0.2, 0.2, 0.8, 0.8))
assert ratio == 1.0
def test_inner_frac_ignore_les_bords(self):
"""Un carré noir occupe toute la zone mais avec un grand inner_frac
on ne voit que le centre, qui reste dans la zone noire."""
img = _image_with_dark_square(100, 100, (0.0, 0.0, 1.0, 1.0))
# Tout noir, peu importe inner_frac
assert dark_ratio(img, (0.0, 0.0, 1.0, 1.0), inner_frac=0.35) == 1.0
def test_cadre_seul_vs_contenu_central(self):
"""Une case 'vide' (cadre seul) doit avoir un ratio inner_frac faible ;
une case 'cochée' (croix au centre) doit avoir un ratio plus élevé."""
# Simuler un cadre : carré noir sur le pourtour uniquement
w, h = 100, 100
arr = np.full((h, w), 255, dtype=np.uint8)
arr[:5, :] = 0; arr[-5:, :] = 0; arr[:, :5] = 0; arr[:, -5:] = 0
frame_only = Image.fromarray(arr, mode="L").convert("RGB")
# Cadre + croix au centre
arr2 = arr.copy()
# Une croix : 2 diagonales
for i in range(20, 80):
arr2[i, i] = 0
arr2[i, 100 - 1 - i] = 0
checked = Image.fromarray(arr2, mode="L").convert("RGB")
ratio_empty = dark_ratio(frame_only, (0.0, 0.0, 1.0, 1.0), inner_frac=0.35)
ratio_full = dark_ratio(checked, (0.0, 0.0, 1.0, 1.0), inner_frac=0.35)
# La case cochée doit avoir un ratio clairement plus élevé
assert ratio_full > ratio_empty + 0.05
# ============================================================
# detect_accord_desaccord (fixtures cache)
# ============================================================
class TestDetectAccordDesaccord:
"""Tests sur les images réelles du cache, avec ground truth vérifié
visuellement (cf. historique du projet, crops audités un par un).
Ground truth indexé par numéro d'OGC — le mapping vers le hash du cache
est résolu au runtime via pipeline.ingest.pdf_hash pour éviter de coder
les hashes en dur (fragile).
"""
# Ground truth vérifié visuellement sur les 18 dossiers 2018 CARC
GROUND_TRUTH_BY_OGC = {
1: "accord",
7: "accord",
9: "désaccord",
18: "désaccord",
20: "désaccord",
27: "désaccord",
29: "accord",
55: "accord",
66: "désaccord",
68: "accord",
69: "accord",
74: "désaccord",
76: "désaccord",
84: "accord",
86: "désaccord",
97: "accord",
99: "désaccord",
}
@pytest.fixture
def cached_pages_with_truth(self):
"""Résout le mapping numéro OGC → page_01.png disponible au runtime."""
from pathlib import Path
from pipeline.ingest import pdf_hash
pdf_dir = Path("2018 CARC")
if not pdf_dir.is_dir():
pytest.skip("répertoire 2018 CARC/ absent")
found = {}
for n, expected in self.GROUND_TRUTH_BY_OGC.items():
pdf = pdf_dir / f"OGC {n}.pdf"
if not pdf.exists():
continue
h = pdf_hash(str(pdf))
img = Path(f".cache/images/{h}/page_01.png")
if img.exists():
found[f"OGC {n}"] = (str(img), expected)
if not found:
pytest.skip("pas de cache d'images disponible — lance le pipeline d'abord")
return found
def test_ground_truth_echantillon(self, cached_pages_with_truth):
"""Sur les cas vérifiés visuellement, le détecteur doit matcher."""
errors = []
for name, (path, expected) in cached_pages_with_truth.items():
r = detect_accord_desaccord(path)
if r["decision"] != expected:
errors.append(f"{name}: attendu={expected}, got={r}")
assert not errors, "\n".join(errors)