Picarones / tests /reports /test_phase21_dispatch.py
Claude
test: dégraissage anti-sur-architecture (-1590 lignes)
aee7b24 unverified
Raw
History Blame Contribute Delete
27.2 kB
"""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 <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.
"""
@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 ``<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>"