Spaces:
Running
Running
File size: 10,434 Bytes
b914841 d109222 f53c0aa b914841 f53c0aa b914841 f53c0aa b914841 f53c0aa b914841 f53c0aa b914841 f53c0aa b914841 f53c0aa b914841 bec0d42 b914841 bec0d42 b914841 f53c0aa b914841 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 | """Tests Sprint 25 — refactor web frontend miroir du Sprint 17.
Sprint 17 a découpé le rapport HTML monolithique en 10 fichiers Jinja2.
Sprint 25 fait pareil pour la SPA web : l'ancien ``_HTML_TEMPLATE`` de
~1500 lignes string Python (3000+ lignes au total avec le JS) dans
``picarones/web/app.py`` est remplacé par :
picarones/web/templates/
├── base.html.j2
├── _ascii_banner.html
├── _header_nav.html
├── _view_benchmark.html
├── _view_reports.html
├── _view_engines.html
├── _view_import.html
└── _modals.html
picarones/web/static/
├── retro.css (existait déjà)
└── web-app.js (extrait du <script> inline)
Ce module vérifie :
1. Les fichiers attendus existent et ne sont pas vides.
2. ``_render_index`` est déterministe (Sprint 17 imposait la même règle).
3. Les éléments structurants critiques sont présents (vues, nav, modals).
4. Pas de balise dupliquée (ex. deux ``id="view-benchmark"``).
5. Pas de bloc ``<script>...</script>`` inline avec du code dans la page
rendue — uniquement des ``<script src="...">``.
6. ``picarones/web/app.py`` est passé sous la barre des 2000 lignes
(était 3163 ; cible Sprint 25 long terme : ≤ 400, mais on commence
par mesurer la victoire de l'extraction des templates).
7. Le rendu HTML reflète bien le cookie de langue (FR vs EN).
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
ROOT = Path(__file__).parent.parent.parent
WEB_DIR = ROOT / "picarones" / "interfaces" / "web" / "_legacy"
TEMPLATES_DIR = WEB_DIR / "templates"
STATIC_DIR = WEB_DIR / "static"
APP_PY = WEB_DIR / "app.py"
# ---------------------------------------------------------------------------
# 1. Présence et taille des fichiers extraits
# ---------------------------------------------------------------------------
EXPECTED_TEMPLATES = [
"base.html.j2",
"_ascii_banner.html",
"_header_nav.html",
"_view_benchmark.html",
"_view_reports.html",
"_view_engines.html",
"_view_import.html",
"_modals.html",
]
class TestTemplateFilesExist:
@pytest.mark.parametrize("name", EXPECTED_TEMPLATES)
def test_template_present_and_non_empty(self, name):
path = TEMPLATES_DIR / name
assert path.is_file(), f"Template manquant : {path}"
assert path.stat().st_size > 30, f"Template suspect (vide ?) : {path}"
def test_web_app_js_extracted(self):
path = STATIC_DIR / "web-app.js"
assert path.is_file(), "web-app.js doit être extrait dans static/"
# L'ancien <script> inline pesait ~1131 lignes
line_count = sum(1 for _ in path.read_text(encoding="utf-8").splitlines())
assert line_count > 500, (
f"web-app.js semble trop court ({line_count} lignes) — "
"extraction incomplète ?"
)
def test_retro_css_still_present(self):
# Sanity : on n'a pas accidentellement supprimé le CSS principal.
assert (STATIC_DIR / "retro.css").is_file()
# ---------------------------------------------------------------------------
# 2. Déterminisme du rendu
# ---------------------------------------------------------------------------
class TestRenderIndexDeterminism:
def test_same_inputs_same_output(self):
from picarones.interfaces.web._legacy.routers.home import render_index as _render_index
a = _render_index("fr")
b = _render_index("fr")
assert a == b, "Rendu non déterministe sur lang=fr"
def test_lang_change_changes_output(self):
from picarones.interfaces.web._legacy.routers.home import render_index as _render_index
fr = _render_index("fr")
en = _render_index("en")
assert fr != en, "Le rendu doit dépendre de la langue"
assert 'name="picarones-lang" content="fr"' in fr
assert 'name="picarones-lang" content="en"' in en
def test_html_lang_attribute_set(self):
from picarones.interfaces.web._legacy.routers.home import render_index as _render_index
assert '<html lang="en">' in _render_index("en")
assert '<html lang="fr">' in _render_index("fr")
# ---------------------------------------------------------------------------
# 3. Éléments structurants présents
# ---------------------------------------------------------------------------
class TestStructuralElementsPresent:
@pytest.fixture(scope="class")
def html(self) -> str:
from picarones.interfaces.web._legacy.routers.home import render_index as _render_index
return _render_index("fr")
@pytest.mark.parametrize("view_id", [
"view-benchmark",
"view-reports",
"view-engines",
"view-import",
])
def test_each_view_present(self, html, view_id):
assert f'id="{view_id}"' in html, (
f"Vue '{view_id}' manquante dans la page rendue"
)
def test_nav_buttons_present(self, html):
for label in ("nav_benchmark", "nav_reports", "nav_engines", "nav_import"):
assert f'data-i18n="{label}"' in html
def test_import_modal_present(self, html):
assert 'id="import-modal"' in html
def test_external_js_referenced(self, html):
# Le bundle JS doit être chargé via <script src="...">
assert re.search(r'<script\s+src="/static/web-app\.js', html), (
"La balise <script src='/static/web-app.js'> doit être présente"
)
def test_retro_css_referenced(self, html):
assert re.search(r'<link\s+rel="stylesheet"\s+href="/static/retro\.css', html)
# ---------------------------------------------------------------------------
# 4. Pas de balise dupliquée (garde-fou contre {% include %} en double)
# ---------------------------------------------------------------------------
_ID_RE = re.compile(r'\sid="([a-zA-Z0-9_\-]+)"')
class TestNoDuplicateIds:
def test_no_duplicate_ids_in_rendered_page(self):
from picarones.interfaces.web._legacy.routers.home import render_index as _render_index
html = _render_index("fr")
ids = _ID_RE.findall(html)
# Les `id` HTML doivent être uniques (W3C). Une duplication signe un
# double-include accidentel ou un copier-coller raté.
seen: dict[str, int] = {}
for i in ids:
seen[i] = seen.get(i, 0) + 1
dupes = {k: v for k, v in seen.items() if v > 1}
assert not dupes, f"IDs dupliqués dans la SPA rendue : {dupes}"
# ---------------------------------------------------------------------------
# 5. Pas de gros bloc <script>...</script> inline avec du code
# ---------------------------------------------------------------------------
class TestNoInlineScriptCode:
"""Sprint 25 a extrait tout le JS dans /static/web-app.js. La page
rendue ne doit plus contenir un bloc ``<script>...</script>`` qui
embarque du code (les ``<script src="..."></script>`` restent
autorisés)."""
def test_no_large_inline_script_block(self):
from picarones.interfaces.web._legacy.routers.home import render_index as _render_index
html = _render_index("fr")
# Capture tout le contenu entre <script> sans src= et </script>.
pattern = re.compile(
r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>",
re.DOTALL,
)
for body in pattern.findall(html):
# Quelques bytes blancs sont tolérés (ex. <script>\n</script>)
stripped = body.strip()
assert len(stripped) < 200, (
"Un bloc <script> inline contient encore du code "
f"({len(stripped)} caractères). Doit vivre dans /static/web-app.js."
)
# ---------------------------------------------------------------------------
# 6. Mesure du dégonflement de app.py
# ---------------------------------------------------------------------------
class TestAppPyShrunk:
def test_app_py_below_target_threshold(self):
# Sprint 25 a fait passer app.py de 3163 à 1690 lignes en
# extrayant ``_HTML_TEMPLATE`` (HTML + CSS + 1131 lignes de JS).
# Sprint 28 a ajouté ~370 lignes de nouveaux endpoints
# (config/save, config/load, synthesis_preview, history/regressions).
# La cible long terme reste ≤ 400 lignes et sera atteinte au
# Sprint 31 quand on découpera en ``picarones/web/routes/``.
# En attendant, on borne à 2400 pour détecter une re-régression
# (ex. quelqu'un qui réintroduit un gros template inline).
n = sum(1 for _ in APP_PY.read_text(encoding="utf-8").splitlines())
assert n < 2400, (
f"web/app.py fait {n} lignes — sortie de la borne haute. "
"Le découpage en routes/ est-il toujours sur la roadmap ?"
)
def test_html_template_string_removed(self):
src = APP_PY.read_text(encoding="utf-8")
assert "_HTML_TEMPLATE = r" not in src, (
"Le monolithe _HTML_TEMPLATE doit être supprimé de app.py"
)
# ---------------------------------------------------------------------------
# 7. Smoke test bout-en-bout via TestClient
# ---------------------------------------------------------------------------
class TestEndpointStillServesPage:
@pytest.fixture
def client(self):
from picarones.interfaces.web._legacy.app import app
return TestClient(app)
def test_root_returns_200_and_html(self, client):
r = client.get("/")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
def test_root_respects_cookie_lang(self, client):
r = client.get("/", cookies={"picarones_lang": "en"})
assert 'content="en"' in r.text
r2 = client.get("/", cookies={"picarones_lang": "fr"})
assert 'content="fr"' in r2.text
def test_root_falls_back_on_unsupported_lang(self, client):
r = client.get("/", cookies={"picarones_lang": "ne-pas-exister"})
# Doit retomber sur fr (cf. ``_SUPPORTED_LANGS``)
assert 'content="fr"' in r.text
def test_static_js_served(self, client):
r = client.get("/static/web-app.js")
assert r.status_code == 200
assert r.headers["content-type"].startswith(("application/javascript", "text/javascript"))
|