"""Tests S1 — toggles UI exposés pour les options `BenchmarkRunRequest`. Sprint S1 expose dans le template web 6 champs Pydantic qui étaient figés côté UI : - ``report_lang`` (FR / EN) - ``views`` (text_final + alto_documentary + searchability) - ``expose_alto`` (par-compétiteur, sur Tesseract uniquement) - ``entity_extractor`` (dotted path NER) - ``output_json`` (toggle qui dérive un chemin auto) - ``partial_dir`` (toggle qui dérive un chemin auto) Ces tests vérifient : 1. La présence des éléments DOM dans le template HTML rendu (l'UI peut effectivement collecter ces valeurs). 2. Le payload complet UI-style est accepté par ``POST /api/benchmark/run`` (compat ascendante préservée). 3. Le payload sans les nouveaux champs continue de marcher (les défauts Pydantic restent valides). Le contrat **API ↔ Pydantic** lui-même (validation positive/négative, path traversal) est testé exhaustivement par ``tests/web/test_benchmark_run_b3_final_fields.py``. Ce module-ci cible la couche **UI → JSON payload**. """ from __future__ import annotations from pathlib import Path import pytest from fastapi.testclient import TestClient @pytest.fixture def client(): from picarones.interfaces.web.app import app return TestClient(app) @pytest.fixture def workspace_corpus(tmp_path: Path) -> str: """Crée un corpus minimal sous un workspace autorisé pour les validators d'``output_dir`` / ``corpus_path``.""" from PIL import Image img_path = tmp_path / "doc01.png" Image.new("RGB", (40, 40), color=(255, 255, 255)).save(img_path) (tmp_path / "doc01.gt.txt").write_text("hello", encoding="utf-8") return str(tmp_path) # ───────────────────────────────────────────────────────────────────────────── # 1. Présence des éléments DOM dans le template # ───────────────────────────────────────────────────────────────────────────── class TestAdvancedOptionsUIElements: """Le template ``_view_benchmark.html`` doit contenir les IDs DOM consommés par ``_gather_advanced_options()`` côté JS.""" @pytest.fixture def html(self, client) -> str: response = client.get("/") assert response.status_code == 200 return response.text def test_report_lang_select_present(self, html: str) -> None: assert 'id="report-lang"' in html, ( "Sélecteur de langue du rapport manquant — " "report_lang ne sera jamais transmis depuis l'UI." ) # Options FR + EN visibles assert 'value="fr"' in html assert 'value="en"' in html def test_views_checkboxes_present(self, html: str) -> None: """Les checkboxes pour alto_documentary et searchability.""" assert 'id="view-alto-documentary"' in html assert 'id="view-searchability"' in html # text_final reste activé en permanence (checkbox disabled) assert 'id="view-text-final"' in html def test_entity_extractor_input_present(self, html: str) -> None: assert 'id="entity-extractor"' in html # Le datalist propose des presets utiles assert "spacy.fr_core_news_sm" in html assert "spacy.en_core_web_sm" in html def test_partial_resume_toggle_present(self, html: str) -> None: assert 'id="enable-partial-resume"' in html def test_output_json_toggle_present(self, html: str) -> None: assert 'id="enable-output-json"' in html def test_expose_alto_checkbox_present(self, html: str) -> None: """``expose_alto`` est dans la section compose (par-compétiteur).""" assert 'id="compose-expose-alto"' in html # Wrap dont la visibilité dépend du moteur (Tesseract uniquement). assert 'id="compose-expose-alto-wrap"' in html def test_advanced_options_collapsible_present(self, html: str) -> None: """Section ``
`` qui regroupe les options avancées.""" assert 'id="bench-advanced-options"' in html # ───────────────────────────────────────────────────────────────────────────── # 2. Payload UI-style complet accepté par l'API # ───────────────────────────────────────────────────────────────────────────── class TestFullUIStylePayloadAccepted: """Un payload qui reflète la sérialisation produite par ``_gather_advanced_options()`` doit être accepté tel quel.""" def test_payload_with_all_advanced_options( self, client, workspace_corpus: str, ) -> None: """Smoke test : payload UI-style avec les 6 toggles activés. Note : le validator de chemins est strict — on utilise ``partial/run-2026-05-23`` (relatif, sans ``..``) et ``rapport.json`` (basename). """ payload = { "corpus_path": workspace_corpus, "competitors": [ { "name": "tesseract (fra) · ALTO", "engine_name": "tesseract", "ocr_model": "fra", "expose_alto": True, "max_image_dimension": 0, }, ], "normalization_profile": "nfc", "char_exclude": "", "output_dir": workspace_corpus, # même workspace "report_name": "test_s1", "profile": "standard", # Options avancées : "report_lang": "en", "views": ["text_final", "alto_documentary", "searchability"], "partial_dir": "partial/run-2026-05-23", "entity_extractor": "spacy.fr_core_news_sm", "output_json": "test_s1.json", } # On ne lance pas vraiment le benchmark (lourd) — on vérifie # juste que Pydantic accepte le payload et que le runner # démarre un job. Le retour est ``{"job_id": ..., "status": "pending"}``. response = client.post("/api/benchmark/run", json=payload) # Accepte 200 (job démarré) ou 429 (rate-limited en CI parallèle) # mais surtout pas 422 (validation). assert response.status_code != 422, ( f"Payload rejeté par Pydantic : {response.json()}" ) def test_payload_with_minimal_advanced_options( self, client, workspace_corpus: str, ) -> None: """Payload sans les nouveaux champs : compat ascendante. Un client qui n'envoie aucun champ avancé doit continuer à marcher. Les défauts Pydantic prennent le relais. """ payload = { "corpus_path": workspace_corpus, "competitors": [ {"name": "tesseract", "engine_name": "tesseract"}, ], "output_dir": workspace_corpus, } response = client.post("/api/benchmark/run", json=payload) assert response.status_code != 422, ( f"Payload minimal rejeté : {response.json()}" ) # ───────────────────────────────────────────────────────────────────────────── # 3. expose_alto par-compétiteur : transmission propre # ───────────────────────────────────────────────────────────────────────────── class TestExposeAltoPerCompetitor: """``expose_alto`` est un champ de ``PipelineConfig``, pas de ``BenchmarkRunRequest`` : il s'applique par-compétiteur.""" def test_expose_alto_only_on_tesseract_competitor( self, client, workspace_corpus: str, ) -> None: """Mixer un compétiteur Tesseract+expose_alto et un autre OCR cloud sans expose_alto doit fonctionner.""" payload = { "corpus_path": workspace_corpus, "competitors": [ { "name": "tesseract (ALTO)", "engine_name": "tesseract", "ocr_model": "fra", "expose_alto": True, }, { "name": "mistral_ocr", "engine_name": "mistral_ocr", "expose_alto": False, # No-op pour Mistral }, ], "output_dir": workspace_corpus, "views": ["text_final", "alto_documentary"], } response = client.post("/api/benchmark/run", json=payload) assert response.status_code != 422 def test_expose_alto_default_false(self, client, workspace_corpus: str) -> None: """Compat ascendante : un client qui n'envoie pas expose_alto reçoit le défaut ``false``.""" from picarones.interfaces.web.models import PipelineConfig config = PipelineConfig(name="t", engine_name="tesseract") assert config.expose_alto is False # ───────────────────────────────────────────────────────────────────────────── # 4. Synchronisation i18n des nouvelles clés # ───────────────────────────────────────────────────────────────────────────── class TestI18nNewKeysCovered: """Les nouveaux libellés doivent avoir une traduction FR ET EN pour ne pas dégrader silencieusement en clé brute (``bench_views_label``) quand la langue d'interface est EN.""" @pytest.fixture def js_source(self) -> str: path = ( Path(__file__).resolve().parents[2] / "picarones" / "interfaces" / "web" / "static" / "web-app.js" ) return path.read_text(encoding="utf-8") @pytest.mark.parametrize("key", [ "compose_expose_alto_label", "compose_expose_alto_hint", "bench_advanced_options", "bench_report_lang_label", "bench_entity_extractor_label", "bench_views_label", "bench_view_alto_documentary", "bench_view_searchability", "bench_partial_resume_label", "bench_output_json_label", ]) def test_key_present_in_both_languages(self, js_source: str, key: str) -> None: """Chaque clé doit apparaître au moins **deux fois** dans la source — une fois dans ``T.fr`` et une fois dans ``T.en``.""" count = js_source.count(f"{key}:") assert count >= 2, ( f"Clé i18n {key!r} déclarée seulement {count} fois ; " "attendu ≥ 2 (FR + EN). Risque : clé brute affichée en EN." )