Spaces:
Sleeping
Sleeping
| """Tests Phase 21C/D — dispatch Documents + Crosses + cleanup JS corr-matrix. | |
| Vérifie que les charts cross-dimensionnels et doc-centriques sont | |
| correctement déplacés vers leur destination XerOCR sémantique : | |
| - chart-cer-doc → documents.html (doc-centric line chart) | |
| - chart-quality-cer → crosses.html (cross-dim image quality × CER) | |
| - pareto-chart → crosses.html (cross-dim cost/quality interactif) | |
| - marginal_cost_html → crosses.html (cost marginal par erreur évitée) | |
| Phase 22 : view_analyses.html a été supprimée — les anciens tests | |
| ``test_not_in_analyses`` ont été consolidés en | |
| ``TestPhase22AnalysesRemoved::test_view_analyses_file_deleted`` (plus | |
| fort : un retour silencieux du fichier serait détecté immédiatement). | |
| Anti-theater : chaque test échoue par sabotage ciblé (ID rename, | |
| template miss, etc.) — preuve documentée dans les docstrings de classe. | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| import pytest | |
| from tests._strip_helpers import strip_comments | |
| REPO_ROOT = Path(__file__).resolve().parents[2] | |
| TEMPLATES = REPO_ROOT / "picarones" / "reports" / "html" / "templates" | |
| DOCS_VIEW = TEMPLATES / "views" / "documents.html" | |
| CROSSES_VIEW = TEMPLATES / "views" / "crosses.html" | |
| def _read_jinja_active(path: Path) -> str: | |
| """Lit un template Jinja2/HTML sans commentaires Jinja ``{# #}`` | |
| ni HTML ``<!-- -->`` (defense contre sabotage par commentaire — | |
| cf. tests/_strip_helpers.py).""" | |
| return strip_comments(path.read_text(encoding="utf-8"), "jinja2") | |
| def _read_js_active(path: Path) -> str: | |
| """Lit un fichier JS sans commentaires ``//`` ni ``/* */``.""" | |
| return strip_comments(path.read_text(encoding="utf-8"), "js") | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 1. Documents — chart-cer-doc | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| class TestChartCerDocInDocuments: | |
| """``chart-cer-doc`` (CER par document, tous moteurs) doit vivre | |
| dans documents.html — chart doc-centric va avec la galerie. | |
| Anti-theater : retirer/renommer le canvas → ces tests échouent. | |
| """ | |
| def docs_src(self) -> str: | |
| return _read_jinja_active(DOCS_VIEW) | |
| def test_canvas_in_documents(self, docs_src: str) -> None: | |
| assert 'id="chart-cer-doc"' in docs_src | |
| def test_section_heading_present(self, docs_src: str) -> None: | |
| assert 'id="documents-cer-doc-title"' in docs_src | |
| assert 'data-i18n="h_cer_doc"' in docs_src | |
| def test_aria_label_preserved(self, docs_src: str) -> None: | |
| assert 'aria-label="CER par document"' in docs_src | |
| assert 'data-a11y-label="CER par document"' in docs_src | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 2. Crosses — chart-quality-cer | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| class TestChartQualityCerInCrosses: | |
| """``chart-quality-cer`` (qualité image × CER) est un scatter | |
| cross-dimensionnel — vit dans crosses.html. | |
| Anti-theater : renommer l'ID fait échouer le test. | |
| """ | |
| def crosses_src(self) -> str: | |
| return _read_jinja_active(CROSSES_VIEW) | |
| def test_canvas_in_crosses(self, crosses_src: str) -> None: | |
| assert 'id="chart-quality-cer"' in crosses_src | |
| def test_section_heading_present(self, crosses_src: str) -> None: | |
| assert 'id="crosses-quality-cer-title"' in crosses_src | |
| assert 'data-i18n="h_quality_cer"' in crosses_src | |
| def test_note_present(self, crosses_src: str) -> None: | |
| assert 'data-i18n="quality_cer_note"' in crosses_src | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 3. Crosses — pareto-chart (canvas + toolbar + assumptions) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| class TestParetoChartInCrosses: | |
| """Le Pareto avec toolbar 3 axes (cost/speed/co2) + détails | |
| hypothèses est entièrement dans crosses.html. | |
| Anti-theater : retirer un toggle ou la liste d'hypothèses fait | |
| échouer le test correspondant. | |
| """ | |
| def crosses_src(self) -> str: | |
| return _read_jinja_active(CROSSES_VIEW) | |
| def test_canvas_in_crosses(self, crosses_src: str) -> None: | |
| assert 'id="pareto-chart"' in crosses_src | |
| def test_toolbar_present(self, crosses_src: str) -> None: | |
| assert 'class="pareto-toolbar"' in crosses_src | |
| assert 'role="group"' in crosses_src | |
| def test_each_axis_toggle_present(self, crosses_src: str, axis: str) -> None: | |
| assert f'data-axis="{axis}"' in crosses_src | |
| assert f"setParetoAxis('{axis}')" in crosses_src | |
| def test_assumptions_list_container(self, crosses_src: str) -> None: | |
| assert 'id="pareto-assumptions-list"' in crosses_src | |
| assert 'class="pareto-assumptions"' in crosses_src | |
| def test_method_note_present(self, crosses_src: str) -> None: | |
| assert 'id="pareto-method-note"' in crosses_src | |
| assert 'data-i18n="pareto_note"' in crosses_src | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 4. Crosses — marginal_cost_html | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| class TestMarginalCostInCrosses: | |
| """``marginal_cost_html`` mesure le coût marginal d'une erreur évitée | |
| entre paires de moteurs — cross-dimensionnel cost × quality. | |
| Anti-theater : déplacer ailleurs ou oublier le wiring → tests échouent. | |
| """ | |
| def test_wired_in_crosses(self) -> None: | |
| crosses_src = _read_jinja_active(CROSSES_VIEW) | |
| assert "{% if marginal_cost_html %}" in crosses_src, ( | |
| "marginal_cost_html doit être câblé conditionnellement dans crosses.html" | |
| ) | |
| def test_not_in_engines_diagnostics(self) -> None: | |
| """Anti-confusion : marginal_cost n'est PAS dans diagnostics — | |
| il appartient à Crosses (cross-dimensional), pas à 'où ça plante'.""" | |
| diag_src = _read_jinja_active(TEMPLATES / "views" / "engines_diagnostics.html") | |
| assert "{% if marginal_cost_html %}" not in diag_src | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 5. Phase 21D — Cleanup : JS corr-matrix interactif supprimé | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| class TestPhase21DCleanup: | |
| """Phase 21D — retrait du code JS dupliqué : | |
| La matrice de corrélation JS interactive (avec sélecteur d'engine) | |
| dupliquait la fonctionnalité du renderer Python statique | |
| ``engines_correlation_matrix_html`` déjà câblé en Engines/Diagnostics. | |
| Elle est entièrement retirée : | |
| - DOM ``corr-matrix-container`` + ``corr-engine-select`` retirés | |
| - Fonctions JS ``initCorrelationMatrix`` + ``renderCorrelationMatrix`` | |
| + ``corrColor`` (qui hardcodait des hex hors palette) retirées | |
| - Clé i18n orphan ``corr_engine_label`` retirée des deux locales. | |
| Anti-theater : restaurer l'une de ces 3 surfaces fait échouer un | |
| test correspondant. | |
| """ | |
| def app_js(self) -> str: | |
| return _read_js_active(TEMPLATES / "_app.js") | |
| def test_init_correlation_matrix_removed(self, app_js: str) -> None: | |
| assert "function initCorrelationMatrix" not in app_js, ( | |
| "initCorrelationMatrix doit être supprimée (Phase 21D — dead code)" | |
| ) | |
| def test_render_correlation_matrix_removed(self, app_js: str) -> None: | |
| assert "function renderCorrelationMatrix" not in app_js, ( | |
| "renderCorrelationMatrix doit être supprimée (Phase 21D — dead code)" | |
| ) | |
| def test_corr_color_inline_helper_removed(self, app_js: str) -> None: | |
| """``corrColor`` hardcodait 10 hex hors palette XerOCR. Sa | |
| suppression évite la dette CSS-in-JS qui aurait fallu migrer | |
| séparément.""" | |
| assert "function corrColor" not in app_js | |
| def test_build_charts_no_longer_calls_init_correlation(self, app_js: str) -> None: | |
| """``initCorrelationMatrix`` ne doit plus être dans la liste | |
| ``fns`` de ``buildCharts()``.""" | |
| # On cherche la signature exacte dans le tableau | |
| assert "initCorrelationMatrix," not in app_js | |
| assert "initCorrelationMatrix\n" not in app_js | |
| def test_corr_dom_removed_from_app_js(self, app_js: str) -> None: | |
| """Les références JS aux IDs DOM corr-* doivent avoir disparu | |
| (la suppression du fichier ``view_analyses.html`` en Phase 22 | |
| rend le test ``not in analyses_src`` caduc — on vérifie | |
| désormais l'absence des handlers JS qui ciblaient ce DOM).""" | |
| assert "'corr-engine-select'" not in app_js | |
| assert "'corr-matrix-container'" not in app_js | |
| def test_orphan_i18n_key_removed(self) -> None: | |
| import json | |
| fr = json.loads((TEMPLATES.parent.parent / "i18n" / "fr.json").read_text(encoding="utf-8")) | |
| en = json.loads((TEMPLATES.parent.parent / "i18n" / "en.json").read_text(encoding="utf-8")) | |
| assert "corr_engine_label" not in fr | |
| assert "corr_engine_label" not in en | |
| def test_analyses_no_longer_in_views_with_charts(self) -> None: | |
| """``_routing.js`` ne déclenche plus ``buildCharts()`` à | |
| l'activation de ``#analyses`` (qui n'a plus aucun canvas).""" | |
| routing_src = _read_js_active(TEMPLATES / "_routing.js") | |
| # Cherche le tableau VIEWS_WITH_CHARTS et vérifie qu'analyses n'y est plus | |
| import re | |
| m = re.search(r"VIEWS_WITH_CHARTS\s*=\s*\[([^\]]+)\]", routing_src) | |
| assert m is not None, "VIEWS_WITH_CHARTS array introuvable dans _routing.js" | |
| assert "'analyses'" not in m.group(1), ( | |
| "analyses doit être retiré de VIEWS_WITH_CHARTS (Phase 21D)" | |
| ) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 6. Phase 22 — Suppression de l'onglet Analyses | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| class TestPhase22AnalysesRemoved: | |
| """Phase 22 — l'onglet Analyses a été supprimé après le dispatch | |
| complet Phase 21. Le fichier ``view_analyses.html`` n'existe plus, | |
| l'entrée nav est retirée, ``analyses`` n'est plus dans ``XER_VIEWS``, | |
| et les deeplinks ``#analyses`` tombent sur le fallback ``overview``. | |
| Anti-theater : restaurer une seule de ces surfaces (fichier, tab, | |
| array entry) fait échouer un test correspondant. | |
| """ | |
| def test_view_analyses_file_deleted(self) -> None: | |
| """Source de vérité : le fichier ``view_analyses.html`` n'existe plus.""" | |
| assert not (TEMPLATES / "view_analyses.html").exists(), ( | |
| "view_analyses.html ne doit plus exister (Phase 22)" | |
| ) | |
| def test_base_template_no_longer_includes_view_analyses(self) -> None: | |
| base_src = _read_jinja_active(TEMPLATES / "base.html.j2") | |
| assert "{% include 'view_analyses.html' %}" not in base_src | |
| def test_header_no_longer_has_analyses_tab_button(self) -> None: | |
| header_src = _read_jinja_active(TEMPLATES / "_header.html") | |
| assert 'id="tab-analyses"' not in header_src | |
| assert 'data-tab-target="analyses"' not in header_src | |
| assert 'data-i18n="tab_analyses"' not in header_src | |
| def test_routing_xer_views_no_longer_contains_analyses(self) -> None: | |
| routing_src = _read_js_active(TEMPLATES / "_routing.js") | |
| import re | |
| m = re.search(r"const\s+XER_VIEWS\s*=\s*\[([^\]]+)\]", routing_src) | |
| assert m is not None, "XER_VIEWS array introuvable dans _routing.js" | |
| assert "'analyses'" not in m.group(1), ( | |
| "analyses doit être retiré de XER_VIEWS (Phase 22)" | |
| ) | |
| def test_app_js_tabmap_no_longer_contains_analyses(self) -> None: | |
| app_js = _read_js_active(TEMPLATES / "_app.js") | |
| # On cherche le tabMap legacy de _switchView et vérifie l'absence | |
| # de la clé analyses (couvre la regex literal {...analyses:'analyses'...}) | |
| assert "analyses:'analyses'" not in app_js | |
| assert 'analyses:"analyses"' not in app_js | |
| def test_app_js_no_special_case_for_analyses_buildCharts(self) -> None: | |
| """Le ``if (name === 'analyses') buildCharts()`` du _switchView | |
| legacy a été retiré — buildCharts est désormais déclenché par | |
| ``_routing.js`` sur l'activation des vues à charts.""" | |
| app_js = _read_js_active(TEMPLATES / "_app.js") | |
| assert "name === 'analyses'" not in app_js | |
| assert 'name === "analyses"' not in app_js | |
| def test_orphan_tab_analyses_i18n_key_removed(self) -> None: | |
| import json | |
| fr = json.loads((TEMPLATES.parent.parent / "i18n" / "fr.json").read_text(encoding="utf-8")) | |
| en = json.loads((TEMPLATES.parent.parent / "i18n" / "en.json").read_text(encoding="utf-8")) | |
| assert "tab_analyses" not in fr | |
| assert "tab_analyses" not in en | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 8. X4 — Pré-existants Étage 4 : null check + dead code characters | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| class TestX4SwitchViewNullSafety: | |
| """``_switchView`` accessait ``document.getElementById('view-' + name) | |
| .classList`` sans null check → crash sur hash invalide (ex: ``#foo``). | |
| Le null check avec fallback silencieux vers ``overview`` est obligatoire. | |
| Note : ``_switchView`` est décomposée en ``_resolveLegacyTarget`` | |
| (lookup) + orchestrateur (null check + clear + activate). | |
| L'invariant testé ici (null check précède la mutation DOM) est | |
| préservé par la composition. | |
| Anti-theater : retirer le ``if (!resolved)`` block ou inverser | |
| l'ordre (clear avant null check) → tests échouent. | |
| """ | |
| def app_js(self) -> str: | |
| return _read_js_active(TEMPLATES / "_app.js") | |
| def test_switch_view_has_null_check(self, app_js: str) -> None: | |
| """Le helper ``_resolveLegacyTarget`` doit contenir le lookup | |
| ``getElementById('view-' + name)`` ET ``_switchView`` doit avoir | |
| une garde sur son retour.""" | |
| import re | |
| # 1. Le lookup vit dans _resolveLegacyTarget (helper de | |
| # responsabilité unique). On le valide ici via le pattern | |
| # textuel exact pour rester compatible avec _strip_helpers. | |
| resolver = re.search( | |
| r"function _resolveLegacyTarget\([^)]*\)\s*\{(.+?)\n\}", | |
| app_js, | |
| re.DOTALL, | |
| ) | |
| assert resolver is not None, "Fonction _resolveLegacyTarget introuvable" | |
| resolver_body = resolver.group(1) | |
| assert re.search( | |
| r"const\s+view\s*=\s*document\.getElementById\('view-'\s*\+\s*name\)", | |
| resolver_body, | |
| ), ( | |
| "_resolveLegacyTarget doit stocker getElementById('view-' + name) " | |
| "dans une variable pour permettre le null check" | |
| ) | |
| # 2. _switchView doit avoir une garde sur le retour du resolver. | |
| sv = re.search( | |
| r"function _switchView\(name\)\s*\{(.+?)\n\}", | |
| app_js, | |
| re.DOTALL, | |
| ) | |
| assert sv is not None, "Fonction _switchView introuvable" | |
| sv_body = sv.group(1) | |
| assert re.search(r"if\s*\(\s*!\s*resolved\s*\)", sv_body), ( | |
| "_switchView doit gérer le cas où _resolveLegacyTarget " | |
| "retourne null (deeplink invalide)" | |
| ) | |
| def test_null_check_BEFORE_clearing_views(self, app_js: str) -> None: | |
| """Y3 (audit Bug A#6) : le null check doit s'exécuter AVANT | |
| toute mutation DOM (clear .view.active). Sinon, un hash | |
| invalide produit un flash de page blanche. | |
| Architecture M4 : le lookup est dans ``_resolveLegacyTarget``, | |
| le clear est dans ``_clearAllActiveStates``. L'orchestrateur | |
| ``_switchView`` doit appeler le resolver, vérifier null, | |
| fallback+return si null, AVANT d'appeler le clear. | |
| Anti-theater : inverser l'ordre dans l'orchestrateur fait | |
| échouer ce test. | |
| """ | |
| import re | |
| m = re.search( | |
| r"function _switchView\(name\)\s*\{(.+?)\n\}", | |
| app_js, | |
| re.DOTALL, | |
| ) | |
| assert m is not None | |
| body = m.group(1) | |
| idx_resolve = body.find("_resolveLegacyTarget(") | |
| idx_guard = body.find("if (!resolved)") | |
| idx_clear = body.find("_clearAllActiveStates(") | |
| assert idx_resolve >= 0, "_resolveLegacyTarget(name) introuvable dans _switchView" | |
| assert idx_guard >= 0, "Garde if (!resolved) introuvable" | |
| assert idx_clear >= 0, "_clearAllActiveStates() introuvable" | |
| assert idx_resolve < idx_guard < idx_clear, ( | |
| "Y3 (Bug A#6) : ordre attendu dans _switchView : " | |
| "_resolveLegacyTarget → if (!resolved) → _clearAllActiveStates. " | |
| "Sinon flash de page blanche sur deeplink invalide." | |
| ) | |
| class TestX4CharactersDeadCodeRemoved: | |
| """Toutes les fonctions JS résiduelles de l'ancienne ``view-characters`` | |
| (supprimée à l'Étage 4) doivent être retirées : ``initCharView``, | |
| ``renderCharView``, ``renderConfusionHeatmap``, ``renderLigatureDetail``, | |
| ``renderTaxonomyDetail``, ``showConfusionExamples``, et le let | |
| ``charViewBuilt``. L'entrée ``characters:'caract'`` du tabMap aussi. | |
| Anti-theater : réintroduire une de ces fonctions → test échoue. | |
| """ | |
| def app_js(self) -> str: | |
| return _read_js_active(TEMPLATES / "_app.js") | |
| def test_dead_function_removed(self, app_js: str, dead_symbol: str) -> None: | |
| assert dead_symbol not in app_js, ( | |
| f"Symbole dead code Étage 4 doit être retiré : {dead_symbol!r}" | |
| ) | |
| def test_tabmap_no_characters_entry(self, app_js: str) -> None: | |
| """L'entrée ``characters:'caract'`` du tabMap ciblait l'ancienne | |
| view-characters supprimée à l'Étage 4 — orphan.""" | |
| assert "characters:'caract'" not in app_js | |
| assert 'characters:"caract"' not in app_js | |
| def test_switch_view_no_characters_branch(self, app_js: str) -> None: | |
| """Le ``if (name === 'characters' && !charViewBuilt) initCharView();`` | |
| de _switchView est dead (view-characters n'existe plus).""" | |
| assert "name === 'characters'" not in app_js | |
| assert 'name === "characters"' not in app_js | |
| assert "!charViewBuilt" not in app_js | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 7. Phase 23 — view_results_html intégré en <details> dans Diagnostics | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| class TestPhase23ViewResultsInDetails: | |
| """Phase 23 — ``view_results_html`` (projections EvaluationView : | |
| TextView, AltoView, etc.) intégré en ``<details>`` repliable dans | |
| ``engines_diagnostics.html`` au lieu de l'ancienne place dans | |
| ``view_analyses.html``. | |
| Le contenu est rendu mais replié par défaut (n'alourdit pas le scroll). | |
| Anti-theater : retirer le ``<details>`` ou le wiring conditionnel | |
| fait échouer un test. Si view_results_html devient orphan dans | |
| le registry, ``test_no_orphan_section_in_registry`` (autre fichier) | |
| le détecte aussi. | |
| """ | |
| def diag_src(self) -> str: | |
| return _read_jinja_active(TEMPLATES / "views" / "engines_diagnostics.html") | |
| def test_view_results_conditional_wired(self, diag_src: str) -> None: | |
| assert ( | |
| "{% if view_results_html %}" in diag_src | |
| or "{%- if view_results_html %}" in diag_src | |
| ), "view_results_html doit être câblé conditionnellement dans engines_diagnostics.html" | |
| def test_view_results_in_details_block(self, diag_src: str) -> None: | |
| """Le rendu doit être enveloppé dans ``<details>`` (replié par | |
| défaut) avec une classe identifiable pour le futur restyle.""" | |
| assert "<details class=\"card xer-chart-card view-results-details\"" in diag_src | |
| assert "</details>" in diag_src | |
| def test_summary_uses_i18n_key(self, diag_src: str) -> None: | |
| assert 'data-i18n="view_results_details_summary"' in diag_src | |
| def test_view_results_html_referenced_inside_details(self, diag_src: str) -> None: | |
| """``{{ view_results_html | safe }}`` doit être à l'intérieur | |
| du bloc ``<details>``, pas à l'extérieur.""" | |
| # On découpe sur <details> et </details> et vérifie que le | |
| # placeholder Jinja2 est dans l'un des blocs <details>. | |
| import re | |
| details_blocks = re.findall( | |
| r"<details[^>]*view-results-details[^>]*>(.*?)</details>", | |
| diag_src, | |
| re.DOTALL, | |
| ) | |
| assert details_blocks, "Bloc <details class=\"...view-results-details\"> introuvable" | |
| assert any( | |
| "{{ view_results_html | safe }}" in block for block in details_blocks | |
| ), "{{ view_results_html | safe }} doit vivre à l'intérieur du <details>" | |
| def test_view_results_i18n_keys_present(self) -> None: | |
| import json | |
| fr = json.loads((TEMPLATES.parent.parent / "i18n" / "fr.json").read_text(encoding="utf-8")) | |
| en = json.loads((TEMPLATES.parent.parent / "i18n" / "en.json").read_text(encoding="utf-8")) | |
| assert "view_results_details_summary" in fr | |
| assert "view_results_details_summary" in en | |
| def test_view_results_renders_inside_details(self, tmp_path) -> None: | |
| """End-to-end : un benchmark avec ``view_results`` doit produire | |
| un rapport HTML qui contient les noms des vues EvaluationView | |
| à l'intérieur du ``<details class="...view-results-details">`` | |
| d'engines_diagnostics (et plus dans view_analyses, supprimé).""" | |
| from picarones.evaluation.benchmark_result import ( | |
| BenchmarkResult, EngineReport, DocumentResult, | |
| ) | |
| from picarones.evaluation.metric_result import MetricsResult | |
| from picarones.reports.html.generator import ReportGenerator | |
| engine = EngineReport( | |
| engine_name="tesseract", | |
| engine_version="5.x", | |
| engine_config={}, | |
| document_results=[ | |
| DocumentResult( | |
| doc_id="doc1", | |
| image_path="/tmp/doc1.png", | |
| ground_truth="Bonjour", | |
| hypothesis="Bonjour", | |
| metrics=MetricsResult( | |
| cer=0.0, cer_nfc=0.0, cer_caseless=0.0, | |
| wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0, | |
| reference_length=7, hypothesis_length=7, | |
| ), | |
| duration_seconds=0.1, | |
| ), | |
| ], | |
| aggregated_metrics={}, | |
| ) | |
| bench = BenchmarkResult( | |
| corpus_name="test_corpus", | |
| corpus_source=None, | |
| document_count=1, | |
| engine_reports=[engine], | |
| view_results={ | |
| "TextView": { | |
| "tesseract": {"doc1": {"cer": 0.0, "wer": 0.0}}, | |
| }, | |
| "AltoView": { | |
| "tesseract": {"doc1": {"cer": 0.05, "wer": 0.10}}, | |
| }, | |
| }, | |
| ) | |
| out = tmp_path / "report.html" | |
| ReportGenerator(bench).generate(out) | |
| html = out.read_text(encoding="utf-8") | |
| assert "TextView" in html, "view_results section TextView absente du HTML" | |
| assert "AltoView" in html, "view_results section AltoView absente du HTML" | |
| # Le contenu doit être DANS le <details> (post-Phase 23) | |
| details_idx = html.find('class="card xer-chart-card view-results-details') | |
| assert details_idx > 0, ( | |
| "<details class=\"...view-results-details\"> introuvable dans le HTML rendu" | |
| ) | |
| details_close = html.find("</details>", details_idx) | |
| details_block = html[details_idx:details_close] | |
| assert "TextView" in details_block, "TextView doit être rendu DANS le <details>" | |
| assert "AltoView" in details_block, "AltoView doit être rendu DANS le <details>" | |