feat(web): light/dark mode cu comutator persistat + anti-FOUC (PRD 5.3)
Tema light ca bloc [data-theme="light"] peste variabilele :root (dark nemodificat la octet). Comutator soare/luna in header pe toate paginile, default OS-aware (prefers-color-scheme, fallback dark), persistenta in localStorage doar la comutare explicita, script anti-FOUC in <head> pre-paint. Suprafetele de stare hardcodate convertite la color-mix in base.html + 7 fragmente _*.html (light lizibil, contrast WCAG AA). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
186
tests/test_tema.py
Normal file
186
tests/test_tema.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Teste US-001 + US-002 (PRD 5.3): Light/Dark mode comutator tema.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "tema.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _create_user(email: str = "tema@test.com", password: str = "parolasecreta"):
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, "Service Tema", active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta") -> None:
|
||||
resp = client.get("/login")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
|
||||
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m, "csrf_token negasit in /login"
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 303, f"Login esuat: {resp.status_code}"
|
||||
|
||||
|
||||
# ── US-001: Tema light ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_paleta_light_definita(client):
|
||||
"""HTML de la GET /login contine un selector [data-theme="light"] care redefineste
|
||||
cel putin --bg, --card, --ink, --muted, --line."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
assert '[data-theme="light"]' in html, 'Lipseste blocul [data-theme="light"] in HTML'
|
||||
|
||||
light_block = re.search(r'\[data-theme=["\']light["\']\]\s*\{([^}]+)\}', html, re.DOTALL)
|
||||
assert light_block, 'Nu am gasit blocul CSS [data-theme="light"] { ... }'
|
||||
block = light_block.group(1)
|
||||
for var in ("--bg", "--card", "--ink", "--muted", "--line"):
|
||||
assert var in block, f"Variabila {var} lipseste din blocul [data-theme=\"light\"]"
|
||||
|
||||
|
||||
def test_dark_ramane_default(client):
|
||||
""":root contine inca paleta dark exacta: --bg:#0f1115, --card:#181b22, --ink:#e6e9ef."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
assert "--bg:#0f1115" in html, "Paleta dark --bg:#0f1115 a fost modificata sau stearsa"
|
||||
assert "--card:#181b22" in html, "Paleta dark --card:#181b22 a fost modificata sau stearsa"
|
||||
assert "--ink:#e6e9ef" in html, "Paleta dark --ink:#e6e9ef a fost modificata sau stearsa"
|
||||
|
||||
|
||||
def test_suprafete_fara_fundal_hardcodat(client):
|
||||
"""<style> NU mai contine literalii hex dark-fix #241a1a, #201c0f, #16241c
|
||||
(banner eroare / banner warn / flash facute theme-aware)."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
|
||||
style_match = re.search(r'<style>(.*?)</style>', resp.text, re.DOTALL)
|
||||
assert style_match, "<style> negasit in HTML"
|
||||
style = style_match.group(1)
|
||||
|
||||
assert "#241a1a" not in style, "Fundalul hardcodat #241a1a (banner eroare) inca in <style>"
|
||||
assert "#201c0f" not in style, "Fundalul hardcodat #201c0f (banner warn) inca in <style>"
|
||||
assert "#16241c" not in style, "Fundalul hardcodat #16241c (flash) inca in <style>"
|
||||
|
||||
|
||||
# ── US-002: Comutator tema + anti-FOUC ────────────────────────────────────────
|
||||
|
||||
def test_script_antifouc_in_head_inainte_de_style(client):
|
||||
"""<head> contine un <script> care citeste localStorage (cheia 'theme') si seteaza
|
||||
data-theme pe document.documentElement, pozitionat INAINTE de <style>."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
head_match = re.search(r'<head>(.*?)</head>', html, re.DOTALL | re.IGNORECASE)
|
||||
assert head_match, "<head> negasit in HTML"
|
||||
head = head_match.group(1)
|
||||
|
||||
style_pos = head.find('<style>')
|
||||
assert style_pos >= 0, "<style> negasit in <head>"
|
||||
|
||||
head_before_style = head[:style_pos]
|
||||
assert 'localStorage' in head_before_style, \
|
||||
"Scriptul anti-FOUC (cu localStorage) trebuie sa fie in <head> INAINTE de <style>"
|
||||
assert 'theme' in head_before_style, \
|
||||
"Scriptul anti-FOUC trebuie sa citeasca cheia 'theme' din localStorage"
|
||||
assert ('data-theme' in head_before_style or 'dataset.theme' in head_before_style), \
|
||||
"Scriptul anti-FOUC trebuie sa seteze data-theme pe documentElement"
|
||||
|
||||
|
||||
def test_buton_toggle_in_header_cu_eticheta(client):
|
||||
"""<header> contine un <button> de comutare cu aria-label descriptiv (contine 'tema')."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
header_match = re.search(r'<header>(.*?)</header>', html, re.DOTALL | re.IGNORECASE)
|
||||
assert header_match, "<header> negasit in HTML"
|
||||
header = header_match.group(1)
|
||||
|
||||
labels = re.findall(r'<button[^>]*aria-label=["\']([^"\']+)["\']', header, re.IGNORECASE)
|
||||
assert labels, "<button> cu aria-label negasit in <header>"
|
||||
assert any('tema' in lbl.lower() for lbl in labels), \
|
||||
f"Niciun <button> in <header> cu aria-label care contine 'tema'. Gasit: {labels}"
|
||||
|
||||
|
||||
def test_toggle_pe_login_si_dashboard(client):
|
||||
"""Butonul toggle apare atat pe /login (neautentificat) cat si pe dashboard (autentificat)."""
|
||||
# Pe /login
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
header_match = re.search(r'<header>(.*?)</header>', resp.text, re.DOTALL | re.IGNORECASE)
|
||||
assert header_match, "<header> negasit pe /login"
|
||||
assert re.search(
|
||||
r'<button[^>]*aria-label=["\'][^"\']*tema[^"\']*["\']',
|
||||
header_match.group(1),
|
||||
re.IGNORECASE,
|
||||
), "Butonul toggle lipseste pe /login"
|
||||
|
||||
# Pe dashboard (autentificat)
|
||||
_create_user("tema_dash@test.com", "parolasecreta")
|
||||
_login(client, "tema_dash@test.com", "parolasecreta")
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
header_match = re.search(r'<header>(.*?)</header>', resp.text, re.DOTALL | re.IGNORECASE)
|
||||
assert header_match, "<header> negasit pe dashboard"
|
||||
assert re.search(
|
||||
r'<button[^>]*aria-label=["\'][^"\']*tema[^"\']*["\']',
|
||||
header_match.group(1),
|
||||
re.IGNORECASE,
|
||||
), "Butonul toggle lipseste pe dashboard"
|
||||
|
||||
|
||||
# ── US-003: Fragmente HTMX fara fundal hardcodat ──────────────────────────────
|
||||
|
||||
def test_fragmente_fara_fundal_hardcodat():
|
||||
"""Niciun fisier _*.html din app/web/templates/ nu contine literalii hex dark-fix
|
||||
#241a1a, #201c0f, #16241c (suprafete banner eroare / warn / flash)."""
|
||||
templates_dir = Path(__file__).parent.parent / "app" / "web" / "templates"
|
||||
fragmente = sorted(templates_dir.glob("_*.html"))
|
||||
assert fragmente, f"Nu am gasit fragmente _*.html in {templates_dir}"
|
||||
|
||||
vinovate = []
|
||||
for f in fragmente:
|
||||
continut = f.read_text(encoding="utf-8")
|
||||
for literal in ("#241a1a", "#201c0f", "#16241c"):
|
||||
if literal in continut:
|
||||
vinovate.append(f"{f.name}: {literal}")
|
||||
|
||||
assert not vinovate, (
|
||||
"Fragmente cu fundal hardcodat dark (nu adapteaza la tema light):\n"
|
||||
+ "\n".join(vinovate)
|
||||
)
|
||||
Reference in New Issue
Block a user