feat(5.10): UX trimiteri (pill filtre, paginare, editare) + Mapari in meniu + branding ROMFAST
14 stories TDD prin echipa de workeri (lead orchestreaza, 3 teammates pe valuri cu fisiere disjuncte; routes.py + base.html serializate ca fisiere fierbinti). - US-001 fix filtrare data (_iso_date_prefix pe garda+comparatie, prinde timestamp cu ora) - US-002/007 operatie service distincta in payload_view + afisare in detaliu - US-003 pill-uri categorii (button/aria-pressed; needs_mapping --warn, needs_data/error --err); fara lista ID-uri/dropdown - US-004 paginare numerotata 25/pag (total ramificat SQL-COUNT vs fetch-all+slice, clamp page, poll pastreaza pagina) - US-005 VIN block-level sub nr - US-006/006b editare cod RAR + validare nomenclator + recalcul idempotency (needs_data/needs_mapping via /corecteaza, error via /repune) - US-008 card eroare 3-niveluri doar pe read-only + rezumat top-of-form - US-009 Mapari in meniu hamburger; scoatere tab-bar + role=tablist orfan - US-010/011 pagina Mapari consolidata + butoane icon SVG + dirty-state (fara kebab/emoji) - US-012/012b header centrat + logo ROMFAST (/static/romfast_logo.png) in header - US-013 paleta azur ROMFAST (#2E74D6/#1F66C9) + IBM Plex Sans/Mono self-host (woff2 reale) - US-014 selector tema ciclic Light/Dark/Petrol/Auto + anti-FOUC pe 4 stari Backend trimitere (worker/masina stari/idempotenta/mapping) + schema NEATINSE (UI/UX pur + 1 fix de filtrare). VERIFY context curat PASS; /code-review high: 1 finding material reparat (US-006b). Regresie 896 passed, 1 skipped, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,14 +50,16 @@ def _seed_submission(status: str = "sent", n: int = 1) -> None:
|
||||
|
||||
|
||||
def test_tab_bar_fara_trimiteri(client):
|
||||
"""Tab-bar-ul nu mai contine tab-ul 'Trimiteri' (coada); raman 4 tab-uri."""
|
||||
"""US-009: tab-bar eliminat; 'Coada' nu exista; Mapari/Cont/Nomenclator raman in meniu."""
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
html = r.text
|
||||
# "Coada" nu trebuie sa existe nici ca tab, nici ca link in meniu
|
||||
assert 'id="tab-coada"' not in html
|
||||
assert 'href="/?tab=coada"' not in html
|
||||
for label in ("Acasa", "Mapari", "Cont", "Nomenclator"):
|
||||
assert f">{label}" in html or f"{label}<" in html, f"lipseste tab {label}"
|
||||
# US-009: tab-bar eliminat; Mapari/Cont/Nomenclator sunt in meniul hamburger
|
||||
for label in ("Mapari", "Cont", "Nomenclator"):
|
||||
assert f">{label}" in html or f"{label}<" in html, f"lipseste intrarea {label} in meniu"
|
||||
|
||||
|
||||
def test_acasa_contine_sectiunea_trimiteri(client):
|
||||
|
||||
@@ -146,3 +146,69 @@ def test_operatie_ramane_denumire_sau_op():
|
||||
})
|
||||
assert d3["operatie"] == "Verificare"
|
||||
assert d3["cod"] == "oe-2" # cod ramas neschimbat (nu uppercase)
|
||||
|
||||
|
||||
# --- US-002: op_service_cod + op_service_denumire distincte ---
|
||||
|
||||
def test_operatie_service_din_cod_op_service():
|
||||
"""cod_op_service prezent -> op_service_cod contine valoarea; string gol cand lipseste."""
|
||||
# cod_op_service prezent -> op_service_cod populated
|
||||
d = prezentare_din_payload({
|
||||
"prestatii": [{"cod_op_service": "OP-77", "denumire": "Verificare faruri"}]
|
||||
})
|
||||
assert d["op_service_cod"] == "OP-77"
|
||||
|
||||
# cod_op_service absent (vine direct cu cod_prestatie) -> op_service_cod == "" (NU EMPTY="—")
|
||||
d2 = prezentare_din_payload({
|
||||
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}]
|
||||
})
|
||||
assert d2["op_service_cod"] == ""
|
||||
assert d2["op_service_cod"] != "—"
|
||||
|
||||
|
||||
def test_operatie_service_din_denumire():
|
||||
"""denumire prezenta cu cod_op_service -> op_service_denumire contine valoarea."""
|
||||
# ambele prezente -> op_service_denumire = denumire
|
||||
d = prezentare_din_payload({
|
||||
"prestatii": [{"cod_op_service": "OP-77", "denumire": "Verificare faruri"}]
|
||||
})
|
||||
assert d["op_service_denumire"] == "Verificare faruri"
|
||||
|
||||
# cod_op_service prezent dar fara denumire -> op_service_denumire == ""
|
||||
d2 = prezentare_din_payload({
|
||||
"prestatii": [{"cod_op_service": "OP-77"}]
|
||||
})
|
||||
assert d2["op_service_denumire"] == ""
|
||||
|
||||
# cod_op_service absent + denumire prezenta -> op_service_denumire == "" (nu expune denumire RAR)
|
||||
d3 = prezentare_din_payload({
|
||||
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}]
|
||||
})
|
||||
assert d3["op_service_denumire"] == ""
|
||||
|
||||
|
||||
def test_fara_operatie_service_cand_lipseste():
|
||||
"""Payload fara cod_op_service -> op_service_cod si op_service_denumire sunt "" (nu "—"), fara exceptie."""
|
||||
# vine direct cu cod_prestatie
|
||||
d = prezentare_din_payload({
|
||||
"prestatii": [{"cod_prestatie": "R-FRANE"}]
|
||||
})
|
||||
assert d["op_service_cod"] == ""
|
||||
assert d["op_service_denumire"] == ""
|
||||
assert d["op_service_cod"] != "—"
|
||||
assert d["op_service_denumire"] != "—"
|
||||
|
||||
# fara prestatii deloc
|
||||
d2 = prezentare_din_payload({"vin": "WVWZZZ1JZXW000001"})
|
||||
assert d2["op_service_cod"] == ""
|
||||
assert d2["op_service_denumire"] == ""
|
||||
|
||||
# payload None/gol
|
||||
d3 = prezentare_din_payload(None)
|
||||
assert d3["op_service_cod"] == ""
|
||||
assert d3["op_service_denumire"] == ""
|
||||
|
||||
# payload JSON invalid
|
||||
d4 = prezentare_din_payload("nu-e-json")
|
||||
assert d4["op_service_cod"] == ""
|
||||
assert d4["op_service_denumire"] == ""
|
||||
|
||||
@@ -71,13 +71,15 @@ def test_paleta_light_definita(client):
|
||||
|
||||
|
||||
def test_dark_ramane_default(client):
|
||||
""":root contine inca paleta dark exacta: --bg:#0f1115, --card:#181b22, --ink:#e6e9ef."""
|
||||
""":root contine paleta dark exacta: --bg:#0f1218, --card:#181c24, --ink:#e6e9ef.
|
||||
Valorile actualizate la US-013 (PRD 5.10) conform DESIGN.md (accent azur ROMFAST).
|
||||
"""
|
||||
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 "--bg:#0f1218" in html, "Paleta dark --bg:#0f1218 a fost modificata sau stearsa"
|
||||
assert "--card:#181c24" in html, "Paleta dark --card:#181c24 a fost modificata sau stearsa"
|
||||
assert "--ink:#e6e9ef" in html, "Paleta dark --ink:#e6e9ef a fost modificata sau stearsa"
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ def client(monkeypatch):
|
||||
|
||||
|
||||
def test_badge_mapari(client):
|
||||
"""Cu operatii needs_mapping, tab-ul Mapari poarta un numar + aria-label."""
|
||||
"""US-009: cu operatii needs_mapping, intrarea Mapari din meniu poarta un badge cu numar."""
|
||||
acct = _create_account_user("bm@test.com")
|
||||
_ins(acct, "needs_mapping")
|
||||
_ins(acct, "needs_mapping")
|
||||
@@ -82,10 +82,14 @@ def test_badge_mapari(client):
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
link = _tab_link(resp.text, "tab-mapari")
|
||||
assert "tab-badge" in link
|
||||
assert "2" in link
|
||||
assert "necesita atentie" in link # aria-label
|
||||
html = resp.text
|
||||
# US-009: Mapari e acum in meniu (nu tab); badgeul apare in intrarea meniului
|
||||
idx = html.find('href="/?tab=mapari"')
|
||||
assert idx != -1, "Intrarea Mapari lipseste din meniu"
|
||||
# Cauta badgeul in contextul link-ului Mapari
|
||||
window = html[idx:idx + 300]
|
||||
assert "tab-badge" in window, "Badgeul (tab-badge) trebuie sa apara langa intrarea Mapari"
|
||||
assert "2" in window, "Contorul 2 trebuie sa apara in badge-ul Mapari"
|
||||
|
||||
|
||||
def test_badge_trimiteri_blocate(client):
|
||||
|
||||
@@ -85,9 +85,7 @@ def test_tab_import_redirect(client):
|
||||
html = resp.text
|
||||
# Echivalent Acasa: contine upload-ul (import-section)
|
||||
assert 'id="import-section"' in html
|
||||
# Acasa e tab-ul activ (import nu mai e tab valid separat)
|
||||
assert re.search(r'id="tab-acasa"[^>]*aria-selected="true"', html), \
|
||||
"?tab=import ar trebui sa cada pe Acasa activ"
|
||||
# US-009: tab-bar eliminat, nu mai exista tab-uri cu aria-selected
|
||||
|
||||
|
||||
def test_tab_bar_fara_import(client):
|
||||
|
||||
207
tests/test_web_detaliu_eroare_simpla.py
Normal file
207
tests/test_web_detaliu_eroare_simpla.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Teste US-008 (PRD 5.10): simplificare erori in formularul de editare.
|
||||
|
||||
Problema actuala: cardul erori_3n/card_erori (clasa eroare-3n) e randat INAINTE de form,
|
||||
in afara ramurii `{% if editabil %}` — deci apare si in contextul de editare.
|
||||
|
||||
US-008 cere:
|
||||
- In editare: cardul 3-niveluri (`eroare-3n`) DISPARUT; erori per-camp raman ca text simplu
|
||||
subliniat (.s-error); erori fara camp (field None) apar ca rezumat simplu top-of-form.
|
||||
- In read-only: cardul 3-niveluri se pastreaza (comportament existent).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _csrf(client) -> str:
|
||||
resp = client.get("/")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _insert(acct: int, *, status: str, rar_error: str | None = None,
|
||||
vin: str = "WVWZZZ1JZXW000099", nr: str = "B100TST") -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
payload = json.dumps({
|
||||
"vin": vin,
|
||||
"nr_inmatriculare": nr,
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "50000",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
})
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions "
|
||||
"(idempotency_key, account_id, status, payload_json, rar_error) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(f"k-{os.urandom(6).hex()}", acct, status, payload, rar_error),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "eroare_simpla.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_form_editare_fara_card_3niveluri(client):
|
||||
"""In contextul editabil (needs_data), cardul erori_3n (eroare-3n) NU apare.
|
||||
|
||||
Problema curenta: cardul e randat INAINTE de form, in afara ramurii editabil,
|
||||
deci apare atat in editare cat si read-only. US-008 il muta in `{% if not editabil %}`.
|
||||
"""
|
||||
acct = _create_account_user("edit3n@test.com")
|
||||
# needs_data cu rar_error care contine o eroare cu field — format {field, message}
|
||||
rar_error = json.dumps([
|
||||
{"field": "odometruFinal", "message": "Odometru trebuie sa fie un numar intreg (ca string)."}
|
||||
])
|
||||
sid = _insert(acct, status="needs_data", rar_error=rar_error)
|
||||
_login(client, "edit3n@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# In editare: cardul cu 3 niveluri NU trebuie sa apara
|
||||
assert 'class="eroare-3n"' not in html, (
|
||||
"Cardul erori_3n (eroare-3n) NU trebuie sa apara in contextul editabil (needs_data). "
|
||||
"US-008: muta-l in '{% if not editabil %}'."
|
||||
)
|
||||
assert 'eroare-3n-item' not in html, (
|
||||
"Itemii card-ului 3n (eroare-3n-item) nu trebuie sa apara in editare."
|
||||
)
|
||||
# Formularul de editare trebuie sa ramana prezent
|
||||
assert 'hx-post=' in html and 'corecteaza' in html, "Formularul de corectie trebuie sa existe"
|
||||
|
||||
|
||||
def test_eroare_pe_camp_doar_text_simplu(client):
|
||||
"""Dupa o corectie invalida, eroarea per-camp apare ca .s-error text simplu, nu card 3n.
|
||||
|
||||
Macro-ul `camp` deja printeaza doar mesajul simplu — testul verifica ca
|
||||
`eroare-3n` nu exista in raspuns (nu e dobla-randat odata prin card si odata prin macro).
|
||||
"""
|
||||
acct = _create_account_user("simplu@test.com")
|
||||
sid = _insert(acct, status="needs_data", vin="WVWZZZ1JZXW000001", nr="B100TST")
|
||||
_login(client, "simplu@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
# POST cu odometru invalid (non-numeric) — ramane needs_data + eroare per-camp
|
||||
resp = client.post(
|
||||
f"/trimitere/{sid}/corecteaza",
|
||||
data={
|
||||
"nr_inmatriculare": "B100TST",
|
||||
"vin": "WVWZZZ1JZXW000001",
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "nu-e-numar",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Eroarea per-camp trebuie sa apara ca text simplu (.s-error)
|
||||
assert 's-error' in html, "Eroarea per-camp trebuie sa apara ca .s-error text simplu"
|
||||
|
||||
# Cardul 3-niveluri NU trebuie sa apara in contextul editabil
|
||||
assert 'class="eroare-3n"' not in html, (
|
||||
"Cardul eroare-3n NU trebuie sa apara in contextul editabil. "
|
||||
"US-008: randat doar in read-only ({% if not editabil %})."
|
||||
)
|
||||
|
||||
|
||||
def test_eroare_fara_camp_apare_ca_rezumat_in_editare(client):
|
||||
"""Erori cu field=None nu dispar silentios in editare — apar ca rezumat simplu top-of-form.
|
||||
|
||||
Bug M6: template-ul filtra erorile in `err_map` DOAR pe cele cu field,
|
||||
iar cardul 3n (ascuns in editare) era singurul canal de afisare pentru field=None.
|
||||
US-008: adauga un rezumat simplu (div .s-error sau similar) in ramura editabil.
|
||||
"""
|
||||
acct = _create_account_user("faracam@test.com")
|
||||
# rar_error cu o eroare FARA camp (field=None) — ex. eroare globala de la RAR
|
||||
rar_error = json.dumps([
|
||||
{"problema": "Date incomplete la nivel de prezentare", "cauza": "", "fix": "", "field": None}
|
||||
])
|
||||
sid = _insert(acct, status="needs_data", rar_error=rar_error)
|
||||
_login(client, "faracam@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Mesajul erorii globale trebuie sa fie prezent (nu silentios disparut)
|
||||
assert "Date incomplete la nivel de prezentare" in html, (
|
||||
"Eroarea fara camp (field=None) trebuie sa apara in contextul editabil. "
|
||||
"US-008 (M6): adauga rezumat simplu top-of-form in ramura '{% if editabil %}'."
|
||||
)
|
||||
# Dar NU ca card 3-niveluri
|
||||
assert 'class="eroare-3n"' not in html, (
|
||||
"Cardul eroare-3n NU trebuie sa apara in contextul editabil."
|
||||
)
|
||||
|
||||
|
||||
def test_readonly_pastreaza_card_3niveluri(client):
|
||||
"""In contextul read-only (error/sent), cardul erori_3n se pastreaza neschimbat."""
|
||||
acct = _create_account_user("readonly3n@test.com")
|
||||
rar_error = json.dumps([
|
||||
{"problema": "Eroare RAR server", "cauza": "ORA-12899", "fix": "Reverifica datele", "field": None}
|
||||
])
|
||||
sid = _insert(acct, status="error", rar_error=rar_error)
|
||||
_login(client, "readonly3n@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# In read-only: cardul eroare-3n TREBUIE sa apara
|
||||
assert 'class="eroare-3n"' in html, (
|
||||
"Cardul erori_3n (eroare-3n) trebuie pastrat in contextul read-only (error)."
|
||||
)
|
||||
191
tests/test_web_detaliu_op_service.py
Normal file
191
tests/test_web_detaliu_op_service.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Teste US-007 (PRD 5.10): afisare operatie de service in detaliu.
|
||||
|
||||
Cand payload-ul contine `cod_op_service` (codul intern al service-ului), detaliul
|
||||
trebuie sa afiseze „Operatie service" (cod + denumire), distinct de „Operatie RAR".
|
||||
Cand lipseste (a venit direct cu `cod_prestatie`), randul nu apare deloc — conventie
|
||||
"" (string gol) stabilita de US-002 in payload_view.py.
|
||||
|
||||
Apare atat in contextul editabil (needs_data/needs_mapping) cat si read-only (sent/error).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _insert(acct: int, *, status: str, payload: dict) -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(f"k-{os.urandom(6).hex()}", acct, status, json.dumps(payload)),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _payload_cu_op_service(vin: str = "WVWZZZ1JZXW000001") -> dict:
|
||||
"""Payload cu cod_op_service + denumire (vine prin API cu cod intern)."""
|
||||
return {
|
||||
"vin": vin,
|
||||
"nr_inmatriculare": "B100TST",
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "50000",
|
||||
"prestatii": [
|
||||
{
|
||||
"cod_op_service": "OP-FRANE-77",
|
||||
"denumire": "Verificare si reglaj frane",
|
||||
"cod_prestatie": "OE-1", # mapat deja
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _payload_fara_op_service(vin: str = "WVWZZZ1JZXW000002") -> dict:
|
||||
"""Payload cu cod_prestatie direct (fara cod_op_service)."""
|
||||
return {
|
||||
"vin": vin,
|
||||
"nr_inmatriculare": "B200TST",
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "50000",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "op_service_test.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_detaliu_arata_operatie_service_read_only(client):
|
||||
"""In context read-only (sent), operatia de service (cod intern + denumire) apare distinct.
|
||||
|
||||
Randul „Operatie service" trebuie sa fie vizibil si sa contina codul intern
|
||||
si denumirea venita prin API, separat de „Operatie RAR".
|
||||
"""
|
||||
acct = _create_account_user("op_srv_ro@test.com")
|
||||
sid = _insert(acct, status="sent", payload=_payload_cu_op_service())
|
||||
_login(client, "op_srv_ro@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Codul intern de service trebuie sa apara
|
||||
assert "OP-FRANE-77" in html, (
|
||||
"Codul intern al operatiei de service (op_service_cod) trebuie afisat in detaliu read-only."
|
||||
)
|
||||
# Denumirea trebuie sa apara
|
||||
assert "Verificare si reglaj frane" in html, (
|
||||
"Denumirea operatiei de service (op_service_denumire) trebuie afisata in detaliu read-only."
|
||||
)
|
||||
# Eticheta „Operatie service" trebuie sa apara
|
||||
assert "Operatie service" in html, (
|
||||
"Eticheta 'Operatie service' trebuie sa apara in detaliu read-only."
|
||||
)
|
||||
|
||||
|
||||
def test_detaliu_arata_operatie_service_editabil(client):
|
||||
"""In context editabil (needs_data), operatia de service apare de asemenea.
|
||||
|
||||
Campul este read-only in forma (nu e editabil de operator), dar trebuie afisat
|
||||
ca referinta pentru ce a cerut service-ul.
|
||||
"""
|
||||
acct = _create_account_user("op_srv_edit@test.com")
|
||||
sid = _insert(acct, status="needs_data", payload=_payload_cu_op_service())
|
||||
_login(client, "op_srv_edit@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Codul intern trebuie sa apara si in editare
|
||||
assert "OP-FRANE-77" in html, (
|
||||
"Codul intern al operatiei de service trebuie afisat si in contextul editabil (needs_data)."
|
||||
)
|
||||
# Eticheta trebuie sa apara
|
||||
assert "Operatie service" in html, (
|
||||
"Eticheta 'Operatie service' trebuie sa apara si in contextul editabil."
|
||||
)
|
||||
|
||||
|
||||
def test_detaliu_omite_cand_lipseste_read_only(client):
|
||||
"""Cand payload-ul nu are cod_op_service (vine direct cu cod_prestatie), randul nu apare.
|
||||
|
||||
Conventie US-002: op_service_cod = "" (nu "—"). Randul lipseste complet
|
||||
(nu apare „Operatie service: —" sau rand gol).
|
||||
"""
|
||||
acct = _create_account_user("op_srv_absent_ro@test.com")
|
||||
sid = _insert(acct, status="sent", payload=_payload_fara_op_service())
|
||||
_login(client, "op_srv_absent_ro@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Eticheta „Operatie service" nu trebuie sa apara cand lipseste
|
||||
assert "Operatie service" not in html, (
|
||||
"Randul 'Operatie service' nu trebuie sa apara cand payload-ul nu contine cod_op_service. "
|
||||
"Conventie US-002: op_service_cod='' → rand absent complet."
|
||||
)
|
||||
|
||||
|
||||
def test_detaliu_omite_cand_lipseste_editabil(client):
|
||||
"""Cand payload-ul nu are cod_op_service, randul nu apare nici in editare."""
|
||||
acct = _create_account_user("op_srv_absent_edit@test.com")
|
||||
sid = _insert(acct, status="needs_data", payload=_payload_fara_op_service())
|
||||
_login(client, "op_srv_absent_edit@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
assert "Operatie service" not in html, (
|
||||
"Randul 'Operatie service' nu trebuie sa apara in editare cand lipseste cod_op_service."
|
||||
)
|
||||
359
tests/test_web_editare_op_rar.py
Normal file
359
tests/test_web_editare_op_rar.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""Teste US-006 (PRD 5.10): editare operatie RAR (cod_prestatie) din formularul de detaliu.
|
||||
|
||||
Stari editabile: needs_data, needs_mapping (stari cu formular de corectie activ).
|
||||
Read-only: sent/sending/queued/error (fara select cod_prestatie).
|
||||
|
||||
Cazuri:
|
||||
- test_editabil_arata_select_cod_rar: detaliu needs_data → HTML are <select name="cod_prestatie">
|
||||
- test_salvare_schimba_cod_si_repune_in_coada: POST cu cod_prestatie=OE-2 → payload actualizat + status queued
|
||||
- test_idempotency_key_se_schimba: schimbarea codului → cheie idempotency noua
|
||||
- test_cod_invalid_respins: cod necunoscut in nomenclator → respins (status neschimbat)
|
||||
- test_sent_nu_arata_select: detaliu sent → fara <select name="cod_prestatie">
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
# VIN valid: 17 caractere, fara I/O/Q
|
||||
VIN_US006 = "WVWZZZ1JZXW0E6001"
|
||||
|
||||
# Payload complet valid (trece validate_prezentare)
|
||||
PAYLOAD_VALID = {
|
||||
"vin": VIN_US006,
|
||||
"nr_inmatriculare": "B100AAA",
|
||||
"data_prestatie": "2026-06-10",
|
||||
"odometru_final": "55000",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _ins(acct: int, *, status: str, payload: dict | None = None) -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(f"k-us006-{os.urandom(6).hex()}", acct, status, json.dumps(payload or PAYLOAD_VALID)),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _ins_nomenclator(*codes: str) -> None:
|
||||
"""Insereaza coduri RAR in nomenclator_rar (tabelul e gol in DB-ul de test)."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
for cod in codes:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||
(cod, f"Operatie test {cod}"),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _row(sid: int):
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _csrf(client) -> str:
|
||||
"""CSRF token din pagina principala (sesiune activa necesara)."""
|
||||
resp = client.get("/")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m, f"CSRF token negasit in pagina principala: {resp.text[:500]}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _detaliu(client, sid: int) -> str:
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
return resp.text
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "editare_rar.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_editabil_arata_select_cod_rar(client):
|
||||
"""needs_data cu nomenclator populat → formularul de detaliu afiseaza <select name='cod_prestatie'>."""
|
||||
acct = _create_account_user("sel1@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="needs_data")
|
||||
_login(client, "sel1@test.com")
|
||||
|
||||
html = _detaliu(client, sid)
|
||||
|
||||
assert 'name="cod_prestatie"' in html, (
|
||||
"Formularul de detaliu needs_data trebuie sa contina un select cu name='cod_prestatie'"
|
||||
)
|
||||
assert "<select" in html, (
|
||||
"Elementul <select> trebuie sa apara in detaliu pentru starea needs_data"
|
||||
)
|
||||
# Optiunile din select contin codurile din nomenclator
|
||||
assert "OE-1" in html, "Codul OE-1 trebuie sa apara in optiunile select-ului"
|
||||
assert "OE-2" in html, "Codul OE-2 trebuie sa apara in optiunile select-ului"
|
||||
|
||||
|
||||
def test_salvare_schimba_cod_si_repune_in_coada(client):
|
||||
"""POST /corecteaza cu cod_prestatie=OE-2 → payload actualizat + status=queued."""
|
||||
acct = _create_account_user("sav2@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="needs_data")
|
||||
_login(client, "sav2@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
resp = client.post(
|
||||
f"/trimitere/{sid}/corecteaza",
|
||||
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
row = _row(sid)
|
||||
assert row["status"] == "queued", (
|
||||
f"Dupa salvarea cu OE-2, randul trebuie sa fie queued, nu '{row['status']}'"
|
||||
)
|
||||
payload = json.loads(row["payload_json"])
|
||||
prestatii = payload.get("prestatii") or []
|
||||
assert prestatii, "Payload-ul trebuie sa contina cel putin o prestatie dupa corectie"
|
||||
cod_nou = (prestatii[0].get("cod_prestatie") or "").strip().upper()
|
||||
assert cod_nou == "OE-2", (
|
||||
f"cod_prestatie in payload trebuie sa fie OE-2, nu '{cod_nou}'"
|
||||
)
|
||||
|
||||
|
||||
def test_idempotency_key_se_schimba(client):
|
||||
"""Schimbarea cod_prestatie (OE-1 → OE-2) recalculeaza cheia de idempotency.
|
||||
|
||||
Verificare stricta: cheia calculata dupa POST cu OE-2 difera de cheia CANONICALA
|
||||
cu OE-1. Daca cod_prestatie nu e injectat inainte de build_key, cheia ramane cea
|
||||
cu OE-1 si testul pica. Este RED inainte de implementarea US-006.
|
||||
"""
|
||||
from app.idempotency import build_key, canonicalize_row
|
||||
|
||||
acct = _create_account_user("idem3@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="needs_data")
|
||||
_login(client, "idem3@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
# Cheia canonicala cu OE-1 = ce ar produce un POST fara cod_prestatie (sau cu OE-1)
|
||||
canon_oe1 = canonicalize_row({**PAYLOAD_VALID, "prestatii": [{"cod_prestatie": "OE-1"}]})
|
||||
key_oe1 = build_key(acct, canon_oe1)
|
||||
|
||||
# POST cu cod_prestatie=OE-2: implementarea US-006 trebuie sa injecteze OE-2 in payload
|
||||
client.post(
|
||||
f"/trimitere/{sid}/corecteaza",
|
||||
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
|
||||
)
|
||||
|
||||
cheie_noua = _row(sid)["idempotency_key"]
|
||||
assert cheie_noua != key_oe1, (
|
||||
"Cheia calculata dupa POST cu OE-2 trebuie sa difere de cheia cu OE-1. "
|
||||
"Daca sunt egale, cod_prestatie nu a fost injectat inainte de build_key (US-006 H3)."
|
||||
)
|
||||
|
||||
|
||||
def test_cod_invalid_respins(client):
|
||||
"""Cod RAR necunoscut in nomenclator → randul ramane needs_data (nu se re-cueaza)."""
|
||||
acct = _create_account_user("inv4@test.com")
|
||||
_ins_nomenclator("OE-1") # "ZZ-INVALID" nu exista
|
||||
sid = _ins(acct, status="needs_data")
|
||||
_login(client, "inv4@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
resp = client.post(
|
||||
f"/trimitere/{sid}/corecteaza",
|
||||
data={"csrf_token": csrf, "cod_prestatie": "ZZ-INVALID"},
|
||||
)
|
||||
# Raspunsul e 200 (eroare in pagina, nu redirect)
|
||||
assert resp.status_code == 200
|
||||
|
||||
row = _row(sid)
|
||||
assert row["status"] == "needs_data", (
|
||||
f"Un cod invalid trebuie sa lase randul in needs_data, nu '{row['status']}'"
|
||||
)
|
||||
# Mesajul de eroare trebuie sa apara in raspuns
|
||||
assert "ZZ-INVALID" in resp.text or "necunoscut" in resp.text.lower(), (
|
||||
"Raspunsul trebuie sa indice ca codul este necunoscut in nomenclator"
|
||||
)
|
||||
|
||||
|
||||
def test_sent_nu_arata_select(client):
|
||||
"""Trimitere cu status=sent → fara <select name='cod_prestatie'> in detaliu (read-only)."""
|
||||
acct = _create_account_user("ro5@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="sent")
|
||||
_login(client, "ro5@test.com")
|
||||
|
||||
html = _detaliu(client, sid)
|
||||
|
||||
assert 'name="cod_prestatie"' not in html, (
|
||||
"Starea sent trebuie sa fie read-only (fara select cod_prestatie)"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# US-006b: extindere la starea error
|
||||
# ================================================================
|
||||
|
||||
def test_error_arata_select_cod_rar(client):
|
||||
"""needs_data/needs_mapping primeau select (US-006); error trebuie sa primeasca si el
|
||||
un select cod_prestatie in formularul 'Re-pune in coada' (US-006b)."""
|
||||
acct = _create_account_user("err1@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="error")
|
||||
_login(client, "err1@test.com")
|
||||
|
||||
html = _detaliu(client, sid)
|
||||
|
||||
assert 'name="cod_prestatie"' in html, (
|
||||
"Starea error trebuie sa afiseze un select cod_prestatie (US-006b)"
|
||||
)
|
||||
assert "<select" in html, "Elementul <select> trebuie sa apara in detaliu pentru error"
|
||||
# Codurile din nomenclator trebuie sa fie in optiuni
|
||||
assert "OE-1" in html and "OE-2" in html, (
|
||||
"Codurile din nomenclator trebuie sa apara in select-ul pentru error"
|
||||
)
|
||||
# NU trebuie sa afiseze formularul complet de corectie (fara /corecteaza)
|
||||
assert f"/trimitere/{sid}/corecteaza" not in html, (
|
||||
"Starea error NU trebuie sa aiba formular /corecteaza (US-006b foloseste /repune)"
|
||||
)
|
||||
# Butonul principal ramane 'Re-pune in coada' (nu 'Salveaza si retrimite')
|
||||
assert "Re-pune in coada" in html
|
||||
assert "Salveaza si retrimite" not in html
|
||||
|
||||
|
||||
def test_error_salvare_schimba_cod_si_repune_in_coada(client):
|
||||
"""POST /repune cu cod_prestatie=OE-2 pe un rand error → payload actualizat + status=queued."""
|
||||
acct = _create_account_user("err2@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="error")
|
||||
_login(client, "err2@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
resp = client.post(
|
||||
f"/trimitere/{sid}/repune",
|
||||
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
row = _row(sid)
|
||||
assert row["status"] == "queued", (
|
||||
f"Dupa repune cu OE-2, randul trebuie sa fie queued, nu '{row['status']}'"
|
||||
)
|
||||
payload = json.loads(row["payload_json"])
|
||||
prestatii = payload.get("prestatii") or []
|
||||
assert prestatii, "Payload-ul trebuie sa contina cel putin o prestatie"
|
||||
cod_nou = (prestatii[0].get("cod_prestatie") or "").strip().upper()
|
||||
assert cod_nou == "OE-2", (
|
||||
f"cod_prestatie in payload trebuie sa fie OE-2, nu '{cod_nou}'"
|
||||
)
|
||||
|
||||
|
||||
def test_error_idempotency_key_se_schimba(client):
|
||||
"""Schimbarea cod_prestatie (OE-1 → OE-2) la repune recalculeaza cheia de idempotency.
|
||||
|
||||
Randul e inserat CU CHEIA CANONICA pentru OE-1 (nu random), ca sa fie RED inainte
|
||||
de implementare: fara injectare, repune nu schimba cheia (ramane OE-1) → test FAIL.
|
||||
Dupa implementare, POST cu OE-2 → cheie noua (canonicala cu OE-2) ≠ cheie OE-1.
|
||||
"""
|
||||
from app.idempotency import build_key, canonicalize_row
|
||||
from app.db import get_connection as _gc
|
||||
|
||||
acct = _create_account_user("err3@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
|
||||
# Calculeaza cheia canonicala pentru OE-1 si insereaza randul CU acea cheie.
|
||||
canon_oe1 = canonicalize_row({**PAYLOAD_VALID, "prestatii": [{"cod_prestatie": "OE-1"}]})
|
||||
key_oe1 = build_key(acct, canon_oe1)
|
||||
|
||||
# Inserare cu cheia cunoscuta (nu random), ca sa avem un baseline deterministic.
|
||||
conn = _gc()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(key_oe1, acct, "error", json.dumps({**PAYLOAD_VALID, "prestatii": [{"cod_prestatie": "OE-1"}]})),
|
||||
)
|
||||
conn.commit()
|
||||
sid = int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
_login(client, "err3@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
# POST /repune cu OE-2 → implementarea trebuie sa recalculeze cheia
|
||||
client.post(
|
||||
f"/trimitere/{sid}/repune",
|
||||
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
|
||||
)
|
||||
|
||||
cheie_noua = _row(sid)["idempotency_key"]
|
||||
assert cheie_noua != key_oe1, (
|
||||
"Cheia idempotency trebuie sa difere dupa schimbarea cod_prestatie la repune (US-006b). "
|
||||
"Daca sunt egale, cod_prestatie nu a fost injectat inainte de build_key."
|
||||
)
|
||||
|
||||
|
||||
def test_queued_nu_arata_select(client):
|
||||
"""Trimitere queued → fara select cod_prestatie (read-only; doar error/needs_* primesc select)."""
|
||||
acct = _create_account_user("ro6@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="queued")
|
||||
_login(client, "ro6@test.com")
|
||||
|
||||
html = _detaliu(client, sid)
|
||||
|
||||
assert 'name="cod_prestatie"' not in html, (
|
||||
"Starea queued trebuie sa fie read-only (fara select cod_prestatie)"
|
||||
)
|
||||
148
tests/test_web_filtre_submissions.py
Normal file
148
tests/test_web_filtre_submissions.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Teste US-001 (PRD 5.10): fix filtrare pe interval de data in fragment_submissions.
|
||||
|
||||
Cazuri:
|
||||
- timestamp-uri cu ora (ex. "2026-06-20 14:35:07") trebuie incluse la filtrare pe ziua respectiva
|
||||
- interval inclusiv la ambele capete
|
||||
- valori ne-ISO raman excluse
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _ins(acct: int, *, status: str = "queued", vin: str, nr: str, data: str) -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
f"k-{os.urandom(5).hex()}", acct, status,
|
||||
json.dumps({
|
||||
"vin": vin,
|
||||
"nr_inmatriculare": nr,
|
||||
"data_prestatie": data,
|
||||
"odometru_final": "100",
|
||||
"prestatii": [{"cod_prestatie": "R-X"}],
|
||||
}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid # type: ignore[return-value]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _row(sid: int) -> str:
|
||||
"""Selector HTML pentru randul cu ID-ul dat."""
|
||||
return f'id="trimitere-row-{sid}"'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "filtre_data.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_filtru_data_include_timestamp_cu_ora(client):
|
||||
"""Un rand cu data_prestatie = '2026-06-20 14:35:07' trebuie sa apara
|
||||
cand filtrul e data_de=2026-06-20 si data_pana=2026-06-20.
|
||||
Bug actual: _is_iso_date verifica len==10 → exclude timestamp → randul dispare."""
|
||||
acct = _create_account_user("ts_ora@test.com")
|
||||
# timestamp cu ora — asta e cauza bug-ului
|
||||
sid_ora = _ins(acct, vin="WVIN001TS001ORA0001", nr="B01TS", data="2026-06-20 14:35:07")
|
||||
# rand fara ora, in afara intervalului (nu trebuie sa apara)
|
||||
sid_alt = _ins(acct, vin="WVIN001TS001ALT0002", nr="B02TS", data="2026-06-21")
|
||||
_login(client, "ts_ora@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?data_de=2026-06-20&data_pana=2026-06-20")
|
||||
assert resp.status_code == 200
|
||||
assert _row(sid_ora) in resp.text, (
|
||||
"Timestamp-ul cu ora trebuie inclus la filtrare pe ziua respectiva"
|
||||
)
|
||||
assert _row(sid_alt) not in resp.text, (
|
||||
"Randul din 2026-06-21 nu trebuie sa apara in intervalul 2026-06-20 to 2026-06-20"
|
||||
)
|
||||
|
||||
|
||||
def test_filtru_data_interval_inclusiv_capete(client):
|
||||
"""Intervalul data_de..data_pana este inclusiv la ambele capete.
|
||||
data_de=2026-06-10, data_pana=2026-06-12 → randurile din 10, 11, 12 apar;
|
||||
cel din 09 si cel din 13 nu apar.
|
||||
Testat si cu timestamp-uri (ISO cu ora) pentru a combina ambele cerinte."""
|
||||
acct = _create_account_user("interval@test.com")
|
||||
sid_09 = _ins(acct, vin="WVIN_INTERVAL_09000", nr="B09", data="2026-06-09")
|
||||
sid_10 = _ins(acct, vin="WVIN_INTERVAL_10000", nr="B10", data="2026-06-10") # capat stang inclusiv
|
||||
sid_11 = _ins(acct, vin="WVIN_INTERVAL_11000", nr="B11", data="2026-06-11 08:00:00") # mijloc cu ora
|
||||
sid_12 = _ins(acct, vin="WVIN_INTERVAL_12000", nr="B12", data="2026-06-12T23:59:59") # capat drept cu T
|
||||
sid_13 = _ins(acct, vin="WVIN_INTERVAL_13000", nr="B13", data="2026-06-13")
|
||||
_login(client, "interval@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?data_de=2026-06-10&data_pana=2026-06-12")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert _row(sid_09) not in body, "2026-06-09 e inainte de data_de → nu trebuie sa apara"
|
||||
assert _row(sid_10) in body, "2026-06-10 = data_de → capatul stang inclusiv"
|
||||
assert _row(sid_11) in body, "2026-06-11 cu ora trebuie inclus"
|
||||
assert _row(sid_12) in body, "2026-06-12 cu T trebuie inclus (capatul drept inclusiv)"
|
||||
assert _row(sid_13) not in body, "2026-06-13 e dupa data_pana → nu trebuie sa apara"
|
||||
|
||||
|
||||
def test_filtru_data_ignora_valori_ne_data(client):
|
||||
"""Valorile care nu incep cu o data ISO valida sunt excluse din rezultate
|
||||
cand filtrul de data e activ — comportamentul actual pastrat."""
|
||||
acct = _create_account_user("nedata@test.com")
|
||||
sid_dd = _ins(acct, vin="WVIN_NEDATA_DD000001", nr="BND1", data="20.06.2026") # format DD.MM.YYYY — ne-ISO
|
||||
sid_en = _ins(acct, vin="WVIN_NEDATA_EN000002", nr="BND2", data="Jun 20 2026") # format englezesc — ne-ISO
|
||||
sid_bun = _ins(acct, vin="WVIN_NEDATA_BUNA0003", nr="BGD", data="2026-06-20") # format corect ISO
|
||||
_login(client, "nedata@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?data_de=2026-06-20&data_pana=2026-06-20")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert _row(sid_dd) not in body, "Format DD.MM.YYYY trebuie exclus (ne-ISO)"
|
||||
assert _row(sid_en) not in body, "Format englezesc trebuie exclus (ne-ISO)"
|
||||
assert _row(sid_bun) in body, "Format ISO corect trebuie inclus"
|
||||
175
tests/test_web_header_branding.py
Normal file
175
tests/test_web_header_branding.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Teste US-012 / US-012b (PRD 5.10): Header logo ROMFAST + titlu centrat.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
|
||||
|
||||
US-012b (decizie user): logo-ul PNG real (/static/romfast_logo.png) in loc de wordmark text.
|
||||
|
||||
Testeaza:
|
||||
- test_header_contine_by_romfast: <img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
|
||||
- test_titlu_centrat: titlul e in structura centrata (grila 3 coloane), controale la dreapta
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "branding.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 _get_header(html: str) -> str:
|
||||
"""Extrage continutul elementului <header>."""
|
||||
m = re.search(r"<header>(.*?)</header>", html, re.DOTALL | re.IGNORECASE)
|
||||
assert m, "<header> negasit in HTML"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _get_style(html: str) -> str:
|
||||
"""Extrage continutul primului <style>."""
|
||||
m = re.search(r"<style>(.*?)</style>", html, re.DOTALL)
|
||||
assert m, "<style> negasit in HTML"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# ── test_header_contine_by_romfast ────────────────────────────────────────────
|
||||
|
||||
def test_header_contine_by_romfast(client):
|
||||
"""Header contine logo-ul ROMFAST ca <img> (US-012b: decizie user — PNG real).
|
||||
|
||||
Verifica:
|
||||
- <img src="/static/romfast_logo.png"> prezent in header
|
||||
- Atribut alt non-gol (ex. alt="ROMFAST") pentru accesibilitate
|
||||
- Imaginea are clasa brand-logo (pentru stilizare CSS)
|
||||
- NU mai exista spanurile text .romfast-rom / .romfast-fast (wordmark text inlocuit)
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
header = _get_header(resp.text)
|
||||
|
||||
# Gaseste toate tag-urile <img> din header si cauta logo-ul
|
||||
img_tags = re.findall(r'<img[^>]+>', header, re.IGNORECASE)
|
||||
logo_tag = next(
|
||||
(t for t in img_tags if "romfast_logo.png" in t),
|
||||
None,
|
||||
)
|
||||
|
||||
# 1. <img> cu src="/static/romfast_logo.png" prezent in header
|
||||
assert logo_tag is not None, (
|
||||
"<img> cu 'romfast_logo.png' negasit in header. "
|
||||
"Decizie user (US-012b): logo-ul PNG real trebuie sa apara in header. "
|
||||
f"Header: {header[:500]}"
|
||||
)
|
||||
|
||||
# 2. Atribut alt non-gol pe imaginea logo-ului (accesibilitate)
|
||||
alt_match = re.search(r'alt=["\']([^"\']+)["\']', logo_tag, re.IGNORECASE)
|
||||
assert alt_match and alt_match.group(1).strip(), (
|
||||
"Imaginea logo lipseste atributul alt (sau e gol). "
|
||||
f"Tag gasit: {logo_tag}"
|
||||
)
|
||||
|
||||
# 3. Clasa brand-logo aplicata (pentru controlul inaltimii CSS)
|
||||
assert "brand-logo" in logo_tag, (
|
||||
"class='brand-logo' lipseste de pe <img> logo. "
|
||||
f"Tag gasit: {logo_tag}"
|
||||
)
|
||||
|
||||
# 4. Spanurile text (wordmark vechi .romfast-rom / .romfast-fast) NU mai exista
|
||||
assert "romfast-rom" not in header and "romfast-fast" not in header, (
|
||||
"Clasele .romfast-rom / .romfast-fast (wordmark text) inca prezente in header. "
|
||||
"Trebuie inlocuite complet de <img> logo. "
|
||||
f"Header snippet: {header[:500]}"
|
||||
)
|
||||
|
||||
|
||||
# ── test_titlu_centrat ────────────────────────────────────────────────────────
|
||||
|
||||
def test_titlu_centrat(client):
|
||||
"""Titlul 'Gateway RAR AUTOPASS' e in structura centrata in header (grila 3 coloane).
|
||||
|
||||
Verifica:
|
||||
- CSS contine grid-template-columns cu 3 coloane pe header (1fr auto 1fr sau similar)
|
||||
- Header contine un element cu clasa 'header-center' (sau similar) care contine h1
|
||||
- Controalele (button tema-toggle) sunt la dreapta (in header-right sau margin-left:auto)
|
||||
- Badge-ul env e in grila (header-left sau similar), nu flotant
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
header = _get_header(html)
|
||||
style = _get_style(html)
|
||||
|
||||
# 1. CSS header foloseste grid cu 3 coloane
|
||||
header_css = re.search(r"header\s*\{([^}]+)\}", style, re.DOTALL)
|
||||
assert header_css, "Regula CSS 'header { ... }' negasita in <style>"
|
||||
header_block = header_css.group(1)
|
||||
assert "grid" in header_block.lower(), (
|
||||
f"header CSS nu foloseste grid (display:grid). Block: {header_block.strip()}"
|
||||
)
|
||||
assert "grid-template-columns" in style, (
|
||||
"grid-template-columns lipseste din <style> (necesar pentru 3 coloane)"
|
||||
)
|
||||
|
||||
# 2. Element centrat in header contine h1
|
||||
center_div = re.search(
|
||||
r'<div[^>]+class=["\'][^"\']*header-center[^"\']*["\'][^>]*>(.*?)</div>',
|
||||
header,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert center_div, (
|
||||
"Element cu clasa 'header-center' negasit in <header>. "
|
||||
"Titlul trebuie sa fie intr-o celula centrata a grilei. "
|
||||
f"Header snippet: {header[:600]}"
|
||||
)
|
||||
center_content = center_div.group(1)
|
||||
assert "<h1" in center_content, (
|
||||
"h1 nu e in interiorul elementului .header-center. "
|
||||
f"Continut .header-center: {center_content[:300]}"
|
||||
)
|
||||
|
||||
# 3. Controalele (butonul de tema) sunt in header-right
|
||||
right_div = re.search(
|
||||
r'<div[^>]+class=["\'][^"\']*header-right[^"\']*["\'][^>]*>(.*?)</div>',
|
||||
header,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert right_div, (
|
||||
"Element cu clasa 'header-right' negasit in <header>. "
|
||||
"Controalele (tema, versiune, meniu) trebuie sa fie la dreapta. "
|
||||
f"Header snippet: {header[:600]}"
|
||||
)
|
||||
right_content = right_div.group(1)
|
||||
assert "tema-toggle" in right_content, (
|
||||
"Butonul tema-toggle nu e in .header-right. "
|
||||
f"Continut .header-right: {right_content[:300]}"
|
||||
)
|
||||
|
||||
# 4. Badge-ul env e in header-left (nu mai e aruncat dupa h1)
|
||||
left_div = re.search(
|
||||
r'<div[^>]+class=["\'][^"\']*header-left[^"\']*["\'][^>]*>(.*?)</div>',
|
||||
header,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert left_div, (
|
||||
"Element cu clasa 'header-left' negasit in <header>. "
|
||||
"Badge-ul env trebuie sa fie in celula stanga a grilei (echilibru optic). "
|
||||
f"Header snippet: {header[:600]}"
|
||||
)
|
||||
left_content = left_div.group(1)
|
||||
assert 'class="env"' in left_content or "class='env'" in left_content, (
|
||||
"Badge-ul .env nu e in .header-left. "
|
||||
f"Continut .header-left: {left_content[:200]}"
|
||||
)
|
||||
141
tests/test_web_mapari_actiuni.py
Normal file
141
tests/test_web_mapari_actiuni.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Teste US-011 (PRD 5.10): butoane icon salvare/stergere + dirty state pe Mapari.
|
||||
|
||||
Cerinte:
|
||||
- Butoane .icon-btn mereu vizibile pe rand (nu ascunse in kebab)
|
||||
- Meniu kebab (<details class="kebab">) eliminat
|
||||
- aria-label descriptiv pe fiecare buton icon
|
||||
- data-dirty-form pe butonul de salvare (permite JS dirty-state)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _seed_saved_mapping(acct_id: int) -> None:
|
||||
"""Insereaza o mapare salvata in operations_mapping."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(acct_id, "OP-TEST-77", "OE-1", 1),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_actiuni.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_butoane_icon_vizibile_pe_rand_salvate(client):
|
||||
"""Butoanele de salvare/stergere in 'Mapari salvate' au clasa icon-btn (mereu vizibile)."""
|
||||
acct = _create_account_user("actiuni_icon@test.com")
|
||||
_seed_saved_mapping(acct)
|
||||
_login(client, "actiuni_icon@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
assert 'class="icon-btn' in html, (
|
||||
"Butoanele de actiune din 'Mapari salvate' trebuie sa aiba clasa 'icon-btn' "
|
||||
"(mereu vizibile pe rand, nu ascunse in kebab)."
|
||||
)
|
||||
|
||||
|
||||
def test_fara_kebab_meniu(client):
|
||||
"""Meniul kebab (details.kebab / kebab-menu) e eliminat din 'Mapari salvate'."""
|
||||
acct = _create_account_user("actiuni_kebab@test.com")
|
||||
_seed_saved_mapping(acct)
|
||||
_login(client, "actiuni_kebab@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
assert 'class="kebab"' not in html, (
|
||||
"Meniul kebab (details.kebab) trebuie eliminat din 'Mapari salvate' (US-011)."
|
||||
)
|
||||
assert '"kebab-menu"' not in html, (
|
||||
"Clasa 'kebab-menu' trebuie eliminata complet din 'Mapari salvate' (US-011)."
|
||||
)
|
||||
|
||||
|
||||
def test_butoane_cu_aria_label(client):
|
||||
"""Butoanele icon-btn au aria-label descriptiv."""
|
||||
acct = _create_account_user("actiuni_aria@test.com")
|
||||
_seed_saved_mapping(acct)
|
||||
_login(client, "actiuni_aria@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
icon_btns = re.findall(r'<button[^>]+class="icon-btn[^"]*"[^>]*>', html)
|
||||
assert icon_btns, "Trebuie sa existe butoane cu clasa icon-btn in 'Mapari salvate'."
|
||||
assert any('aria-label' in btn for btn in icon_btns), (
|
||||
"Cel putin un buton icon-btn trebuie sa aiba atributul aria-label descriptiv."
|
||||
)
|
||||
|
||||
|
||||
def test_dirty_state_data_attr(client):
|
||||
"""Butonul de salvare are data-dirty-form pentru dirty-state JS."""
|
||||
acct = _create_account_user("actiuni_dirty@test.com")
|
||||
_seed_saved_mapping(acct)
|
||||
_login(client, "actiuni_dirty@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
assert 'data-dirty-form=' in html, (
|
||||
"Butonul de salvare trebuie sa aiba atributul data-dirty-form pentru JS dirty-state. "
|
||||
"Cand utilizatorul schimba selectul, JS adauga clasa 'dirty' pe buton (fundal --accent)."
|
||||
)
|
||||
135
tests/test_web_mapari_layout.py
Normal file
135
tests/test_web_mapari_layout.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Teste US-010 (PRD 5.10): restructurare pagina Mapari intr-o singura pagina consolidata.
|
||||
|
||||
Modificari cerute:
|
||||
- Ordinea sectiunilor: (1) De rezolvat, (2) Mapari salvate, (3) Reguli automate, (4) Formate coloane.
|
||||
- Sectiunea de ajutor (<details class="ajutor-mapari">) eliminata.
|
||||
- Textul empty-state „Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR..." eliminat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_layout.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_de_rezolvat_prima(client):
|
||||
"""Sectiunea „De rezolvat" apare prima — inaintea „Mapari operatii salvate"."""
|
||||
_create_account_user("mapari_ord@test.com")
|
||||
_login(client, "mapari_ord@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
pos_de_rezolvat = html.find("De rezolvat")
|
||||
pos_salvate = html.find("Mapari operatii salvate")
|
||||
|
||||
assert pos_de_rezolvat != -1, "Sectiunea 'De rezolvat' trebuie sa existe in pagina."
|
||||
assert pos_salvate != -1, "Sectiunea 'Mapari operatii salvate' trebuie sa existe in pagina."
|
||||
assert pos_de_rezolvat < pos_salvate, (
|
||||
f"'De rezolvat' (pozitia {pos_de_rezolvat}) trebuie sa apara INAINTE de "
|
||||
f"'Mapari operatii salvate' (pozitia {pos_salvate})."
|
||||
)
|
||||
|
||||
|
||||
def test_fara_ajutor_si_empty_text(client):
|
||||
"""Sectiunea de ajutor (ajutor-mapari) si empty-text-ul specific sunt eliminate."""
|
||||
_create_account_user("mapari_fara@test.com")
|
||||
_login(client, "mapari_fara@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Sectiunea de ajutor eliminata
|
||||
assert 'ajutor-mapari' not in html, (
|
||||
"Clasa 'ajutor-mapari' (details de ajutor) trebuie eliminata din pagina Mapari (US-010)."
|
||||
)
|
||||
assert 'class="ajutor-mapari"' not in html, (
|
||||
"<details class=\"ajutor-mapari\"> trebuie eliminat."
|
||||
)
|
||||
|
||||
# Empty-text specific „Nicio operatie nemapata" eliminat
|
||||
assert "Nicio operatie nemapata" not in html, (
|
||||
"Textul empty-state 'Nicio operatie nemapata — tot ce a venit...' trebuie eliminat (US-010)."
|
||||
)
|
||||
assert "tot ce a venit s-a tradus in coduri RAR" not in html, (
|
||||
"Textul empty-state extins trebuie eliminat complet."
|
||||
)
|
||||
|
||||
|
||||
def test_ordine_sectiuni(client):
|
||||
"""Ordinea corecta a sectiunilor: De rezolvat → Mapari salvate → Reguli automate → Formate."""
|
||||
_create_account_user("mapari_ord2@test.com")
|
||||
_login(client, "mapari_ord2@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
pos1 = html.find("De rezolvat")
|
||||
pos2 = html.find("Mapari operatii salvate")
|
||||
pos3 = html.find("Reguli automate")
|
||||
pos4 = html.find("Formate de coloane")
|
||||
|
||||
assert pos1 != -1, "Sectiunea 'De rezolvat' trebuie sa existe."
|
||||
assert pos2 != -1, "Sectiunea 'Mapari operatii salvate' trebuie sa existe."
|
||||
assert pos3 != -1, "Sectiunea 'Reguli automate' trebuie sa existe."
|
||||
assert pos4 != -1, "Sectiunea 'Formate de coloane' trebuie sa existe."
|
||||
|
||||
assert pos1 < pos2, "De rezolvat trebuie sa fie inaintea Mapari salvate."
|
||||
assert pos2 < pos3, (
|
||||
f"'Mapari operatii salvate' (poz {pos2}) trebuie sa fie inaintea "
|
||||
f"'Reguli automate' (poz {pos3}). Acum Reguli automate e ultima sectiune — "
|
||||
"muta-o pe pozitia 3 (inaintea Formate de coloane)."
|
||||
)
|
||||
assert pos3 < pos4, (
|
||||
f"'Reguli automate' (poz {pos3}) trebuie sa fie inaintea "
|
||||
f"'Formate de coloane' (poz {pos4})."
|
||||
)
|
||||
150
tests/test_web_mapari_meniu.py
Normal file
150
tests/test_web_mapari_meniu.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Teste US-009 (PRD 5.10): Mapari in meniu hamburger + scoatere tab-uri.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare.
|
||||
|
||||
Acceptance criteria testate:
|
||||
- test_meniu_contine_mapari: meniul #cont-menu are o intrare Mapari
|
||||
(href=/?tab=mapari); badge vizibil cand exista needs_mapping.
|
||||
- test_pagina_principala_fara_tabbar_mapari: pagina / nu mai are role="tablist"
|
||||
(tab-bar-ul Acasa/Mapari a fost eliminat).
|
||||
- test_ruta_mapari_randeaza_sectiunea: GET /?tab=mapari → 200, sectiunea mapari
|
||||
randata (id="mapari-section"), fara role="tablist" rezidual.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, password: str = "parolasecreta10"):
|
||||
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 Test Meniu", active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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 pe /login"
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _ins_needs_mapping(acct: int) -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES (?, ?, 'needs_mapping', ?)",
|
||||
(f"k-us009-{os.urandom(4).hex()}", acct, json.dumps({"prestatii": [{"cod_op_service": "X"}]})),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "meniu_test.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_meniu_contine_mapari(client):
|
||||
"""Meniul hamburger (#cont-menu) contine o intrare cu href=/?tab=mapari."""
|
||||
_create_account_user("menu1@test.com")
|
||||
_login(client, "menu1@test.com")
|
||||
|
||||
html = client.get("/").text
|
||||
|
||||
# Intrarea Mapari trebuie sa existe in meniu
|
||||
assert 'href="/?tab=mapari"' in html, (
|
||||
"Meniul hamburger trebuie sa contina o intrare cu href='/?tab=mapari'"
|
||||
)
|
||||
# Textul "Mapari" trebuie sa apara in meniu (in apropierea link-ului)
|
||||
idx = html.find('href="/?tab=mapari"')
|
||||
assert idx != -1
|
||||
# Cauta "Mapari" in fereastra contextului link-ului
|
||||
window = html[max(0, idx - 50):idx + 100]
|
||||
assert "Mapari" in window, (
|
||||
f"Textul 'Mapari' trebuie sa apara langa href=/?tab=mapari: ...{window}..."
|
||||
)
|
||||
|
||||
|
||||
def test_meniu_badge_needs_mapping(client):
|
||||
"""Badge vizibil in meniu cand exista submissions needs_mapping."""
|
||||
acct = _create_account_user("menu2@test.com")
|
||||
_ins_needs_mapping(acct)
|
||||
_login(client, "menu2@test.com")
|
||||
|
||||
html = client.get("/").text
|
||||
|
||||
# Badgeul trebuie sa apara in apropierea intrarii Mapari
|
||||
idx = html.find('href="/?tab=mapari"')
|
||||
assert idx != -1, "Intrarea Mapari lipseste din meniu"
|
||||
# Cauta tab-badge in contextul intrarii Mapari (in tag-ul/blocul imediat urmator)
|
||||
window = html[idx:idx + 300]
|
||||
assert "tab-badge" in window, (
|
||||
"Badgeul (tab-badge) trebuie sa apara in intrarea Mapari cand exista needs_mapping"
|
||||
)
|
||||
|
||||
|
||||
def test_pagina_principala_fara_tabbar_mapari(client):
|
||||
"""Pagina principala / nu mai are role=tablist (tab-bar-ul eliminat in US-009)."""
|
||||
_create_account_user("menu3@test.com")
|
||||
_login(client, "menu3@test.com")
|
||||
|
||||
html = client.get("/").text
|
||||
|
||||
assert 'role="tablist"' not in html, (
|
||||
"Tab-bar-ul (role=tablist) trebuie eliminat din pagina principala (US-009)"
|
||||
)
|
||||
# Nici rolul de tab individual nu trebuie sa existe in tab-bar
|
||||
# (role=tab poate exista in alte contexte, dar tab-bar-ul tablist+tab nu)
|
||||
assert 'class="tab-bar"' not in html, (
|
||||
"Clasa CSS tab-bar trebuie eliminata din pagina principala (US-009)"
|
||||
)
|
||||
|
||||
|
||||
def test_ruta_mapari_randeaza_sectiunea(client):
|
||||
"""GET /?tab=mapari → 200, sectiunea mapari randata, fara tablist rezidual."""
|
||||
_create_account_user("menu4@test.com")
|
||||
_login(client, "menu4@test.com")
|
||||
|
||||
resp = client.get("/?tab=mapari")
|
||||
|
||||
assert resp.status_code == 200, (
|
||||
f"/?tab=mapari trebuie sa returneze 200, nu {resp.status_code}"
|
||||
)
|
||||
html = resp.text
|
||||
assert 'id="mapari-section"' in html, (
|
||||
"Sectiunea mapari (id='mapari-section') trebuie randata la /?tab=mapari"
|
||||
)
|
||||
assert 'role="tablist"' not in html, (
|
||||
"Tab-bar-ul (role=tablist) nu trebuie sa apara nici la /?tab=mapari"
|
||||
)
|
||||
182
tests/test_web_paginare_submissions.py
Normal file
182
tests/test_web_paginare_submissions.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Teste US-004 (PRD 5.10): paginare numerotata pe tabelul de trimiteri.
|
||||
|
||||
Cazuri:
|
||||
- pagina_implicita_25: 30 trimiteri → pagina 1 afiseaza max 25
|
||||
- pagina_2_offset: 30 trimiteri, page=2 → 5 randuri
|
||||
- total_si_numar_pagini: raspunsul contine totalul + aria-current pe pagina curenta
|
||||
- paginarea_pastreaza_filtrele: linkurile de paginare includ filtrul status activ
|
||||
- pagina_peste_total_revine_la_ultima: page=99 cu 30 trimiteri → clamped la pagina 2
|
||||
- poll_pastreaza_pagina: raspunsul include id='f-page' value='2' (OOB) pentru poll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _ins_n(acct: int, n: int, status: str = "sent") -> None:
|
||||
"""Insereaza n submissions pentru contul dat."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
for i in range(n):
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
f"k-pg-{os.urandom(6).hex()}", acct, status,
|
||||
json.dumps({
|
||||
"vin": f"WVIN_PG_{i:04d}_DUMMY",
|
||||
"nr_inmatriculare": f"B{i:03d}PG",
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "100",
|
||||
"prestatii": [{"cod_prestatie": "R-X"}],
|
||||
}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "paginare.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_pagina_implicita_25(client):
|
||||
"""Cu 30 trimiteri, pagina 1 (implicita) returneaza exact 25 randuri."""
|
||||
acct = _create_account_user("pg1@test.com")
|
||||
_ins_n(acct, 30)
|
||||
_login(client, "pg1@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
row_count = body.count('id="trimitere-row-')
|
||||
assert row_count == 25, (
|
||||
f"Pagina 1 cu 30 trimiteri trebuie sa arate exact 25 randuri, nu {row_count}"
|
||||
)
|
||||
|
||||
|
||||
def test_pagina_2_offset(client):
|
||||
"""Cu 30 trimiteri, page=2 returneaza restul de 5 randuri (offset 25)."""
|
||||
acct = _create_account_user("pg2@test.com")
|
||||
_ins_n(acct, 30)
|
||||
_login(client, "pg2@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?page=2")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
row_count = body.count('id="trimitere-row-')
|
||||
assert row_count == 5, (
|
||||
f"Pagina 2 cu 30 total trebuie sa arate 5 randuri, nu {row_count}"
|
||||
)
|
||||
|
||||
|
||||
def test_total_si_numar_pagini(client):
|
||||
"""Raspunsul contine totalul (30) si marcheaza pagina curenta cu aria-current='page'."""
|
||||
acct = _create_account_user("pg3@test.com")
|
||||
_ins_n(acct, 30)
|
||||
_login(client, "pg3@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
# Totalul trebuie afisat undeva (ex. "30 din 30" sau "afiseaza 1-25 din 30")
|
||||
assert "30" in body, "Totalul 30 trebuie sa apara in raspuns"
|
||||
# Pagina curenta e marcata semantic
|
||||
assert 'aria-current="page"' in body, (
|
||||
"Pagina curenta trebuie marcata cu aria-current='page'"
|
||||
)
|
||||
|
||||
|
||||
def test_paginarea_pastreaza_filtrele(client):
|
||||
"""Linkurile de paginare pastreaza filtrul status activ in URL."""
|
||||
acct = _create_account_user("pg4@test.com")
|
||||
_ins_n(acct, 30, status="needs_data")
|
||||
_login(client, "pg4@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?status=needs_data&page=1")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
# Pager-ul exista si linkurile contin status=needs_data
|
||||
assert "status=needs_data" in body, (
|
||||
"Linkurile de paginare trebuie sa pastreze filtrul status=needs_data"
|
||||
)
|
||||
|
||||
|
||||
def test_pagina_peste_total_revine_la_ultima(client):
|
||||
"""page=99 cu 30 trimiteri se clampeaza la ultima pagina (page 2 → 5 randuri)."""
|
||||
acct = _create_account_user("pg5@test.com")
|
||||
_ins_n(acct, 30)
|
||||
_login(client, "pg5@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?page=99")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
row_count = body.count('id="trimitere-row-')
|
||||
assert row_count == 5, (
|
||||
f"page=99 cu 30 trimiteri trebuie clamped la ultima pagina (5 randuri), nu {row_count}"
|
||||
)
|
||||
|
||||
|
||||
def test_poll_pastreaza_pagina(client):
|
||||
"""Raspunsul de la page=2 include id='f-page' value='2' (OOB swap) pentru poll.
|
||||
|
||||
Mecanismul: _submissions.html include un element cu id='f-page' si hx-swap-oob='true'
|
||||
care actualizeaza inputul ascuns din #filtre-trimiteri. Poll-ul de 15s (hx-include=
|
||||
'#filtre-trimiteri') include astfel pagina curenta la urmatoarea iteratie (L2 PRD).
|
||||
"""
|
||||
acct = _create_account_user("pg6@test.com")
|
||||
_ins_n(acct, 30)
|
||||
_login(client, "pg6@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?page=2")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
# Elementul OOB trebuie sa fie in raspuns cu valoarea corecta
|
||||
assert 'id="f-page"' in body, (
|
||||
"Raspunsul trebuie sa includa id='f-page' (OOB swap) pentru ca poll-ul sa pastreze pagina"
|
||||
)
|
||||
assert 'value="2"' in body, (
|
||||
"Elementul f-page trebuie sa aiba value='2' cand page=2"
|
||||
)
|
||||
172
tests/test_web_pill_filtre.py
Normal file
172
tests/test_web_pill_filtre.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Teste US-003 (PRD 5.10): pill-uri de filtrare per categorie in bara de status.
|
||||
|
||||
Cazuri:
|
||||
- pill_per_categorie_cu_numar: pill-uri <button> cu numarul corect per categorie
|
||||
- pill_click_seteaza_status: pill-urile au atributele HTMX corecte (status=X, aria-pressed)
|
||||
- fara_lista_id_uri: lista de ID-uri/VIN-uri nu mai apare in bara de status
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _ins(acct: int, *, status: str, vin: str = "WVIN000000000001", nr: str = "B001") -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
f"k-{os.urandom(5).hex()}", acct, status,
|
||||
json.dumps({"vin": vin, "nr_inmatriculare": nr, "data_prestatie": "2026-06-20",
|
||||
"odometru_final": "100", "prestatii": [{"cod_prestatie": "R-X"}]}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid # type: ignore[return-value]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "pill.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_pill_per_categorie_cu_numar(client):
|
||||
"""Bara de status afiseaza pill-uri <button> cu numarul corect per categorie blocata."""
|
||||
acct = _create_account_user("pill1@test.com")
|
||||
# 2x needs_data, 1x needs_mapping, 1x error, 1x sent (fara pill)
|
||||
_ins(acct, status="needs_data", vin="WVIN_ND1_001", nr="BND1")
|
||||
_ins(acct, status="needs_data", vin="WVIN_ND1_002", nr="BND2")
|
||||
_ins(acct, status="needs_mapping", vin="WVIN_NM1_001", nr="BNM1")
|
||||
_ins(acct, status="error", vin="WVIN_ER1_001", nr="BER1")
|
||||
_ins(acct, status="sent", vin="WVIN_SE1_001", nr="BSE1")
|
||||
_login(client, "pill1@test.com")
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
|
||||
# Pill-urile sunt elemente <button> (nu <span onclick>)
|
||||
assert "<button" in body, "Pill-urile trebuie sa fie elemente <button>"
|
||||
|
||||
# Fiecare categorie problemativa apare ca pill
|
||||
assert "needs_data" in body, "Pill needs_data trebuie sa apara"
|
||||
assert "needs_mapping" in body, "Pill needs_mapping trebuie sa apara"
|
||||
assert "error" in body, "Pill error trebuie sa apara (hx-get sau text)"
|
||||
|
||||
# Contoarele sunt afisate in pill-uri
|
||||
assert ">2<" in body or "2<" in body, "Contorul 2 pt needs_data trebuie vizibil in pill"
|
||||
|
||||
# Starea 'sent' nu produce pill (nu e categorie de problema)
|
||||
# (nu exista un pill cu status=sent in bara de status)
|
||||
pill_sent_count = body.count("status=sent")
|
||||
assert pill_sent_count == 0, "Nu trebuie pill pentru sent in bara de status"
|
||||
|
||||
|
||||
def test_pill_click_seteaza_status(client):
|
||||
"""Pill-urile au atributele HTMX corecte: hx-get cu status=X si aria-pressed."""
|
||||
acct = _create_account_user("pill2@test.com")
|
||||
_ins(acct, status="needs_data", vin="WVIN_ND2_001", nr="BND_P2a")
|
||||
_ins(acct, status="needs_mapping", vin="WVIN_NM2_001", nr="BNM_P2a")
|
||||
_ins(acct, status="error", vin="WVIN_ER2_001", nr="BER_P2a")
|
||||
_login(client, "pill2@test.com")
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
|
||||
# Fiecare pill are atribut hx-get cu parametrul status corespunzator
|
||||
assert "status=needs_data" in body, "Pill needs_data trebuie sa aiba ?status=needs_data in hx-get"
|
||||
assert "status=needs_mapping" in body, "Pill needs_mapping trebuie sa aiba ?status=needs_mapping in hx-get"
|
||||
assert "status=error" in body, "Pill error trebuie sa aiba ?status=error in hx-get"
|
||||
|
||||
# Pill-urile au aria-pressed pentru accesibilitate (WCAG)
|
||||
assert "aria-pressed" in body, "Pill-urile trebuie sa aiba atribut aria-pressed"
|
||||
|
||||
# Target-ul este tabelul de trimiteri
|
||||
assert "submissions-wrap" in body or "_fragments/submissions" in body, (
|
||||
"Pill-urile trebuie sa targeteze #submissions-wrap sau sa apeleze /_fragments/submissions"
|
||||
)
|
||||
|
||||
|
||||
def test_fara_lista_id_uri(client):
|
||||
"""Lista de ID-uri/VIN-uri (ex. #1 WVIN... / B...) nu mai apare in bara de status."""
|
||||
acct = _create_account_user("pill3@test.com")
|
||||
sid = _ins(acct, status="needs_data", vin="WVIN_ND3_UNIC001", nr="BND_P3")
|
||||
_login(client, "pill3@test.com")
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
|
||||
# Structura <li class="muted"> cu randuri blocate a disparut
|
||||
assert '<li class="muted"' not in body, (
|
||||
"Structura <li class='muted'> cu randuri blocate trebuie eliminata din bara de status"
|
||||
)
|
||||
# VIN-ul complet nu mai apare in bara de status (era partial, dar nici partial nu mai vrem)
|
||||
assert "WVIN_ND3_UNIC001" not in body, (
|
||||
"VIN-ul (sau partial) nu trebuie sa mai apara in bara de status"
|
||||
)
|
||||
|
||||
|
||||
def test_pill_needs_mapping_culoare_warn(client):
|
||||
"""Pill-ul 'Lipsa cod' (needs_mapping) foloseste --warn (chihlimbar), nu --err (DESIGN.md).
|
||||
|
||||
DESIGN.md §Componente: 'Lipsa cod = --warn'. Celelalte categorii (needs_data, error) = --err.
|
||||
"""
|
||||
acct = _create_account_user("warn@test.com")
|
||||
# Inseram DOAR needs_mapping — pentru a izola culoarea si a nu confunda cu --err
|
||||
_ins(acct, status="needs_mapping", vin="WVIN_NM_WARN0001", nr="BNMW1")
|
||||
_login(client, "warn@test.com")
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
|
||||
# Pill-ul needs_mapping trebuie sa aiba culoarea --warn (nu --err)
|
||||
assert "var(--warn)" in body, (
|
||||
"Pill needs_mapping trebuie sa foloseasca var(--warn) conform DESIGN.md §Componente"
|
||||
)
|
||||
assert "status=needs_mapping" in body, "Pill needs_mapping trebuie sa fie prezent in bara"
|
||||
163
tests/test_web_selector_tema.py
Normal file
163
tests/test_web_selector_tema.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Teste US-014 (PRD 5.10): Selector de tema ciclic Light/Dark/Petrol/Auto.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
|
||||
|
||||
Testeaza:
|
||||
- test_petrol_theme_definit: [data-theme="petrol"] definit cu valorile din DESIGN.md
|
||||
- test_buton_cicleaza_temele: buton ciclic + anti-FOUC extins + aria-live
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "selector.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 _get_style(html: str) -> str:
|
||||
m = re.search(r"<style>(.*?)</style>", html, re.DOTALL)
|
||||
assert m, "<style> negasit in HTML"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _get_head(html: str) -> str:
|
||||
m = re.search(r"<head>(.*?)</head>", html, re.DOTALL | re.IGNORECASE)
|
||||
assert m, "<head> negasit in HTML"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# ── test_petrol_theme_definit ─────────────────────────────────────────────────
|
||||
|
||||
def test_petrol_theme_definit(client):
|
||||
"""[data-theme="petrol"] definit in CSS cu valorile din DESIGN.md:
|
||||
--bg:#0e1416, --card:#161e20, --ink:#e6e9ef, --muted:#8b93a7,
|
||||
--line:#232c2e, --accent:#0E7C7B, --ok:#2FBF8F, --warn:#E0A93B, --err:#E05D5D
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
style = _get_style(resp.text)
|
||||
|
||||
# Bloc CSS [data-theme="petrol"] prezent
|
||||
petrol_m = re.search(
|
||||
r'\[data-theme=["\']petrol["\']\]\s*\{([^}]+)\}',
|
||||
style,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert petrol_m, (
|
||||
'[data-theme="petrol"] { ... } negasit in <style>. '
|
||||
"Tema Petrol trebuie definita conform DESIGN.md."
|
||||
)
|
||||
petrol_block = petrol_m.group(1)
|
||||
|
||||
petrol_vars = {
|
||||
"--accent": "#0E7C7B",
|
||||
"--bg": "#0e1416",
|
||||
"--card": "#161e20",
|
||||
"--ink": "#e6e9ef",
|
||||
"--line": "#232c2e",
|
||||
"--ok": "#2FBF8F",
|
||||
"--warn": "#E0A93B",
|
||||
"--err": "#E05D5D",
|
||||
}
|
||||
for var, val in petrol_vars.items():
|
||||
assert val.lower() in petrol_block.lower(), (
|
||||
f"Variabila {var}:{val} lipseste din [data-theme=\"petrol\"]. "
|
||||
f"Block petrol: {petrol_block.strip()}"
|
||||
)
|
||||
|
||||
|
||||
# ── test_buton_cicleaza_temele ────────────────────────────────────────────────
|
||||
|
||||
def test_buton_cicleaza_temele(client):
|
||||
"""Butonul de tema cicleaza Light->Dark->Petrol->Auto si are accesibilitate completa.
|
||||
|
||||
Verifica:
|
||||
1. Anti-FOUC extins: cunoaste 'petrol' si 'auto'; fallback definit pt. valori legacy
|
||||
2. JS-ul ciclului contine toate cele 4 teme in ordinea corecta
|
||||
3. aria-label pe buton include 'Tema:' + tema curenta + urmatoarea
|
||||
4. Regiune aria-live="polite" prezenta pentru anuntarea schimbarii
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
head = _get_head(html)
|
||||
|
||||
# 1. Anti-FOUC cunoaste 'petrol' si 'auto' (script in <head>, inainte de <style>)
|
||||
style_pos = head.find('<style>')
|
||||
assert style_pos >= 0, "<style> negasit in <head>"
|
||||
head_before_style = head[:style_pos]
|
||||
|
||||
assert 'petrol' in head_before_style, (
|
||||
"Scriptul anti-FOUC nu cunoaste tema 'petrol'. "
|
||||
"Trebuie extins sa enumere toate cele 4 teme (light/dark/petrol/auto)."
|
||||
)
|
||||
assert 'auto' in head_before_style, (
|
||||
"Scriptul anti-FOUC nu cunoaste tema 'auto'. "
|
||||
"Trebuie sa rezolve 'auto' la light/dark inainte de primul paint."
|
||||
)
|
||||
|
||||
# 2. Anti-FOUC are fallback pentru valori legacy/necunoscute (un set de valide)
|
||||
# Acceptam: un obiect/array VALID, sau un if care verifica valorile cunoscute
|
||||
has_valid_guard = (
|
||||
'VALID' in head_before_style
|
||||
or re.search(r'light.*dark.*petrol.*auto', head_before_style, re.DOTALL)
|
||||
or 'indexOf' in head_before_style
|
||||
)
|
||||
assert has_valid_guard, (
|
||||
"Anti-FOUC lipseste de un guard pentru valori legacy/necunoscute. "
|
||||
"O valoare 'localStorage.theme' necunoscuta trebuie sa cada pe 'auto' sau 'dark'."
|
||||
)
|
||||
|
||||
# 3. JS-ul din <body> contine ciclul complet Light->Dark->Petrol->Auto
|
||||
# Cautam in tot HTML-ul (nu doar head) prezenta tuturor celor 4 teme in JS
|
||||
# Acceptam: array explicit ['light','dark','petrol','auto'] sau logic echivalent
|
||||
cycle_match = re.search(
|
||||
r"['\"]light['\"].*['\"]dark['\"].*['\"]petrol['\"].*['\"]auto['\"]",
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert cycle_match, (
|
||||
"Ciclul Light->Dark->Petrol->Auto negasit in JS. "
|
||||
"Asteptat: array sau secventa cu toate cele 4 teme in ordine."
|
||||
)
|
||||
|
||||
# 4. aria-label pe butonul tema-toggle include 'Tema:' (format 'Tema: X, apasa pentru Y')
|
||||
tema_btn = re.search(
|
||||
r'<button[^>]+id=["\']tema-toggle["\'][^>]*>',
|
||||
html,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert tema_btn, "Butonul #tema-toggle negasit in HTML"
|
||||
btn_tag = tema_btn.group(0)
|
||||
# Acceptam aria-label setat initial in HTML SAU prin JS (aria-label din tag poate fi
|
||||
# placeholder; testam ca JS-ul contine formatul corect)
|
||||
has_tema_label = (
|
||||
'Tema:' in html
|
||||
or 'tema:' in html.lower()
|
||||
or re.search(r'aria-label[^>]*[Tt]ema', html)
|
||||
)
|
||||
assert has_tema_label, (
|
||||
"aria-label cu formatul 'Tema: ...' negasit in HTML/JS. "
|
||||
"Butonul trebuie sa anunte tema curenta + urmatoarea."
|
||||
)
|
||||
|
||||
# 5. Regiune aria-live="polite" prezenta (pentru anuntarea schimbarii de tema)
|
||||
assert 'aria-live="polite"' in html or "aria-live='polite'" in html, (
|
||||
'Regiune aria-live="polite" negasita in HTML. '
|
||||
"Necesara pentru a anunta schimbarea temei catre screen-readers."
|
||||
)
|
||||
@@ -137,24 +137,23 @@ def test_status_blocate_defalcare(client):
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Trebuie sa arate titlul grupului de blocate
|
||||
assert "Necesita atentia ta" in html, (
|
||||
f"Fragmentul nu contine 'Necesita atentia ta'. HTML: {html[:800]}"
|
||||
# US-003 (PRD 5.10): Blocatele apar ca pill-uri (nu ca lista cu ID-uri)
|
||||
assert "Necesita atentie" in html, (
|
||||
f"Fragmentul nu contine sectiunea 'Necesita atentie'. HTML: {html[:800]}"
|
||||
)
|
||||
# Trebuie sa arate etichetele umane pe motiv (din STARI_SUBMISSION in labels.py)
|
||||
assert "Lipseste codul prestatiei" in html, (
|
||||
"Fragmentul nu arata eticheta pentru needs_mapping"
|
||||
# Pill-urile au etichetele scurte per categorie (nu etichetele lungi din eticheta_stare)
|
||||
assert "Lipsa cod" in html, (
|
||||
"Fragmentul nu arata pill-ul pentru needs_mapping"
|
||||
)
|
||||
assert "Date incomplete" in html, (
|
||||
"Fragmentul nu arata eticheta pentru needs_data"
|
||||
"Fragmentul nu arata pill-ul pentru needs_data"
|
||||
)
|
||||
assert "Eroare la trimitere" in html, (
|
||||
"Fragmentul nu arata eticheta pentru error"
|
||||
assert "Eroare" in html, (
|
||||
"Fragmentul nu arata pill-ul pentru error"
|
||||
)
|
||||
# Trebuie sa arate numere concrete (2 needs_mapping, 1 needs_data, 1 error)
|
||||
# Verificam ca exista cel putin un numar > 0 langa fiecare eticheta
|
||||
# (nu strict format, ci prezenta datelor)
|
||||
assert "2" in html or "1" in html, "Fragmentul nu arata numarul de submissions blocate"
|
||||
# Pill-urile arata numarul total per categorie (2 needs_mapping, 1 needs_data, 1 error)
|
||||
assert "2" in html, "Pill-ul needs_mapping trebuie sa arate numarul 2"
|
||||
assert "<button" in html, "Pill-urile trebuie sa fie elemente <button>"
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -208,26 +207,33 @@ def _insert_submission_vehicul(status, account_id, vin, nr):
|
||||
|
||||
|
||||
def test_categorie_blocata_linkeaza_la_trimiteri_filtrate(client):
|
||||
"""US-003 (PRD 5.10): pill-ul error are hx-get cu ?status=error.
|
||||
Deep-link-ul tab=acasa&status=error a fost eliminat (pill inlocuieste link-ul vechi)."""
|
||||
acct_id, _ = _create_account_user("link@test.com", "parolasecreta10")
|
||||
_login(client, "link@test.com", "parolasecreta10")
|
||||
_insert_submission("error", acct_id)
|
||||
|
||||
html = client.get("/_fragments/status").text
|
||||
# Link HTMX catre lista filtrata pe error + deep-link server-side
|
||||
# Pill-ul are hx-get cu status=error (filtrare directa submissions)
|
||||
assert "/_fragments/submissions?status=error" in html
|
||||
assert "tab=acasa&status=error" in html
|
||||
# Deep-link-ul tab=acasa&status=error nu mai exista — pill-uri inlocuiesc link-urile
|
||||
assert "tab=acasa&status=error" not in html
|
||||
|
||||
|
||||
def test_status_arata_identificator_rand_blocat(client):
|
||||
def test_status_nu_arata_identificator_rand_blocat(client):
|
||||
"""US-003 (PRD 5.10): VIN/nr inmatriculare nu mai apar in bara de status.
|
||||
Lista de ID-uri a fost inlocuita cu pill-uri cu numar total (fara PII nominal)."""
|
||||
acct_id, _ = _create_account_user("ident@test.com", "parolasecreta10")
|
||||
_login(client, "ident@test.com", "parolasecreta10")
|
||||
_insert_submission_vehicul("error", acct_id, "WVWZZZ1KZAW000123", "B123ABC")
|
||||
|
||||
html = client.get("/_fragments/status").text
|
||||
# VIN partial (ultimele 4) + nr inmatriculare + #id
|
||||
assert "0123" in html, "lipseste VIN partial"
|
||||
assert "B123ABC" in html, "lipseste nr inmatriculare"
|
||||
# Bara de status arata pill cu count, nu lista cu VIN/nr per rand
|
||||
assert "B123ABC" not in html, "Nr inmatriculare nu trebuie sa mai apara in bara de status"
|
||||
assert "WVWZZZ1KZAW000123" not in html, "VIN integral nu trebuie expus"
|
||||
assert "0123" not in html, "VIN partial nu trebuie sa mai apara in bara de status"
|
||||
# Pill-ul cu count 1 apare in locul listei
|
||||
assert "status=error" in html, "Pill error trebuie sa aiba hx-get cu status=error"
|
||||
|
||||
|
||||
def test_scoped_pe_cont(client):
|
||||
|
||||
151
tests/test_web_submissions_layout.py
Normal file
151
tests/test_web_submissions_layout.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Teste US-005 (PRD 5.10): VIN pe rand separat sub numarul de inmatriculare.
|
||||
|
||||
VIN-ul era randat ca <span> inline in aceeasi celula cu nr. Story-ul cere un
|
||||
element block-level (div/small/p cu display:block) sub nr, in stil muted.
|
||||
Testul asserteaza tipul elementului (block), nu doar prezenta textului.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
|
||||
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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _ins(acct: int, *, vin: str = "", nr: str = "B01TST", status: str = "queued") -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
f"k-{os.urandom(5).hex()}", acct, status,
|
||||
json.dumps({
|
||||
"vin": vin,
|
||||
"nr_inmatriculare": nr,
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "100",
|
||||
"prestatii": [{"cod_prestatie": "R-X"}],
|
||||
}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid # type: ignore[return-value]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "layout_test.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_vin_pe_rand_separat_sub_nr(client):
|
||||
"""VIN-ul apare intr-un element block-level (div/p/small cu display:block) sub nr.
|
||||
|
||||
Inainte: <span class="muted">...VIN...</span> inline.
|
||||
Dupa: <div class="muted">...VIN...</div> (block, rand separat).
|
||||
Testul asserteaza prezenta unui element block, nu doar textul.
|
||||
"""
|
||||
acct = _create_account_user("vin_layout@test.com")
|
||||
sid = _ins(acct, vin="WVWZZZ1JZXW000001", nr="B123XYZ")
|
||||
_login(client, "vin_layout@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# VIN trunchiat trebuie sa apara in HTML
|
||||
assert "000001" in html, "VIN-ul trunchiat trebuie sa apara in tabel"
|
||||
|
||||
# Elementul ce contine VIN-ul trebuie sa fie block-level (div, p, small etc.)
|
||||
# NU un simplu <span> inline.
|
||||
# Pattern: <div ... >...000001...</div> sau <p ... >...000001...</p>
|
||||
# Acceptam orice block-level tag (div/p/small) care contine fragmentul VIN.
|
||||
block_tags = ["div", "p", "small"]
|
||||
vin_fragment = "000001"
|
||||
found_block = any(
|
||||
re.search(
|
||||
rf"<{tag}[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</{tag}>",
|
||||
html,
|
||||
)
|
||||
for tag in block_tags
|
||||
)
|
||||
assert found_block, (
|
||||
f"VIN '{vin_fragment}' trebuie sa fie intr-un element block-level "
|
||||
f"(div/p/small), nu intr-un <span> inline. HTML gasit: "
|
||||
+ html[max(0, html.find(vin_fragment) - 80):html.find(vin_fragment) + 80]
|
||||
)
|
||||
|
||||
# Elementul block trebuie sa aiba clasa 'muted' (stil discret)
|
||||
muted_block = any(
|
||||
re.search(
|
||||
rf'<{tag}[^>]*class="[^"]*muted[^"]*"[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</{tag}>',
|
||||
html,
|
||||
)
|
||||
for tag in block_tags
|
||||
)
|
||||
assert muted_block, (
|
||||
f"Elementul block cu VIN trebuie sa aiba clasa 'muted'"
|
||||
)
|
||||
|
||||
|
||||
def test_vin_lipsa_nu_genereaza_rand_gol(client):
|
||||
"""Cand VIN-ul lipseste (sau e EMPTY='—'), nu apare un element gol in celula Vehicul."""
|
||||
acct = _create_account_user("vin_gol@test.com")
|
||||
sid = _ins(acct, vin="", nr="B999TST") # VIN gol -> EMPTY="—"
|
||||
_login(client, "vin_gol@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Randul trebuie sa existe
|
||||
assert f'id="trimitere-row-{sid}"' in html
|
||||
|
||||
# In coloana vehicul nu trebuie sa apara un element block gol cu "—"
|
||||
# (garda != '—' exista deja, verifica ca e respectata)
|
||||
assert 'class="muted"' not in html.split('col-vehicul')[1].split('col-operatie')[0] or \
|
||||
'—' not in (html.split('col-vehicul')[1].split('col-operatie')[0]), \
|
||||
"Elementul muted din coloana Vehicul nu trebuie sa contina '—' (rand gol VIN)"
|
||||
@@ -73,7 +73,7 @@ def client(monkeypatch):
|
||||
# ============================================================
|
||||
|
||||
def test_dashboard_are_tabbar(client):
|
||||
"""US-007 (5.5): tab-bar redus la Acasa + Mapari; Cont/Integrare/Nomenclator in meniul ☰."""
|
||||
"""US-009 (5.10): tab-bar-ul eliminat; Mapari mutat in meniul ☰; rutele raman valide."""
|
||||
_create_account_user("tabbar@test.com", "parolasecreta10")
|
||||
_login(client, "tabbar@test.com", "parolasecreta10")
|
||||
|
||||
@@ -81,16 +81,15 @@ def test_dashboard_are_tabbar(client):
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
assert 'role="tablist"' in html, "Lipseste role=tablist"
|
||||
|
||||
# Doar Acasa + Mapari sunt tab-uri (role="tab")
|
||||
assert re.search(r'role="tab"[^>]*>\s*Acasa', html), "Lipseste tab-ul Acasa"
|
||||
assert re.search(r'role="tab"[^>]*>\s*Mapari', html), "Lipseste tab-ul Mapari"
|
||||
# Cont/Integrare/Nomenclator NU mai sunt tab-uri
|
||||
# US-009: tab-bar-ul (role="tablist") a fost eliminat
|
||||
assert 'role="tablist"' not in html, "Tab-bar-ul (role=tablist) trebuie eliminat (US-009)"
|
||||
# Cont/Integrare/Nomenclator raman in meniu, nu ca tab-uri
|
||||
for label in ("Cont", "Integrare", "Nomenclator", "Import"):
|
||||
assert not re.search(rf'role="tab"[^>]*>\s*{label}\s*<', html), \
|
||||
f"'{label}' nu ar mai trebui sa fie un tab separat (mutat in meniu)"
|
||||
# ...dar traiesc in meniul de cont
|
||||
f"'{label}' nu ar mai trebui sa fie un tab separat"
|
||||
# Mapari e acum in meniu (nu tab), cu link valid
|
||||
assert 'href="/?tab=mapari"' in html, "Lipseste link Mapari din meniu"
|
||||
# Cont/Nomenclator raman in meniu
|
||||
assert 'href="/?tab=cont"' in html and 'href="/?tab=nomenclator"' in html
|
||||
|
||||
|
||||
@@ -99,7 +98,7 @@ def test_dashboard_are_tabbar(client):
|
||||
# ============================================================
|
||||
|
||||
def test_tab_implicit_acasa(client):
|
||||
"""Fara ?tab=, tab-ul Acasa are aria-selected=true."""
|
||||
"""US-009: fara ?tab=, pagina principala randeaza continutul Acasa (upload + sectiuni)."""
|
||||
_create_account_user("implicit@test.com", "parolasecreta10")
|
||||
_login(client, "implicit@test.com", "parolasecreta10")
|
||||
|
||||
@@ -107,13 +106,11 @@ def test_tab_implicit_acasa(client):
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Tab-ul activ trebuie sa aiba aria-selected="true"
|
||||
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
|
||||
|
||||
# Verificam ca Acasa e cel cu aria-selected=true
|
||||
# Cautam un fragment care contine atat Acasa cat si aria-selected="true" in proximitate
|
||||
assert re.search(r'aria-selected="true"[^>]*>.*?Acasa|Acasa.*?aria-selected="true"', html, re.DOTALL), \
|
||||
"Tab-ul Acasa nu are aria-selected=true"
|
||||
# US-009: tab-bar eliminat, deci nu mai exista aria-selected pe tab-uri
|
||||
assert 'role="tablist"' not in html, "Tab-bar-ul trebuie eliminat (US-009)"
|
||||
# Continutul Acasa (status-bar + tab-panel cu continut Acasa) e randat direct
|
||||
assert 'id="status-bar"' in html, "Status-bar-ul trebuie sa fie prezent"
|
||||
assert 'id="tab-panel"' in html, "Panoul de continut (tab-panel) trebuie sa fie prezent"
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -140,23 +137,22 @@ def test_deeplink_tab_import(client):
|
||||
# ============================================================
|
||||
|
||||
def test_tab_activ_randat_server_side(client):
|
||||
"""Panoul activ e in HTML-ul initial, nu doar cerut prin HTMX dupa load."""
|
||||
"""Panoul activ e in HTML-ul initial, randat server-side (nu doar HTMX dupa load)."""
|
||||
_create_account_user("serverside@test.com", "parolasecreta10")
|
||||
_login(client, "serverside@test.com", "parolasecreta10")
|
||||
|
||||
# Tab-ul implicit (Acasa) trebuie sa fie randat server-side
|
||||
# Acasa e randat server-side
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Panoul trebuie sa aiba role="tabpanel"
|
||||
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel in HTML initial"
|
||||
# US-009: role="tabpanel" eliminat; continutul e in div#tab-panel fara rol ARIA de tabpanel
|
||||
assert 'id="tab-panel"' in html, "Containerul de continut tab-panel trebuie sa existe"
|
||||
assert 'role="tabpanel"' not in html, "role=tabpanel trebuie eliminat (US-009)"
|
||||
|
||||
# Import tab server-side
|
||||
# Import tab server-side: ?tab=import randeaza direct continutul Import
|
||||
resp2 = client.get("/?tab=import")
|
||||
assert resp2.status_code == 200
|
||||
html2 = resp2.text
|
||||
# Continutul Import trebuie sa fie randat direct, nu prin hx-trigger=load pe panoul inactiv
|
||||
assert 'id="import-section"' in html2, "Panoul Import nu e randat server-side la ?tab=import"
|
||||
|
||||
|
||||
@@ -205,7 +201,7 @@ def test_fragmentele_inactive_lazy(client):
|
||||
# ============================================================
|
||||
|
||||
def test_tabbar_aria(client):
|
||||
"""Prezenta atributelor ARIA: role=tablist/tab/tabpanel, aria-selected."""
|
||||
"""US-009: schela ARIA orfana (role=tablist/tab/tabpanel/aria-selected) a fost eliminata."""
|
||||
_create_account_user("aria@test.com", "parolasecreta10")
|
||||
_login(client, "aria@test.com", "parolasecreta10")
|
||||
|
||||
@@ -213,11 +209,14 @@ def test_tabbar_aria(client):
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
assert 'role="tablist"' in html, "Lipseste role=tablist"
|
||||
assert 'role="tab"' in html, "Lipseste role=tab"
|
||||
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel"
|
||||
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
|
||||
assert 'aria-selected="false"' in html, "Lipseste aria-selected=false pe tab-urile inactive"
|
||||
# US-009: un role="tablist" cu un singur tab e violare ARIA → eliminat
|
||||
assert 'role="tablist"' not in html, "role=tablist trebuie eliminat (US-009)"
|
||||
assert 'role="tab"' not in html, "role=tab trebuie eliminat (tab-bar eliminat)"
|
||||
assert 'role="tabpanel"' not in html, "role=tabpanel trebuie eliminat (tab-bar eliminat)"
|
||||
assert 'aria-selected=' not in html, "aria-selected trebuie eliminat (fara tab-uri)"
|
||||
# Meniu cont (role="menu") si item-urile sale (role="menuitem") raman valide
|
||||
assert 'role="menu"' in html, "Meniul hamburger (role=menu) trebuie pastrat"
|
||||
assert 'role="menuitem"' in html, "Intrarile meniului (role=menuitem) trebuie pastrate"
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
256
tests/test_web_tema_culori.py
Normal file
256
tests/test_web_tema_culori.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Teste US-013 (PRD 5.10): Tema de culori ROMFAST (accent azur) + tipografie IBM Plex.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
|
||||
|
||||
Testeaza:
|
||||
- test_paleta_accent_azur_definita: accentul azur ROMFAST definit corect in :root si [data-theme="light"]
|
||||
- test_font_ibm_plex_aplicat: IBM Plex Sans + Mono declarate in font-family si @font-face
|
||||
- test_contrast_aa_pe_text_principal: contrast text principal >= 4.5:1 in dark si light
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "culori.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 _get_style_block(html: str) -> str:
|
||||
"""Extrage continutul primului <style> din HTML."""
|
||||
m = re.search(r"<style>(.*?)</style>", html, re.DOTALL)
|
||||
assert m, "<style> negasit in HTML"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _hex_to_srgb(hex_color: str) -> tuple[float, float, float]:
|
||||
"""Converteste hex (#rrggbb) la tuple (r, g, b) in [0,1]."""
|
||||
h = hex_color.lstrip("#")
|
||||
assert len(h) == 6, f"Hex invalid: {hex_color}"
|
||||
r = int(h[0:2], 16) / 255.0
|
||||
g = int(h[2:4], 16) / 255.0
|
||||
b = int(h[4:6], 16) / 255.0
|
||||
return r, g, b
|
||||
|
||||
|
||||
def _linearize(c: float) -> float:
|
||||
"""Liniarizeaza o componenta sRGB pentru calcul luminanta WCAG."""
|
||||
if c <= 0.04045:
|
||||
return c / 12.92
|
||||
return ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def _luminance(hex_color: str) -> float:
|
||||
"""Calculeaza luminanta relativa WCAG 2.1 pentru o culoare hex."""
|
||||
r, g, b = _hex_to_srgb(hex_color)
|
||||
rl = _linearize(r)
|
||||
gl = _linearize(g)
|
||||
bl = _linearize(b)
|
||||
return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl
|
||||
|
||||
|
||||
def _contrast_ratio(c1: str, c2: str) -> float:
|
||||
"""Calculeaza raportul de contrast WCAG 2.1 intre doua culori hex."""
|
||||
l1 = _luminance(c1)
|
||||
l2 = _luminance(c2)
|
||||
lighter = max(l1, l2)
|
||||
darker = min(l1, l2)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
|
||||
# ── test_paleta_accent_azur_definita ─────────────────────────────────────────
|
||||
|
||||
def test_paleta_accent_azur_definita(client):
|
||||
"""Accentul azur ROMFAST definit corect si neutrele actualizate conform DESIGN.md.
|
||||
|
||||
:root (dark default):
|
||||
--accent:#2E74D6
|
||||
--bg:#0f1218 --card:#181c24 --ink:#e6e9ef --muted:#8b93a7 --line:#262b36
|
||||
--ok:#2FBF8F --warn:#E0A93B --err:#E05D5D
|
||||
|
||||
[data-theme="light"]:
|
||||
--accent:#1F66C9
|
||||
--bg:#f5f7fa --card:#ffffff --ink:#1a1d24 --muted:#5c6473 --line:#e2e5ea
|
||||
--ok:#15803d --warn:#b45309 --err:#dc2626
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
style = _get_style_block(resp.text)
|
||||
|
||||
# Paleta dark (:root)
|
||||
dark_vars = {
|
||||
"--accent": "#2E74D6",
|
||||
"--bg": "#0f1218",
|
||||
"--card": "#181c24",
|
||||
"--ink": "#e6e9ef",
|
||||
"--muted": "#8b93a7",
|
||||
"--line": "#262b36",
|
||||
"--ok": "#2FBF8F",
|
||||
"--warn": "#E0A93B",
|
||||
"--err": "#E05D5D",
|
||||
}
|
||||
# Extrage blocul :root
|
||||
root_m = re.search(r":root\s*\{([^}]+)\}", style, re.DOTALL)
|
||||
assert root_m, "Blocul :root negasit in <style>"
|
||||
root_block = root_m.group(1)
|
||||
|
||||
for var, val in dark_vars.items():
|
||||
assert val.lower() in root_block.lower(), (
|
||||
f"Variabila {var}:{val} lipseste sau are valoare gresita in :root (dark). "
|
||||
f"Continut :root: {root_block.strip()}"
|
||||
)
|
||||
|
||||
# Paleta light ([data-theme="light"])
|
||||
light_vars = {
|
||||
"--accent": "#1F66C9",
|
||||
"--bg": "#f5f7fa",
|
||||
"--card": "#ffffff",
|
||||
"--ink": "#1a1d24",
|
||||
"--muted": "#5c6473",
|
||||
"--line": "#e2e5ea",
|
||||
"--ok": "#15803d",
|
||||
"--warn": "#b45309",
|
||||
"--err": "#dc2626",
|
||||
}
|
||||
light_m = re.search(r'\[data-theme=["\']light["\']\]\s*\{([^}]+)\}', style, re.DOTALL)
|
||||
assert light_m, 'Blocul [data-theme="light"] negasit in <style>'
|
||||
light_block = light_m.group(1)
|
||||
|
||||
for var, val in light_vars.items():
|
||||
assert val.lower() in light_block.lower(), (
|
||||
f"Variabila {var}:{val} lipseste sau are valoare gresita in [data-theme=\"light\"]. "
|
||||
f"Continut light: {light_block.strip()}"
|
||||
)
|
||||
|
||||
|
||||
# ── test_font_ibm_plex_aplicat ────────────────────────────────────────────────
|
||||
|
||||
def test_font_ibm_plex_aplicat(client):
|
||||
"""IBM Plex Sans si IBM Plex Mono declarate in font-family si @font-face cu font-display:swap.
|
||||
|
||||
Verifica:
|
||||
- body font-family contine 'IBM Plex Sans' (sau alias ibm-plex-sans)
|
||||
- exista cel putin un @font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono'
|
||||
- @font-face include font-display:swap
|
||||
- @font-face pointeaza spre /static/fonts/
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
style = _get_style_block(resp.text)
|
||||
|
||||
# 1. body font-family contine IBM Plex Sans
|
||||
body_m = re.search(r"body\s*\{([^}]+)\}", style, re.DOTALL)
|
||||
assert body_m, "Regula 'body { ... }' negasita in <style>"
|
||||
body_block = body_m.group(1)
|
||||
assert "IBM Plex Sans" in body_block or "ibm-plex-sans" in body_block.lower(), (
|
||||
f"'IBM Plex Sans' lipseste din font-family al body. body block: {body_block.strip()}"
|
||||
)
|
||||
|
||||
# 2. Exista cel putin un @font-face cu IBM Plex
|
||||
font_face_blocks = re.findall(r"@font-face\s*\{([^}]+)\}", style, re.DOTALL)
|
||||
assert font_face_blocks, "@font-face negasit in <style>"
|
||||
ibm_face = [b for b in font_face_blocks if "IBM Plex" in b or "ibm-plex" in b.lower()]
|
||||
assert ibm_face, (
|
||||
"@font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono' negasit. "
|
||||
f"Blocuri @font-face gasite: {font_face_blocks}"
|
||||
)
|
||||
|
||||
# 3. font-display:swap prezent in cel putin un bloc IBM Plex @font-face
|
||||
swap_present = any("swap" in b.lower() for b in ibm_face)
|
||||
assert swap_present, (
|
||||
"font-display:swap lipseste din @font-face IBM Plex. "
|
||||
f"Blocuri @font-face IBM Plex: {ibm_face}"
|
||||
)
|
||||
|
||||
# 4. @font-face pointeaza spre /static/fonts/
|
||||
fonts_src = any("/static/fonts/" in b for b in ibm_face)
|
||||
assert fonts_src, (
|
||||
"@font-face IBM Plex nu pointeaza spre /static/fonts/. "
|
||||
f"Blocuri: {ibm_face}"
|
||||
)
|
||||
|
||||
# 5. IBM Plex Mono pentru monospace: exista un context monospace cu IBM Plex Mono
|
||||
# (fie @font-face, fie o regula font-family cu monospace)
|
||||
has_mono = any("IBM Plex Mono" in b or "ibm-plex-mono" in b.lower() for b in font_face_blocks)
|
||||
if not has_mono:
|
||||
# Acceptam si daca e in o regula CSS (nu neaparat @font-face)
|
||||
has_mono = "IBM Plex Mono" in style
|
||||
assert has_mono, (
|
||||
"'IBM Plex Mono' lipseste din <style> (trebuie pentru coduri RAR/VIN/nr)."
|
||||
)
|
||||
|
||||
|
||||
# ── test_contrast_aa_pe_text_principal ───────────────────────────────────────
|
||||
|
||||
def test_contrast_aa_pe_text_principal(client):
|
||||
"""Contrastul text principal este >= 4.5:1 in dark si light (WCAG 2.1 AA).
|
||||
|
||||
Dark: --ink:#e6e9ef pe --bg:#0f1218
|
||||
Light: --ink:#1a1d24 pe --bg:#f5f7fa
|
||||
Accent ca text pe alb: #1F66C9 pe #ffffff (WCAG AA pentru text normal)
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
style = _get_style_block(resp.text)
|
||||
|
||||
# Extrage valorile de culoare din :root si [data-theme="light"]
|
||||
def _extract_var(block: str, var_name: str) -> str | None:
|
||||
m = re.search(
|
||||
re.escape(var_name) + r"\s*:\s*(#[0-9a-fA-F]{6})",
|
||||
block,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
return m.group(1) if m else None
|
||||
|
||||
root_m = re.search(r":root\s*\{([^}]+)\}", style, re.DOTALL)
|
||||
assert root_m, "Blocul :root negasit"
|
||||
root_block = root_m.group(1)
|
||||
|
||||
light_m = re.search(r'\[data-theme=["\']light["\']\]\s*\{([^}]+)\}', style, re.DOTALL)
|
||||
assert light_m, 'Blocul [data-theme="light"] negasit'
|
||||
light_block = light_m.group(1)
|
||||
|
||||
# --- Dark: ink pe bg ---
|
||||
dark_ink = _extract_var(root_block, "--ink")
|
||||
dark_bg = _extract_var(root_block, "--bg")
|
||||
assert dark_ink and dark_bg, (
|
||||
f"Nu am putut extrage --ink/{dark_ink} sau --bg/{dark_bg} din :root"
|
||||
)
|
||||
cr_dark = _contrast_ratio(dark_ink, dark_bg)
|
||||
assert cr_dark >= 4.5, (
|
||||
f"Contrast dark insuficient: {dark_ink} pe {dark_bg} = {cr_dark:.2f}:1 (minim 4.5:1 AA)"
|
||||
)
|
||||
|
||||
# --- Light: ink pe bg ---
|
||||
light_ink = _extract_var(light_block, "--ink")
|
||||
light_bg = _extract_var(light_block, "--bg")
|
||||
assert light_ink and light_bg, (
|
||||
f"Nu am putut extrage --ink/{light_ink} sau --bg/{light_bg} din [data-theme=light]"
|
||||
)
|
||||
cr_light = _contrast_ratio(light_ink, light_bg)
|
||||
assert cr_light >= 4.5, (
|
||||
f"Contrast light insuficient: {light_ink} pe {light_bg} = {cr_light:.2f}:1 (minim 4.5:1 AA)"
|
||||
)
|
||||
|
||||
# --- Accent ca text pe alb (tema light) ---
|
||||
light_accent = _extract_var(light_block, "--accent")
|
||||
assert light_accent, f"--accent negasit in [data-theme=light]: {light_block.strip()}"
|
||||
cr_accent_white = _contrast_ratio(light_accent, "#ffffff")
|
||||
assert cr_accent_white >= 4.5, (
|
||||
f"Accent light ({light_accent}) pe alb: contrast {cr_accent_white:.2f}:1 < 4.5:1 AA. "
|
||||
f"Foloseste o varianta mai inchisa (ex. #1F66C9)."
|
||||
)
|
||||
@@ -153,9 +153,8 @@ def test_mapari_ajutor_disclosure_si_fara_proza_inline(client):
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
# panou Ajutor (<details>) prezent
|
||||
assert "ajutor-mapari" in html
|
||||
assert "<details" in html and ">Ajutor<" in html
|
||||
# US-010: sectiunea de ajutor (<details class="ajutor-mapari">) eliminata
|
||||
assert "ajutor-mapari" not in html
|
||||
# antet de coloana compact
|
||||
assert ">In coada<" in html
|
||||
# proza inline veche eliminata de pe sectiuni
|
||||
|
||||
Reference in New Issue
Block a user