Files
rar-autopass/tests/test_web_responsive.py
Claude Agent 8d4ff3400e feat(5.13): carduri compacte mobil/tableta + fix editare preview (OOB tr) + toast
Dogfood pe import + Trimiteri (mobil/tableta <1024px), pur CSS + markup, backend
trimitere neatins:

- Card compact real pentru .tabel-trimiteri (preview + Trimiteri): vehicul=titlu,
  stare=pill dreapta-sus, operatie+cod, meta data/km muted, nota mica. Inlocuieste
  stiva generica eticheta+valoare (carduri de ~450px -> ~135px). Anuleaza regula
  desktop tr.trimitere-row > td{padding:11px} in blocul compact.
- FIX editare preview: OOB swap pe <tr> esua tacit in htmx 1.9 (un <tr> brut se
  pierde la parsarea unui fragment fara context de tabel) -> randul ramanea cu
  starea veche dupa salvare. Inlocuit cu reload complet al preview-ului prin
  HX-Trigger:reincarcaPreview + detalii randSalvat. /editeaza si /confirma-review
  folosesc helper-ul _raspuns_rand_salvat.
- Feedback post-salvare: toast global "Randul N actualizat · <stare>" + scroll +
  flash pe randul actualizat (base.html window.arataToast + listener randSalvat).
- Modal editare: Salveaza + Anuleaza pe acelasi rand (sistem .act): desktop text,
  mobil doua iconite Lucide 44px alaturate (save/x). Macro icon('x') + .act-primary.
- Randuri deja-trimise/duplicate colapsate implicit in preview + toggle "Arata N".
- Select "Operatii de mapat" full-width pe mobil (nu mai iese din viewport).
- Bara de filtre Trimiteri adaptata mobil: pills pe banda cu scroll orizontal,
  cautare vehicul proeminenta (nu 8 butoane full-width stivuite).
- Nota preview = culoarea camp-fix (accent) ca sa atraga atentia; hint-urile
  camp-fix per-camp scoase (campul Note e self-explanatory).
- Confirmare trimitere: scos campul email (Declarant); text mai clar
  ("Confirma numarul din N gata de trimis"). Backend confirmed_by ramane optional.

Teste: contractul OOB (rupt in browser) inlocuit cu noul contract
(reincarcaPreview + randSalvat) in test_web_preview_edit / test_preview_edit_ui /
test_import_review. Suita: 992 passed (exclus live).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:34:33 +00:00

503 lines
21 KiB
Python

