Picarones / tests /web /test_benchmark_request_options.py
Claude
feat(web): S1 โ€” expose 6 toggles BenchmarkRunRequest figรฉs dans l'UI
a98013e unverified
Raw
History Blame Contribute Delete
11.4 kB
"""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 ``<details>`` 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."
)