168 lines
7.1 KiB
Python
168 lines
7.1 KiB
Python
"""Tests — thème de la page /recherche ObsiGate (défaut 4).
|
|
|
|
Mécanisme audité (ne pas réinventer) : le thème ObsiGate n'est PAS une
|
|
préférence serveur. La SPA le lit dans localStorage au démarrage —
|
|
``frontend/js/ui.js`` ``initTheme`` (mode seul, pour la première
|
|
peinture) puis ``frontend/js/sync.js`` ``init`` -> ``themes.js``
|
|
``initThemes`` (clé + mode, palette complète en variables inline sur
|
|
``:root``). Clés : ``obsigate-theme`` (clé, ex. ``'spark'``,
|
|
``'dracula'``, ou historiquement un mode) et ``obsigate-theme-mode``
|
|
(``'dark'`` | ``'light'`` | ``'high-contrast'`` | ``'sepia'``).
|
|
|
|
La page /recherche, servie hors SPA avec un ``data-theme="dark"`` en
|
|
dur et aucun script de thème, restait donc sombre quel que soit le
|
|
réglage. Correctif verrouillé ici, en deux étapes dans
|
|
``frontend/recherche.html`` (aucun fichier partagé touché :
|
|
``js/recherche.js`` reste identique sur les 4 hôtes, ``draco-barre.css``
|
|
reste le fichier commun vendangé, ``backend/main.py`` et sa CSP
|
|
strictes sont inchangés) :
|
|
|
|
- étape 1 : script synchrone en tête, avant les feuilles — pose
|
|
``data-theme`` = mode persisté avant la première peinture ;
|
|
- étape 2 : module différé — ``initThemes()`` de ``themes.js`` pose la
|
|
palette complète (16 thèmes, modes générés, thèmes personnalisés).
|
|
|
|
``frontend/recherche.css`` décline la barre pour les modes que
|
|
``draco-barre.css`` ne connaît pas et branche bordures/accents sur
|
|
``var(--border)`` / ``var(--accent)`` (anciennes valeurs en repli).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
FRONTEND = ROOT / "frontend"
|
|
MODES = ["dark", "light", "high-contrast", "sepia"]
|
|
NONCE = "TESTNONCE1234567890"
|
|
|
|
|
|
def _html() -> str:
|
|
return (FRONTEND / "recherche.html").read_text(encoding="utf-8")
|
|
|
|
|
|
def _css() -> str:
|
|
return (FRONTEND / "recherche.css").read_text(encoding="utf-8")
|
|
|
|
|
|
def _inline_scripts(html: str) -> list[str]:
|
|
"""Contenu des scripts inline exécutables (sans src)."""
|
|
return re.findall(
|
|
r"<script(?: type=\"module\")?>(.*?)</script>", html, re.DOTALL
|
|
)
|
|
|
|
|
|
def test_etape1_avant_les_feuilles_et_mode_valide():
|
|
html = _html()
|
|
scripts = _inline_scripts(html)
|
|
assert scripts, "aucun script inline dans recherche.html"
|
|
etape1 = scripts[0]
|
|
assert "obsigate-theme-mode" in etape1
|
|
assert "obsigate-theme" in etape1
|
|
assert "setAttribute('data-theme'" in etape1 or 'setAttribute("data-theme"' in etape1
|
|
for mode in MODES:
|
|
assert f"'{mode}'" in etape1, f"mode {mode} absent de l'étape 1"
|
|
# Le script étape 1 précède la première feuille de style.
|
|
assert html.index("<script>") < html.index('<link rel="stylesheet"')
|
|
# Repli sans JS : sombre, le défaut SPA.
|
|
assert '<html lang="fr" data-theme="dark">' in html
|
|
|
|
|
|
def test_etape1_ignore_les_cles_de_theme():
|
|
"""Une clé ('dracula', 'spark', …) ne doit jamais devenir un mode."""
|
|
etape1 = _inline_scripts(_html())[0]
|
|
# La validation passe par une liste fermée de modes, jamais par la
|
|
# valeur brute de 'obsigate-theme'.
|
|
assert "MODES.indexOf(mode)" in etape1
|
|
assert "MODES.indexOf(legacy)" in etape1
|
|
assert "'dark'" in etape1 # repli final
|
|
|
|
|
|
def test_etape2_importe_themes_js_avec_garde_historique():
|
|
html = _html()
|
|
modules = re.findall(
|
|
r'<script type="module">(.*?)</script>', html, re.DOTALL
|
|
)
|
|
assert len(modules) == 1, "exactement un module inline attendu"
|
|
etape2 = modules[0]
|
|
assert "import('/static/js/themes.js')" in etape2
|
|
assert "initThemes" in etape2
|
|
# Garde historique : 'obsigate-theme' valant encore un mode sans
|
|
# 'obsigate-theme-mode' -> on garde le mode de l'étape 1.
|
|
assert "savedMode === null" in etape2
|
|
assert "saved === 'dark'" in etape2 and "saved === 'light'" in etape2
|
|
# Échec de chargement silencieux : la page garde le mode étape 1.
|
|
assert ".catch(function () {})" in etape2
|
|
# Le module est après recherche.js (différé : il tourne avant
|
|
# l'init DOMContentLoaded de recherche.js, sans le modifier).
|
|
assert html.index("/static/js/recherche.js") < html.index(
|
|
'<script type="module">'
|
|
)
|
|
# themes.js existe bien à l'URL importée et exporte initThemes.
|
|
themes = (FRONTEND / "js" / "themes.js").read_text(encoding="utf-8")
|
|
assert "initThemes" in themes
|
|
assert "AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']" in themes
|
|
|
|
|
|
def test_nonce_csp_pour_les_deux_etapes():
|
|
"""Comme /recherche injecte window.DRACO_URLS avant </head>, les
|
|
trois scripts inline doivent tous recevoir le nonce frais."""
|
|
from backend.csp import inject_csp_nonce
|
|
|
|
html = _html().replace(
|
|
"</head>",
|
|
'<script>window.DRACO_URLS = {};</script></head>',
|
|
1,
|
|
)
|
|
out = inject_csp_nonce(html, NONCE)
|
|
assert not re.findall(r"<script>", out), "script classique sans nonce"
|
|
assert not re.findall(
|
|
r'<script type="module">', out
|
|
), "module inline sans nonce"
|
|
assert out.count(f'nonce="{NONCE}"') == 3
|
|
|
|
|
|
def test_barre_decline_tous_les_modes():
|
|
"""draco-barre.css ne connaît que [data-theme="dark"] : les autres
|
|
modes sont déclinés dans recherche.css (fichier de la page), sans
|
|
toucher au fichier commun vendangé."""
|
|
css = _css()
|
|
barre = (FRONTEND / "draco-barre.css").read_text(encoding="utf-8")
|
|
assert '[data-theme="light"] .draco-bar' not in barre
|
|
for mode in ("light", "high-contrast", "sepia"):
|
|
assert f'[data-theme="{mode}"] .draco-bar' in css, mode
|
|
# Valeurs reprises des générateurs themes.js (_genHighContrast /
|
|
# _genSepia) : fond noir / sépia, encre blanche / brune.
|
|
assert "--surface: #0a0a0a" in css and "--ink: #ffffff" in css
|
|
assert "--surface: #eee3c8" in css and "--ink: #3d2e1a" in css
|
|
assert "color-scheme: dark" in css and "color-scheme: light" in css
|
|
|
|
|
|
def test_champs_onglets_resultats_suivent_accent_et_bordure():
|
|
css = _css()
|
|
assert "border: 1px solid var(--border, #888)" in css
|
|
assert "background: var(--accent, #3b82f6)" in css
|
|
assert "border-bottom-color: var(--accent, #3b82f6)" in css
|
|
assert "outline: 2px solid var(--accent, #3b82f6)" in css
|
|
|
|
|
|
def test_invariants_page_intacts():
|
|
"""Barre (5e onglet Console Spark), onglets, focus, 'Tout voir',
|
|
repli typographique : rien ne régresse."""
|
|
html = _html()
|
|
for app in ("obsigate", "flowdeck", "tiktak", "kanbante", "agent"):
|
|
assert f'data-app="{app}"' in html, app
|
|
assert "{{URL_AGENT}}" in html
|
|
for tab in ("all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"):
|
|
assert f'data-rp-tab="{tab}"' in html, tab
|
|
assert "Tout voir" in html
|
|
assert 'id="rp-q"' in html and "autofocus" in html
|
|
assert '<script src="/static/js/recherche.js"></script>' in html
|
|
# Aucune police externe ajoutée : le repli système imposé par la
|
|
# CSP stricte de /recherche (font-src 'self' data:) est inchangé.
|
|
assert "fonts.googleapis.com" not in html
|
|
assert "fonts.gstatic.com" not in html
|
|
# Ordre des feuilles inchangé (recherche.css après la barre).
|
|
assert html.index("draco-barre.css") < html.index("recherche.css")
|