"""Teste PRD 5.9 US-006: fundatie responsive — viewport, header/nav colapsabil,
modal full-screen pe mobil, breakpoint-uri consistente.
Verificam markup-ul + CSS server-side randat (TestClient): prezenta meta viewport,
existenta unei reguli `@media (max-width:767px)` care trece modalul pe full-screen
(`.modal-dialog`) cu buton `x` >=44px, si structura de nav colapsabil (meniul de cont
hamburger + tinte touch >=44px). Verificarile vizuale efective (375px, fara scroll
orizontal) sunt deferate la VERIFY (gstack browser).
"""
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 _insert_submission(acct: int, status: str = "needs_data") -> int:
from app.db import get_connection
conn = get_connection()
try:
p = {
"vin": "WVWZZZ1JZXW000888",
"nr_inmatriculare": "B888ZZZ",
"data_prestatie": "2026-06-18",
"odometru_final": "55000",
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}],
}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"k-{status}-{os.urandom(4).hex()}", acct, status, json.dumps(p)),
)
conn.commit()
return int(cur.lastrowid)
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 _bloc_mobil_principal(html: str) -> str:
"""Felie din CSS de la sentinel-ul blocului mobil principal pana la `</style>`."""
i = html.find("SENTINEL-TESTE-MOBIL")
assert i != -1, "Lipseste SENTINEL-TESTE-MOBIL in base.html (vezi PRD 5.13 Wave 0)"
j = html.find("</style>", i)
return html[i:(j if j != -1 else len(html))]
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "responsive.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_viewport_meta_prezent(client):
"""`<meta name=viewport>` cu width=device-width prezent in base.html."""
_create_account_user("vp@test.com")
_login(client, "vp@test.com")
html = client.get("/?tab=acasa").text
assert 'name="viewport"' in html
assert "width=device-width" in html
assert "initial-scale=1" in html
def test_modal_fullscreen_clasa_mobil(client):
"""Sub 768px modalul devine full-screen: o regula `@media (max-width:767px)`
pune `.modal-dialog` la latime/inaltime pline (fara latimea marginita de desktop)
si butonul `x` la >=44px. Desktop pastreaza regula centrata (`max-width:680px`)."""
_create_account_user("mf@test.com")
_login(client, "mf@test.com")
html = client.get("/?tab=acasa").text
# Regula de baza (desktop) ramane: dialog centrat cu latime marginita.
assert "max-width:680px" in html
# Exista un bloc media mobil care vizeaza modalul.
assert "@media (max-width:767px)" in html
# Dialogul ocupa tot ecranul pe mobil (latime/inaltime pline, fara border-radius lateral).
mobil = html[html.find("@media (max-width:767px)"):]
assert "100vw" in mobil or "width:100%" in mobil
assert "100vh" in mobil
# Butonul de inchidere >=44px pe mobil (tinta touch).
assert "44px" in mobil
def test_nav_colapsabil_sub_breakpoint(client):
"""Nav colapsabil: meniul de cont e un buton hamburger (☰) ascuns intr-un dropdown,
iar tintele touch (icon-btn, tab-link, itemi meniu) ajung la >=44px sub breakpoint."""
_create_account_user("nav@test.com")
_login(client, "nav@test.com")
html = client.get("/?tab=acasa").text
# Hamburger-ul de cont exista si dropdown-ul e colapsat (hidden) implicit.
assert 'id="cont-menu-toggle"' in html
assert "&#9776;" in html # pictograma hamburger
assert 'id="cont-menu"' in html
assert 'class="cont-menu"' in html
# Tab-bar-ul (nav principal) exista si e scrollabil orizontal (nu deborda pagina).
assert "tab-bar" in html
assert "overflow-x:auto" in html
# Sub breakpoint, tintele touch din header/nav cresc la >=44px.
mobil = html[html.find("@media (max-width:767px)"):]
assert "44px" in mobil
# ============================================================
# PRD 5.9 US-007: responsive paginile de continut
# (Mapari, Cont, Nomenclator, Integrare, Jurnal, Admin)
# ============================================================
def _seed_nomenclator(items):
from app.mapping import upsert_nomenclator
from app.db import get_connection
conn = get_connection()
try:
upsert_nomenclator(conn, items)
conn.commit()
finally:
conn.close()
def _seed_event(account_id, tip="test", mesaj="eveniment de test"):
from app.observ import log_event
from app.db import get_connection
conn = get_connection()
try:
log_event(tip, account_id=account_id, mesaj=mesaj, conn=conn)
conn.commit()
finally:
conn.close()
def test_tabele_continut_au_clasa_responsive(client):
"""R12 politica per-tabel:
- Mapari (actionabil) = CARD per rand: clasa proprie `.tabel-card` + `data-eticheta` pe `<td>`-uri.
- Jurnal / Nomenclator (dense read-only) = `.tablewrap` (scroll orizontal CONTAINED),
FARA `.tabel-card`.
Definitia regulii de card traieste in base.html, scopata SEPARAT de `.tabel-trimiteri`.
"""
acct = _create_account_user("t7@test.com")
_login(client, "t7@test.com")
# --- Mapari = card. Tabelul „Reguli automate (text)" e mereu randat (rand de adaugare),
# deci nu depinde de date seedate. ---
mapari = client.get("/?tab=mapari").text
assert "tabel-card" in mapari
assert 'data-eticheta="Cod RAR"' in mapari
assert 'data-eticheta="Daca operatia contine"' in mapari
# Regula de card e definita o data in base.html, scopata pe `.tabel-card`.
assert ".tabel-card thead" in mapari
# --- Jurnal = scroll contained. Cu un eveniment seedat, tabelul apare in `.tablewrap`,
# NU ca `.tabel-card`. ---
_seed_event(acct)
jurnal = client.get("/?tab=jurnal").text
assert "tablewrap" in jurnal
# Sectiunea de jurnal NU foloseste cardul actionabil.
body = jurnal[jurnal.find('id="jurnal-section"'):]
assert "tabel-card" not in body
# --- Nomenclator = scroll contained (dens read-only). ---
_seed_nomenclator([{"codPrestatie": "OE-2", "numePrestatie": "Revizie"}])
nomen = client.get("/?tab=nomenclator").text
assert "tablewrap" in nomen
def test_formulare_full_width_mobil(client):
"""Formularele de continut (Cont, Integrare test, filtre Jurnal) stiveaza pe o coloana
sub 767px: inputuri full-width + butoane >=44px. Verificam ancorele de scope (id-uri)
plus regulile CSS mobil din base.html (toate scopate sub `@media (max-width:767px)`)."""
_create_account_user("f7@test.com")
_login(client, "f7@test.com")
cont = client.get("/?tab=cont").text
assert 'id="card-cont"' in cont
# Regulile mobil de formular exista si sunt scopate pe sectiunile de continut.
mobil = cont[cont.find("@media (max-width:767px)"):]
assert "#card-cont button" in mobil
assert "min-height:44px" in mobil
assert "width:100% !important" in mobil # suprascrie latimile inline doar pe mobil
integrare = client.get("/?tab=integrare").text
assert 'id="form-test-cheie"' in integrare
# Filtrele de jurnal (form mereu prezent, indiferent de date) primesc scope-ul mobil.
jurnal = client.get("/?tab=jurnal").text
assert 'id="filtre-jurnal"' in jurnal
assert "#jurnal-section #filtre-jurnal button" in jurnal
def test_carduri_trimiteri_5_8_supravietuiesc(client):
"""Regresie R12: scoparea `.tabel-card` (US-007) NU trebuie sa atinga blocul
`.tabel-trimiteri @media(max-width:767px)` din 5.8 — cardurile de trimiteri raman."""
_create_account_user("r58@test.com")
_login(client, "r58@test.com")
html = client.get("/?tab=acasa").text
mobil = html[html.find("@media (max-width:767px)"):]
# Cardurile de trimiteri 5.8 (clasa proprie, separata de `.tabel-card`).
assert ".tabel-trimiteri thead { display:none; }" in mobil
assert ".tabel-trimiteri td::before" in mobil
# Cele doua mecanisme coexista, scopate distinct.
assert ".tabel-card thead" in mobil
# ============================================================
# PRD 5.9 US-008: responsive Acasa (upload, status, filtre) + login/signup
# ============================================================
def test_acasa_fara_scroll_orizontal_mobil(client):
"""US-008: pe Acasa sub 767px zona de upload, bara de status si bara de filtre
stiveaza pe O coloana, cu inputuri/butoane full-width >=44px. Verificam ancorele
de scope (id-uri) + regulile CSS mobil din base.html (toate sub `@media (max-width:767px)`),
fara sa atingem cardurile de trimiteri 5.8 (verificate separat)."""
acct = _create_account_user("a8@test.com")
_insert_submission(acct) # sectiunea Trimiteri (filtre + wrap) apare doar cu randuri
_login(client, "a8@test.com")
html = client.get("/?tab=acasa").text
# Ancore de scope prezente in markup.
assert 'id="import-section"' in html
assert 'id="status-bar"' in html
assert 'id="filtre-trimiteri"' in html
mobil = html[html.find("@media (max-width:767px)"):]
# Bara de upload: zona de drop trece pe coloana, butonul de alegere full-width.
assert "#import-section .drop-zone" in mobil
assert "#import-section #upload-btn" in mobil
# Bara de filtre: o coloana, controale full-width, buton >=44px.
assert "#filtre-trimiteri" in mobil
filtre = mobil[mobil.find("#filtre-trimiteri"):]
assert "width:100% !important" in filtre # suprascrie latimile inline (max-width:180px etc.)
assert "min-height:44px" in mobil
# Bara de status stiveaza pe coloana (scope dedicat).
assert "#status-bar" in mobil
def test_login_signup_full_width_mobil(client):
"""US-008: login.html si signup.html randeaza un card centrat (`.auth-card`, margin auto)
care nu depaseste latimea pe mobil (max-width:100% sub 767px), cu inputuri full-width.
Rutele `/login` si `/signup` sunt publice (fara autentificare)."""
for ruta in ("/login", "/signup"):
html = client.get(ruta).text
# Card de autentificare marcat si centrat.
assert "auth-card" in html, ruta
assert "margin:40px auto" in html or "margin:24px auto" in html, ruta
# Inputurile sunt full-width.
assert "width:100%" in html, ruta
# Regula mobil: cardul nu depaseste viewport-ul.
mobil = html[html.find("@media (max-width:767px)"):]
assert ".auth-card" in mobil, ruta
# ============================================================
# PRD 5.12 US-008: responsive tableta+mobil + header fara suprapuneri
# ============================================================
def test_header_are_breakpoint_tableta(client):
"""US-008 RED: exista reguli @media intre 768 si 1024 pentru header.
Desktop: grid 3-coloane + min-height:92px. Pe tableta (768-1024px) nu exista
inca breakpoint — logo+titlu+badge+tema+versiune+hamburger se inghesuie si se suprapun."""
_create_account_user("bt@test.com")
_login(client, "bt@test.com")
html = client.get("/?tab=acasa").text
# Bloc media dedicat tabletei (range min-width:768 si max-width:1024px).
assert "@media (min-width:768px) and (max-width:1024px)" in html, \
"Lipseste blocul @media tableta (min-width:768px) and (max-width:1024px)"
# Blocul tableta contine reguli pentru header sau elemente de header.
idx = html.find("@media (min-width:768px) and (max-width:1024px)")
bloc = html[idx:idx + 800]
assert "header" in bloc or ".brand-logo" in bloc, \
"Blocul tableta nu are reguli pentru header sau .brand-logo"
def test_header_elemente_nu_au_min_height_fix_pe_mobil(client):
"""US-008 RED: header-ul nu forteaza min-height:92px pe tableta si mobil.
Regula de baza (desktop) are min-height:92px; pe tableta (768-1024px)
lipseste resetarea -> inghesuire garantata la ~820px."""
_create_account_user("mh@test.com")
_login(client, "mh@test.com")
html = client.get("/?tab=acasa").text
# Regula de baza desktop are min-height:92px (sa nu dispara).
assert "min-height:92px" in html, "Regula desktop min-height:92px a disparut"
# Blocul tableta (768-1024px) trebuie sa reseteze min-height pe header.
idx_t = html.find("@media (min-width:768px) and (max-width:1024px)")
assert idx_t != -1, "Lipseste blocul @media tableta (768-1024px)"
tableta = html[idx_t:idx_t + 800]
assert "min-height:0" in tableta, \
"Blocul tableta nu reseteaza min-height pentru header"
# Blocul mobil (<768px) reseteaza si el min-height (regresie: nu a disparut).
mobil = _bloc_mobil_principal(html)
assert "min-height:0" in mobil, "Blocul mobil a pierdut resetarea min-height pe header"
def test_modal_full_screen_pe_mobil(client):
"""US-008 D#13 verificare: regula full-screen mobil pentru modal exista in base.html
(@media max-width:767px) si se aplica modalului global prin clasa modal-overlay.
VERIFICA prezenta regulii, NU re-implementa."""
_create_account_user("mfp@test.com")
_login(client, "mfp@test.com")
html = client.get("/?tab=acasa").text
# Regula CSS full-screen exista in blocul @media (max-width:767px) {.
mobil = _bloc_mobil_principal(html)
assert "100vw" in mobil, "Dialogul nu are latime 100vw pe mobil"
assert "100vh" in mobil, "Dialogul nu are inaltime 100vh pe mobil"
# Butonul de inchidere >=44px (tinta touch) pe mobil.
assert "44px" in mobil, "Butonul modal-close nu are tinta touch 44px pe mobil"
# Modalul global din HTML foloseste clasa modal-overlay -> prinde regula CSS.
assert 'class="modal-overlay"' in html, \
"Modalul global nu are class=modal-overlay (nu prinde regula full-screen)"
# Target swap pentru editare preview (US-006) exista in DOM.
assert 'id="detaliu-modal-body"' in html, \
"Target #detaliu-modal-body lipseste din base.html"
# ============================================================
# PRD 5.13: guard-uri responsive card mobil + sistem actiuni + stepper compact
# ============================================================
def test_card_mobil_fara_break_vertical_120px(client):
"""P0: blocul mobil principal NU mai forteaza min-width:120px pe eticheta
(cauza break-ului vertical caracter-cu-caracter). Eticheta stivuita deasupra
valorii: td::before cu display:block. Checkbox + # ascunse pe card."""
_create_account_user("card120@test.com")
_login(client, "card120@test.com")
html = client.get("/?tab=acasa").text
bloc = _bloc_mobil_principal(html)
# min-width:120px nu mai exista in blocul mobil.
assert "min-width:120px" not in bloc, \
"min-width:120px inca prezent in blocul mobil — cauzeaza break vertical"
# td::before stivuieste eticheta deasupra valorii (display:block).
assert ".tabel-trimiteri td::before" in bloc, \
".tabel-trimiteri td::before lipseste din blocul mobil"
assert "display:block" in bloc, \
"display:block lipseste din blocul mobil (eticheta nu e stivuita)"
# col-chk si col-id ascunse pe card (nu ocupa spatiu).
assert ".tabel-trimiteri td.col-chk, .tabel-trimiteri td.col-id { display:none; }" in bloc, \
"col-chk/col-id nu sunt ascunse in blocul mobil"
def test_sistem_act_desktop_text_mobil_icon(client):
"""Sistemul .act: desktop = iconita ascunsa (text vizibil),
mobil = text ascuns, iconita vizibila 44px (tinta touch)."""
_create_account_user("act_sys@test.com")
_login(client, "act_sys@test.com")
html = client.get("/?tab=acasa").text
bloc = _bloc_mobil_principal(html)
# Desktop: act-ic ascunsa implicit (display:none in regula de baza).
assert ".act .act-ic { width:18px; height:18px; display:none; }" in html, \
"Regula desktop care ascunde .act-ic lipseste din CSS"
# Mobil: text ascuns, iconita vizibila.
assert ".act .act-tx { display:none; }" in bloc, \
".act .act-tx nu e ascuns in blocul mobil"
assert ".act .act-ic { display:inline-block; }" in bloc, \
".act .act-ic nu devine vizibila in blocul mobil"
# .act are tinta touch >=44px pe mobil.
assert ".act { min-width:44px" in bloc, \
".act nu are min-width:44px in blocul mobil"
def _seed_saved_mapping_responsive(acct_id: int) -> None:
"""Insereaza o mapare salvata in operations_mapping (pentru test act aria-label)."""
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-RESP-99", "OE-1", 1),
)
conn.commit()
finally:
conn.close()
def test_act_btn_aria_label_invariant(client):
"""Invariant a11y: butoanele .act din Mapari au mereu aria-label
(accesibil in modul icon-only pe mobil) si iconita .act-ic."""
acct = _create_account_user("actaria@test.com")
_seed_saved_mapping_responsive(acct)
_login(client, "actaria@test.com")
html = client.get("/_fragments/mapari").text
# Butoane cu clasa act exista in fragmentul de mapari.
act_btns = re.findall(r'<button[^>]+class="act[^"]*"[^>]*>', html)
assert act_btns, "Trebuie sa existe butoane cu clasa 'act' in fragmentul Mapari"
# Fiecare buton .act are aria-label (accesibil cand textul e ascuns pe mobil).
assert any("aria-label" in btn for btn in act_btns), \
"Niciun buton .act nu are aria-label — inaccessibil in modul icon-only"
# Iconita .act-ic prezenta in markup (afisata pe mobil).
assert 'class="act-ic"' in html, \
"Iconita .act-ic lipseste din fragmentul Mapari"
def test_stepper_compact_clase(client):
"""Stepper compact (5.13): clasele stepper-track, stepper-collapsed,
stepper-progress prezente; textul 'Pasul N din 4' randat; vechile clase
stepper-pas-- absente; comutarea la <1024px declarata in CSS."""
_create_account_user("stepper@test.com")
_login(client, "stepper@test.com")
html = client.get("/?tab=acasa").text
# Elementele de structura ale stepper-ului compact.
assert "stepper-track" in html, "stepper-track lipseste din markup"
assert "stepper-collapsed" in html, "stepper-collapsed lipseste din markup"
assert "stepper-progress" in html, "stepper-progress lipseste din markup"
# Textul de progres in forma colapsata (<1024px).
assert "Pasul 1 din 4" in html, "Textul 'Pasul 1 din 4' lipseste din markup"
# Vechile clase anti-pattern cu stepper-pas-- nu mai exista.
assert "stepper-pas--" not in html, \
"Clasa veche stepper-pas-- inca prezenta — curata markup-ul"
# CSS declara comutarea la <1024px (track ascuns, collapsed afisat).
assert "@media (max-width:1024px)" in html, \
"Lipseste regula @media (max-width:1024px) pentru comutarea stepper-ului"
def test_liste_actionabile_o_coloana_pana_1024(client):
"""Guard scope (decizie 5.13): listele actionabile raman O COLOANA
pana la 1024px — fara grila 2/rand (repeat(2)). Blocul tableta 768-1024px
cardifica (thead ascuns, card per rand)."""
_create_account_user("ocoloana@test.com")
_login(client, "ocoloana@test.com")
html = client.get("/?tab=acasa").text
# Nicaieri in CSS nu apare grila 2/rand (repeat(2, ...)).
assert "repeat(2" not in html, \
"CSS contine repeat(2 — listele actionabile NU trebuie sa fie 2/rand pana la 1024px"
# Exista blocul tableta (768-1024px).
assert "@media (min-width:768px) and (max-width:1024px)" in html, \
"Lipseste blocul @media tableta (min-width:768px) and (max-width:1024px)"
# Blocul tableta cardifica listele (thead ascuns = card per rand, o coloana).
assert ".tabel-trimiteri thead, .tabel-card thead { display:none; }" in html, \
"Blocul tableta nu ascunde thead-ul pentru cardificare (o coloana)"