"""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. """ @pytest.fixture(scope="class") 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. """ @pytest.fixture(scope="class") 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. """ @pytest.fixture(scope="class") 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 @pytest.mark.parametrize("axis", ["cost", "speed", "co2"]) 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. """ @pytest.fixture(scope="class") 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. """ @pytest.fixture(scope="class") 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. """ @pytest.fixture(scope="class") def app_js(self) -> str: return _read_js_active(TEMPLATES / "_app.js") @pytest.mark.parametrize("dead_symbol", [ "function initCharView", "function renderCharView", "function renderConfusionHeatmap", "function renderLigatureDetail", "function renderTaxonomyDetail", "function showConfusionExamples", "let charViewBuilt", ]) 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
dans Diagnostics # ───────────────────────────────────────────────────────────────────────────── class TestPhase23ViewResultsInDetails: """Phase 23 — ``view_results_html`` (projections EvaluationView : TextView, AltoView, etc.) intégré en ``
`` 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 ``
`` 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. """ @pytest.fixture(scope="class") 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 ``
`` (replié par défaut) avec une classe identifiable pour le futur restyle.""" assert "
" 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 ``
``, pas à l'extérieur.""" # On découpe sur
et
et vérifie que le # placeholder Jinja2 est dans l'un des blocs
. import re details_blocks = re.findall( r"]*view-results-details[^>]*>(.*?)
", diag_src, re.DOTALL, ) assert details_blocks, "Bloc
introuvable" assert any( "{{ view_results_html | safe }}" in block for block in details_blocks ), "{{ view_results_html | safe }} doit vivre à l'intérieur du
" 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 ``
`` 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
(post-Phase 23) details_idx = html.find('class="card xer-chart-card view-results-details') assert details_idx > 0, ( "
introuvable dans le HTML rendu" ) details_close = html.find("
", details_idx) details_block = html[details_idx:details_close] assert "TextView" in details_block, "TextView doit être rendu DANS le
" assert "AltoView" in details_block, "AltoView doit être rendu DANS le
"