feat(web): dashboard ergonomic cu tab-uri, stepper import si microcopy uman (3.4)

Reorganizeaza interfata web pe trei principii, fara a atinge backend-ul de
trimitere (worker, mapping, idempotency, masina de stari neatinse):

- US-001 app/web/labels.py: modul pur stari tehnice -> text uman + clasa CSS
- US-002 bara status /_fragments/status: microcopy uman, defalcare blocate, scoped cont
- US-003 shell 6 tab-uri (Acasa/Import/Coada/Mapari/Cont/Nomenclator): deep-link
  ?tab=, panou activ randat server-side, fragmente inactive lazy, ARIA real
- US-004 stepper import 4 pasi (pur vizual; hx-target + csrf pastrate)
- US-005 Acasa onboarding checklist auto-bifat + colaps + empty states prietenoase

Reparat in cursul VERIFY/CLOSE: izolare teste (reset ratelimit._hits in fixturi),
regresie avertisment "cont in asteptare de activare" (re-introdus in bara status),
culori hardcodate -> variabile paleta. 434 teste pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-18 22:26:10 +00:00
parent ccd26115f8
commit 4a1d28749a
22 changed files with 1889 additions and 96 deletions

126
app/web/labels.py Normal file
View File

@@ -0,0 +1,126 @@
"""
labels.py — traducere stari tehnice in text uman + clasa CSS (US-001, PRD 3.4).
Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri.
Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001.
"""
from typing import Tuple
# ---------------------------------------------------------------------------
# Tipul returnat: (text_principal, subtext_tooltip, css_class)
# ---------------------------------------------------------------------------
Eticheta = Tuple[str, str, str]
# ---------------------------------------------------------------------------
# Etichete stari submissions
# Clasele CSS corespund celor definite in base.html:
# s-queued (accent/albastru), s-sending (warn/galben), s-sent (ok/verde),
# s-error, s-needs_data, s-needs_mapping (err/rosu).
# ---------------------------------------------------------------------------
STARI_SUBMISSION: dict[str, Eticheta] = {
"queued": (
"In asteptare sa fie trimise",
"",
"s-queued",
),
"sending": (
"Se trimite acum",
"",
"s-sending",
),
"sent": (
"Declarate la RAR (finalizate)",
"Confirmate cu numar de prezentare; nu se mai pot modifica.",
"s-sent",
),
"needs_mapping": (
"Lipseste codul prestatiei",
"Alege codul RAR in tab-ul Mapari.",
"s-needs_mapping",
),
"needs_data": (
"Date incomplete (respinse de RAR)",
"Corecteaza randul si reimporta.",
"s-needs_data",
),
"error": (
"Eroare la trimitere",
"Vezi detaliul randului; se reincearca automat sau necesita corectie.",
"s-error",
),
}
def eticheta_stare(status: str) -> Eticheta:
"""
Returneaza (text, subtext, css_class) pentru o stare de submission.
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
stari noi adaugate in schema fara mapare corespunzatoare.
"""
try:
return STARI_SUBMISSION[status]
except KeyError:
raise KeyError(
f"Starea de submission {status!r} nu are eticheta umana in labels.py. "
"Adauga-o in STARI_SUBMISSION."
)
# ---------------------------------------------------------------------------
# Etichete worker (viu / mort)
# ---------------------------------------------------------------------------
def eticheta_worker(viu: bool) -> Eticheta:
"""
Returneaza (text, subtext, css_class) pentru starea worker-ului.
viu=True => "Trimitere automata: activa" (clasa s-sent / verde)
viu=False => "Trimitere automata: oprita" (clasa s-error / rosu)
"""
if viu:
return (
"Trimitere automata: activa",
"Sistemul verifica coada si trimite la RAR la fiecare cateva secunde.",
"s-sent",
)
return (
"Trimitere automata: oprita",
"Nimic nu pleaca spre RAR pana reporneste. Anunta administratorul.",
"s-error",
)
# ---------------------------------------------------------------------------
# Etichete conexiune RAR (ok / indisponibil)
# ---------------------------------------------------------------------------
def eticheta_rar(stare: str) -> Eticheta:
"""
Returneaza (text, subtext, css_class) pentru starea conexiunii cu RAR.
stare="ok" => "Legatura cu RAR: functionala" (s-sent / verde)
stare="indisponibil" => "Legatura cu RAR: indisponibila" (s-error / rosu)
"""
if stare == "ok":
return (
"Legatura cu RAR: functionala",
"Portalul AUTOPASS raspunde.",
"s-sent",
)
return (
"Legatura cu RAR: indisponibila",
"Portalul RAR nu raspunde acum; coada se reia automat cand revine.",
"s-error",
)
# ---------------------------------------------------------------------------
# Constante auxiliare (microcopy fix, fara logica)
# ---------------------------------------------------------------------------
ETICHETA_ULTIMA_AUTENTIFICARE_RAR = "Ultima autentificare la RAR"

View File

@@ -24,6 +24,12 @@ from fastapi.templating import Jinja2Templates
from .. import __version__ from .. import __version__
from ..auth import rotate_api_key from ..auth import rotate_api_key
from ..web.csrf import get_csrf_token, verify_csrf from ..web.csrf import get_csrf_token, verify_csrf
from .labels import (
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
eticheta_rar,
eticheta_stare,
eticheta_worker,
)
from ..web.session import require_login from ..web.session import require_login
from ..api.v1.import_router import ( from ..api.v1.import_router import (
_already_sent_lookup, _already_sent_lookup,
@@ -115,25 +121,142 @@ def _rar_state(hb, worker_alive: bool) -> str:
return "indisponibil?" if age > 108000 else "ok" return "indisponibil?" if age > 108000 else "ok"
_TABS_VALIDE = {"acasa", "import", "coada", "mapari", "cont", "nomenclator"}
def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
"""Calculeaza contextul pentru panoul Acasa (US-005).
Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1).
"""
from ..mapping import account_or_default
acct = account_or_default(account_id)
# Pas 1: are credentiale RAR configurate?
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
# Pas 3: are cel putin un submission (trimis sau in coada)?
row_sub = conn.execute(
"SELECT 1 FROM submissions "
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) LIMIT 1",
(acct, acct),
).fetchone()
are_trimiteri = row_sub is not None
# Pas 2 (optional): are cheie API activa?
row_key = conn.execute(
"SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1",
(acct,),
).fetchone()
are_cheie_folosita = row_key is not None
return {
"request": request,
"are_creds": are_creds,
"are_trimiteri": are_trimiteri,
"are_cheie_folosita": are_cheie_folosita,
}
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1) -> str:
"""Randeaza panoul Acasa ca string HTML."""
if conn is None:
return templates.get_template("_acasa.html").render({"request": request})
ctx = _get_acasa_context(request, conn, account_id)
return templates.get_template("_acasa.html").render(ctx)
def _render_panel_import(request: Request) -> str:
"""Randeaza panoul Import ca string HTML (include _upload.html)."""
return templates.get_template("_upload.html").render({
"request": request,
"csrf_token": get_csrf_token(request),
})
def _render_panel_coada(request: Request) -> str:
"""Randeaza panoul Coada ca string HTML."""
return templates.get_template("_coada.html").render({"request": request})
def _render_panel_mapari(request: Request, conn, account_id: int) -> str:
"""Randeaza panoul Mapari ca string HTML."""
return templates.get_template("_mapari.html").render({
"request": request,
"pending": pending_unmapped(conn, account_id),
"nomenclator": load_nomenclator(conn),
"message": None,
"csrf_token": get_csrf_token(request),
})
def _render_panel_cont(request: Request, conn, account_id: int) -> str:
"""Randeaza panoul Cont ca string HTML."""
from ..mapping import account_or_default
acct = account_or_default(account_id)
row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
return templates.get_template("_cont.html").render({
"request": request,
"csrf_token": get_csrf_token(request),
"api_key": None,
"are_creds": are_creds,
"creds_mesaj": None,
"creds_eroare": None,
"rot_eroare": None,
})
def _render_panel_nomenclator(request: Request, conn) -> str:
"""Randeaza panoul Nomenclator ca string HTML."""
rows = conn.execute(
"SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie"
).fetchall()
return templates.get_template("_nomenclator.html").render({
"request": request,
"rows": rows,
})
def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) -> str:
"""Randeaza panoul corespunzator unui tab ca string HTML."""
if tab == "acasa":
return _render_panel_acasa(request, conn, account_id)
if tab == "import":
return _render_panel_import(request)
if tab == "coada":
return _render_panel_coada(request)
if tab == "mapari":
return _render_panel_mapari(request, conn, account_id)
if tab == "cont":
return _render_panel_cont(request, conn, account_id)
if tab == "nomenclator":
return _render_panel_nomenclator(request, conn)
return _render_panel_acasa(request)
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def dashboard(request: Request) -> HTMLResponse: def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
"""Dashboard principal cu tab-uri (US-003).
Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat
server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS).
Tab invalid -> fallback la 'acasa'.
"""
account_id = require_login(request) account_id = require_login(request)
active_tab = tab if tab in _TABS_VALIDE else "acasa"
conn = get_connection() conn = get_connection()
try: try:
counts = _status_counts(conn, account_id) panel_html = _render_panel_for_tab(request, conn, account_id, active_tab)
hb = read_heartbeat(conn)
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
worker_alive = _worker_alive(hb)
ctx = { ctx = {
"request": request, "request": request,
"rar_env": get_settings().rar_env, "rar_env": get_settings().rar_env,
"version": __version__, "version": __version__,
"counts": counts, "active_tab": active_tab,
"blocked": blocked, "panel_html": panel_html,
"worker_alive": worker_alive,
"last_login": hb["last_rar_login_ok"] if hb else None,
"rar_state": _rar_state(hb, worker_alive),
"account_active": _account_active(conn, account_id),
"is_admin": is_account_admin(conn, account_id), "is_admin": is_account_admin(conn, account_id),
"csrf_token": get_csrf_token(request), "csrf_token": get_csrf_token(request),
} }
@@ -142,6 +265,32 @@ def dashboard(request: Request) -> HTMLResponse:
conn.close() conn.close()
@router.get("/_fragments/acasa", response_class=HTMLResponse)
def fragment_acasa(request: Request) -> HTMLResponse:
"""Fragment HTMX pentru tab-ul Acasa (US-003, US-005)."""
account_id = require_login(request)
conn = get_connection()
try:
ctx = _get_acasa_context(request, conn, account_id)
return templates.TemplateResponse("_acasa.html", ctx)
finally:
conn.close()
@router.get("/_fragments/import", response_class=HTMLResponse)
def fragment_import(request: Request) -> HTMLResponse:
"""Fragment HTMX pentru tab-ul Import — include zona de upload (US-003)."""
require_login(request)
return templates.TemplateResponse("_upload.html", _ctx(request))
@router.get("/_fragments/coada", response_class=HTMLResponse)
def fragment_coada(request: Request) -> HTMLResponse:
"""Fragment HTMX pentru tab-ul Coada — include coada submissions (US-003)."""
require_login(request)
return templates.TemplateResponse("_coada.html", {"request": request})
@router.get("/_fragments/nomenclator", response_class=HTMLResponse) @router.get("/_fragments/nomenclator", response_class=HTMLResponse)
def fragment_nomenclator(request: Request) -> HTMLResponse: def fragment_nomenclator(request: Request) -> HTMLResponse:
"""Browser nomenclator RAR (cache local upsert-at de worker la fiecare login).""" """Browser nomenclator RAR (cache local upsert-at de worker la fiecare login)."""
@@ -173,6 +322,56 @@ def fragment_banner(request: Request) -> HTMLResponse:
conn.close() conn.close()
def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
"""Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0.
Ordinea: needs_mapping, needs_data, error — aceeasi ca in PRD.
Returneaza lista goala daca nu exista nicio stare blocata.
"""
rezultat = []
for status in ("needs_mapping", "needs_data", "error"):
n = counts.get(status, 0)
if n > 0:
rezultat.append((eticheta_stare(status), n))
return rezultat
@router.get("/_fragments/status", response_class=HTMLResponse)
def fragment_status(request: Request) -> HTMLResponse:
"""Bara de status persistenta cu etichete umane (US-002, PRD 3.4).
Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima
autentificare, contorii de coada si defalcarea blocatelor pe motiv.
Logica in routes.py (nu in template) pentru testabilitate.
"""
account_id = require_login(request)
conn = get_connection()
try:
counts = _status_counts(conn, account_id)
hb = read_heartbeat(conn)
worker_alive = _worker_alive(hb)
rar_state = _rar_state(hb, worker_alive)
# Etichete umane pre-calculate (nu logica in template)
worker_lbl = eticheta_worker(worker_alive)
# eticheta_rar accepta "ok" sau orice alt string -> indisponibil/necunoscut
rar_lbl = eticheta_rar("ok" if rar_state == "ok" else rar_state)
return templates.TemplateResponse("_status.html", {
"request": request,
"worker_lbl": worker_lbl,
"rar_lbl": rar_lbl,
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
"last_login": hb["last_rar_login_ok"] if hb else None,
"counts_queued": counts.get("queued", 0),
"counts_sent": counts.get("sent", 0),
"blocate_defalcat": _blocate_defalcat(counts),
"account_active": _account_active(conn, account_id),
})
finally:
conn.close()
@router.get("/_fragments/submissions", response_class=HTMLResponse) @router.get("/_fragments/submissions", response_class=HTMLResponse)
def fragment_submissions(request: Request) -> HTMLResponse: def fragment_submissions(request: Request) -> HTMLResponse:
account_id = require_login(request) account_id = require_login(request)

View File

@@ -0,0 +1,81 @@
<div id="acasa-section">
{% set toti_esentiali = are_creds and are_trimiteri %}
{% if toti_esentiali %}
{# Ghid colapsat/discret cand toti pasii esentiali sunt gata #}
<div class="ghid-complet" style="margin-bottom:12px; font-size:13px; color:var(--muted);">
Totul e configurat —
<a href="/?tab=coada">vezi coada</a>
</div>
{% else %}
{# Card ghid de pornire vizibil cand nu toti pasii sunt finalizati #}
<div class="card" style="margin-bottom:16px;">
<h2 style="font-size:15px; margin:0 0 12px;">Primii pasi</h2>
<ul style="list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:8px;">
{# Pas 1: Conecteaza contul RAR (esential) #}
<li style="display:flex; align-items:flex-start; gap:10px;">
{% if are_creds %}
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">&#10003;</span>
{% else %}
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">&#9675;</span>
{% endif %}
<span>
<a href="/?tab=cont">Conecteaza-ti contul RAR</a>
<span class="muted" style="font-size:12px; display:block;">
Email + parola portal AUTOPASS RAR
</span>
</span>
</li>
{# Pas 2: Cheie API (optional) #}
<li style="display:flex; align-items:flex-start; gap:10px;">
{% if are_cheie_folosita %}
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">&#10003;</span>
{% else %}
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">&#9675;</span>
{% endif %}
<span>
<a href="/?tab=cont">Ia-ti cheia API</a>
<span class="muted" style="font-size:12px; display:block;">
<em>Optional</em> — doar daca trimiti din soft propriu prin API
</span>
</span>
</li>
{# Pas 3: Import primul fisier (esential) #}
<li style="display:flex; align-items:flex-start; gap:10px;">
{% if are_trimiteri %}
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">&#10003;</span>
{% else %}
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">&#9675;</span>
{% endif %}
<span>
<a href="/?tab=import">Importa primul fisier</a>
<span class="muted" style="font-size:12px; display:block;">
Incarca un fisier xlsx/csv cu prezentarile de declarat la RAR
</span>
</span>
</li>
</ul>
</div>
{% endif %}
{# Rezumat si scurtaturi rapide (mereu vizibile) #}
<div class="card">
<h2 style="font-size:15px; margin:0 0 8px;">Bun venit la Gateway RAR AUTOPASS</h2>
<p class="muted" style="margin:0 0 10px; font-size:13px;">
Importa fisiere din tab-ul <strong><a href="/?tab=import">Import</a></strong>,
urmareste coada in tab-ul <strong><a href="/?tab=coada">Coada</a></strong>
si rezolva mapari lipsa in tab-ul <strong><a href="/?tab=mapari">Mapari</a></strong>.
</p>
<div style="display:flex; gap:12px; flex-wrap:wrap; font-size:13px;">
<a href="/?tab=coada" class="cardlink">Coada submissions</a>
<a href="/?tab=import" class="cardlink">Import fisier nou</a>
<a href="/?tab=mapari" class="cardlink">Mapari operatii</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,14 @@
<div id="coada-section">
<div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 style="font-size:15px; margin:0;">Coada submissions</h2>
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;">
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
</span>
</div>
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">
<div class="empty">se incarca…</div>
</div>
</div>
</div>

View File

@@ -6,7 +6,10 @@
{% endif %} {% endif %}
{% if not pending %} {% if not pending %}
<div class="empty">Nicio operatie nemapata. Tot ce a venit s-a tradus in coduri RAR.</div> <div class="empty">
Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR.
<a href="/?tab=import">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
</div>
{% else %} {% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;"> <p class="muted" style="margin:0 0 12px; font-size:13px;">
Operatii ROAAUTO necunoscute, blocate in <span class="s-needs_mapping">needs_mapping</span>. Operatii ROAAUTO necunoscute, blocate in <span class="s-needs_mapping">needs_mapping</span>.

View File

@@ -1,4 +1,5 @@
<div id="import-section"> <div id="import-section">
{% set pas = 2 %}{% include '_stepper.html' %}
<div class="card"> <div class="card">
<h2 style="font-size:15px; margin:0 0 12px;"> <h2 style="font-size:15px; margin:0 0 12px;">
Mapare coloane — Mapare coloane —

View File

@@ -1,4 +1,5 @@
<div id="import-section"> <div id="import-section">
{% set pas = 3 %}{% include '_stepper.html' %}
<div class="card"> <div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;"> <div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
<h2 style="font-size:15px; margin:0;"> <h2 style="font-size:15px; margin:0;">

View File

@@ -0,0 +1,80 @@
<div id="status-bar" class="status-bar card"
hx-get="/_fragments/status"
hx-trigger="every 15s"
hx-swap="outerHTML">
<!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) -->
{% if not account_active %}
<div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn);
background:#201c0f; border-radius:6px; font-size:13px;">
<strong>Cont in asteptare de activare.</strong>
Configureaza credentialele RAR si pregateste importul acum; trimiterea catre RAR
porneste automat dupa activare de catre administrator.
</div>
{% endif %}
<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;">
<!-- Starea worker (Trimitere automata) -->
<div>
<div class="muted" style="font-size:12px;">{{ worker_lbl[0] }}</div>
<div class="{{ worker_lbl[2] }}" title="{{ worker_lbl[1] }}">
{{ worker_lbl[0].split(':')[1].strip() if ':' in worker_lbl[0] else worker_lbl[0] }}
</div>
{% if worker_lbl[1] %}
<div class="muted" style="font-size:11px; max-width:220px;">{{ worker_lbl[1] }}</div>
{% endif %}
</div>
<!-- Legatura RAR -->
<div>
<div class="muted" style="font-size:12px;">Legatura RAR</div>
<div class="{{ rar_lbl[2] }}" title="{{ rar_lbl[1] }}">
{{ rar_lbl[0].split(':')[1].strip() if ':' in rar_lbl[0] else rar_lbl[0] }}
</div>
{% if rar_lbl[1] and rar_lbl[2] != 's-sent' %}
<div class="muted" style="font-size:11px; max-width:220px;">{{ rar_lbl[1] }}</div>
{% endif %}
</div>
<!-- Ultima autentificare RAR -->
<div>
<div class="muted" style="font-size:12px;">{{ eticheta_ultima_auth }}</div>
<div>{{ last_login or '—' }}</div>
</div>
<!-- In asteptare (queued) -->
<div>
<div class="muted" style="font-size:12px;">In asteptare</div>
<div class="s-queued">{{ counts_queued }}</div>
</div>
<!-- Declarate la RAR (sent) -->
<div>
<div class="muted" style="font-size:12px;">Declarate la RAR</div>
<div class="s-sent">{{ counts_sent }}</div>
</div>
</div>
<!-- Defalcare blocate pe motiv (doar daca exista) -->
{% if blocate_defalcat %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
<div style="font-size:12px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div>
<div style="display:flex; gap:16px; flex-wrap:wrap;">
{% for eticheta, n in blocate_defalcat %}
{% if n > 0 %}
<div>
<span class="{{ eticheta[2] }}" style="font-size:13px;">{{ eticheta[0] }}</span>
<span class="muted" style="font-size:12px; margin-left:4px;">({{ n }})</span>
{% if eticheta[1] %}
<div class="muted" style="font-size:11px; max-width:200px;">{{ eticheta[1] }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,96 @@
{#
_stepper.html — Antet wizard import (PUR vizual, fara logica de rutare).
Parametru: `pas` (integer 1-4) — pasul curent.
Utilizare in template-uri care mostenesc contextul Jinja2:
{% set pas = 1 %}{% include '_stepper.html' %}
sau cu `with`:
{% with pas=2 %}{% include '_stepper.html' %}{% endwith %}
Cei 4 pasi ficsi:
1. Incarca fisier
2. Potriveste coloanele
3. Verifica
4. Confirma trimiterea
Stari vizuale:
- index < pas "facut" (bulina plina, text bifat)
- index == pas "activ" (evidentiat, aria-current="step")
- index > pas → "viitor" (estompat)
#}
{%- set _pasi_import = [
(1, "Incarca fisier", "Trage un fisier xlsx/csv aici sau foloseste butonul de alegere."),
(2, "Potriveste coloanele", "Spune-ne ce coloana din fisier corespunde cu ce camp RAR."),
(3, "Verifica", "Verifica randurile inainte sa le trimiti la RAR."),
(4, "Confirma trimiterea", "Confirma numarul de prezentari — actiunea e ireversibila."),
] -%}
<nav class="stepper-import" aria-label="Pasii importului" style="
display:flex;
gap:0;
align-items:stretch;
margin-bottom:20px;
border:1px solid var(--line);
border-radius:8px;
overflow:hidden;
background:var(--card);
">
{% for nr, titlu, ajutor in _pasi_import %}
{%- if nr < pas %}
{%- set cls = "facut" -%}
{%- set aria = "" -%}
{%- elif nr == pas %}
{%- set cls = "activ" -%}
{%- set aria = ' aria-current="step"' -%}
{%- else %}
{%- set cls = "viitor" -%}
{%- set aria = "" -%}
{%- endif %}
<div class="stepper-pas stepper-pas--{{ cls }}"{{ aria | safe }}
style="
flex:1;
padding:10px 14px;
border-right:{% if not loop.last %}1px solid var(--line){% else %}none{% endif %};
{% if cls == 'activ' %}
background:rgba(91,141,239,.10);
{% elif cls == 'facut' %}
opacity:1;
{% else %}
opacity:.4;
{% endif %}
">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:2px;">
<span class="stepper-nr" style="
display:inline-flex;
align-items:center;
justify-content:center;
width:20px;
height:20px;
border-radius:50%;
font-size:11px;
font-weight:700;
flex-shrink:0;
{% if cls == 'activ' %}
background:var(--accent);
color:#fff;
{% elif cls == 'facut' %}
background:var(--ok);
color:#fff;
{% else %}
background:var(--line);
color:var(--muted);
{% endif %}
">
{% if cls == 'facut' %}&#10003;{% else %}{{ nr }}{% endif %}
</span>
<span style="
font-size:13px;
font-weight:{% if cls == 'activ' %}600{% else %}400{% endif %};
color:{% if cls == 'activ' %}var(--ink){% elif cls == 'facut' %}var(--ink){% else %}var(--muted){% endif %};
">{{ titlu }}</span>
</div>
{% if cls == 'activ' %}
<p class="muted" style="margin:0; font-size:12px; padding-left:26px;">{{ ajutor }}</p>
{% endif %}
</div>
{% endfor %}
</nav>

View File

@@ -18,5 +18,9 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<div class="empty">Coada e goala. Trimite o prezentare prin <code>POST /v1/prezentari</code>.</div> <div class="empty">
Nicio trimitere inca —
<a href="/?tab=import">incepe cu Import</a>
sau trimite o prezentare prin API.
</div>
{% endif %} {% endif %}

View File

@@ -1,4 +1,5 @@
<div id="import-section"> <div id="import-section">
{% set pas = 1 %}{% include '_stepper.html' %}
<div class="card"> <div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Import fisier (xlsx / csv)</h2> <h2 style="font-size:15px; margin:0 0 12px;">Import fisier (xlsx / csv)</h2>

View File

@@ -64,6 +64,20 @@
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; } button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
button:hover { filter:brightness(1.08); } button:hover { filter:brightness(1.08); }
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; } .chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; }
/* Tab-bar (US-003) */
.tab-bar { display:flex; gap:2px; overflow-x:auto; -webkit-overflow-scrolling:touch;
border-bottom:1px solid var(--line); margin-bottom:16px; padding-bottom:0;
scrollbar-width:none; }
.tab-bar::-webkit-scrollbar { display:none; }
.tab-link { display:inline-flex; align-items:center; padding:8px 16px; font-size:14px;
font-weight:500; color:var(--muted); text-decoration:none; border-radius:6px 6px 0 0;
border:1px solid transparent; border-bottom:none; white-space:nowrap;
transition:color .12s, background .12s; margin-bottom:-1px; }
.tab-link:hover { color:var(--ink); background:var(--line); }
.tab-link.tab-activ { color:var(--ink); background:var(--card);
border-color:var(--line); border-bottom-color:var(--card); }
.tab-panel { min-height:120px; }
.status-bar { margin-bottom:12px; }
</style> </style>
</head> </head>
<body> <body>

View File

@@ -10,61 +10,83 @@
</form> </form>
</div> </div>
<!-- Sectiunea de import fisier: stare initiala = drop zone; HTMX swapeaza in flow --> <!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
{% include '_upload.html' %} <div id="status-bar" class="status-bar card"
hx-get="/_fragments/status"
<div class="card banner {% if not blocked %}hidden{% endif %}" hx-trigger="load, every 15s"
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML"> hx-swap="outerHTML">
<strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping). <div class="empty muted" style="padding:8px 0;">se incarca starea…</div>
Plasa de siguranta pe pene RAR &gt; 30h. Verifica coada mai jos.
</div> </div>
<div class="card"> <!-- Tab-bar: navigare intre sectiuni -->
<div style="display:flex; gap:24px; flex-wrap:wrap;"> <div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
<div><div class="muted">Worker</div><div class="{{ 's-sent' if worker_alive else 's-error' }}"> {% set tabs = [
{{ 'viu' if worker_alive else 'mort' }}</div></div> ("acasa", "Acasa", "tab-acasa"),
<div><div class="muted">RAR</div><div class="{{ 's-sent' if rar_state == 'ok' else 's-error' if 'indisponibil' in rar_state else 'muted' }}">{{ rar_state }}</div></div> ("import", "Import", "tab-import"),
<div><div class="muted">Ultimul login RAR</div><div>{{ last_login or '—' }}</div></div> ("coada", "Coada", "tab-coada"),
<div><div class="muted">In coada</div><div>{{ counts.get('queued', 0) }}</div></div> ("mapari", "Mapari", "tab-mapari"),
<div><div class="muted">Trimise</div><div class="s-sent">{{ counts.get('sent', 0) }}</div></div> ("cont", "Cont", "tab-cont"),
<div><div class="muted">Blocate</div><div class="{{ 's-error' if blocked else '' }}">{{ blocked }}</div></div> ("nomenclator", "Nomenclator", "tab-nomenclator")
</div> ] %}
{% if rar_state != 'ok' %} {% for tab_id, tab_label, tab_elem_id in tabs %}
<p class="muted" style="margin:12px 0 0; font-size:12px;"> <a id="{{ tab_elem_id }}"
RAR posibil indisponibil — coada de mai jos arata ultima stare cunoscuta (local), nu live din RAR. role="tab"
</p> href="/?tab={{ tab_id }}"
{% endif %} aria-selected="{{ 'true' if active_tab == tab_id else 'false' }}"
aria-controls="tab-panel"
class="tab-link{% if active_tab == tab_id %} tab-activ{% endif %}"
tabindex="{{ '0' if active_tab == tab_id else '-1' }}"
hx-get="/_fragments/{{ tab_id }}"
hx-target="#tab-panel"
hx-swap="innerHTML"
hx-push-url="/?tab={{ tab_id }}">{{ tab_label }}</a>
{% endfor %}
</div> </div>
<!-- incarcat o data; NU poll (sa nu stergem o selectie in curs). Se re-randeaza la salvare. --> <!-- Panou activ: randat server-side la full load; HTMX inlocuieste continutul la click pe tab -->
<div hx-get="/_fragments/mapari" hx-trigger="load" hx-swap="outerHTML"> <div id="tab-panel" role="tabpanel" aria-labelledby="tab-{{ active_tab }}" class="tab-panel">
<div class="card"><div class="empty">se incarca mapari…</div></div> {{ panel_html | safe }}
</div> </div>
<div hx-get="/_fragments/cont" hx-trigger="load" hx-swap="outerHTML"> <script>
<div class="card"><div class="empty">se incarca cont…</div></div> (function() {
</div> /* Navigare cu sageti intre tab-uri (ARIA pattern) */
var tablist = document.querySelector('[role="tablist"]');
if (!tablist) return;
var tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
tablist.addEventListener('keydown', function(e) {
var idx = tabs.indexOf(document.activeElement);
if (idx === -1) return;
var next = -1;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
next = (idx + 1) % tabs.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
next = (idx - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
next = 0;
} else if (e.key === 'End') {
next = tabs.length - 1;
}
if (next !== -1) {
e.preventDefault();
tabs[next].focus();
}
});
<div class="card"> /* La click pe tab: actualizeaza aria-selected + tabindex */
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;"> tabs.forEach(function(tab) {
<h2 style="font-size:15px; margin:0;">Coada submissions</h2> tab.addEventListener('click', function() {
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;"> tabs.forEach(function(t) {
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a> t.setAttribute('aria-selected', 'false');
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a> t.setAttribute('tabindex', '-1');
</span> t.classList.remove('tab-activ');
</div> });
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML"> tab.setAttribute('aria-selected', 'true');
<div class="empty">se incarca…</div> tab.setAttribute('tabindex', '0');
</div> tab.classList.add('tab-activ');
</div> });
});
<div class="card"> })();
<details> </script>
<summary style="cursor:pointer; font-size:15px; font-weight:600;">Nomenclator RAR (coduri prestatii)</summary>
<div style="margin-top:12px;" hx-get="/_fragments/nomenclator" hx-trigger="load" hx-swap="innerHTML">
<div class="empty">se incarca…</div>
</div>
</details>
</div>
{% endblock %} {% endblock %}

View File

@@ -48,7 +48,9 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
> PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata: > PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata:
> schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare". > schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare".
**Ultima actualizare**: 2026-06-18 — 3.3b LIVRAT (self-service cheie/creds + admin web + email). US-007 rute web proprii pentru rotire cheie + setare creds RAR scoped pe sesiune (C13, nu endpointul API). US-010 rol admin (`users.is_admin`) + `require_admin``AdminRequired`→403 + CLI `tools/account.py set-admin` + bootstrap automat (primul cont care se inregistreaza = admin, citit in `BEGIN IMMEDIATE` anti-race). US-011 panou `/admin` (conturi in asteptare/active, activare/dezactivare cu CSRF + PRG, contul dev id=1 protejat) + link "Panou admin" pe dashboard doar pentru admini + buton logout. US-012 `app/email.py notify_signup` best-effort DEGRADAT fara SMTP (no-op + log, prinde orice exceptie, nu blocheaza signup) + config `smtp_*`. Fix migrare defensiva `users.is_admin`/`email_verified` in `_migrate` (gap prins de VERIFY r1, ca C1 pe `accounts.active`). 2 runde VERIFY context curat (r2 PASS, sweep securitate toate rutele noi sub require_login/require_admin + CSRF, scoped sesiune). `/code-review` high: TOCTOU bootstrap mutat in tranzactie + `_render_admin` extras (anti-duplicare + N+1). 393 teste pass. Urmeaza Etapa 4 (4.1 mapare AI/MCP). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`. SMTP real = follow-up pe US-012. **Ultima actualizare**: 2026-06-18 — 3.4 LIVRAT (interfata web ergonomica: tab-uri + wizard + microcopy). US-001 modul pur `app/web/labels.py` (stari tehnice→text uman + clasa CSS; test parametrizat din CHECK-ul `schema.sql` iese rosu la stare nemapata). US-002 bara status `/_fragments/status` + `_status.html` (etichete umane, defalcare blocate pe motiv, poll 15s, scoped pe cont). US-003 shell 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=`, panou activ randat server-side, fragmente inactive lazy pe click, ARIA real (tablist/tab/tabpanel + aria-selected + navigare cu sageti). US-004 stepper import 4 pasi (PUR vizual, `hx-target="#import-section"` + csrf pastrate). US-005 Acasa onboarding checklist auto-bifat (are_creds/are_trimiteri) + colaps cand totul gata + empty states prietenoase Coada/Mapari. VERIFY lead-driven (TestClient ACs + 434 pytest pass; E2E browser/RAR LIVE neprobat in sesiune — recomandata probare manuala `--send`). Fix izolare teste (reset `ratelimit._hits` in fixturi, 429 la rulare subset). `/code-review` high: regasit avertisment "cont in asteptare de activare" (regresie din scoaterea `/_fragments/banner`) re-introdus in bara status + culori hardcodate→variabile paleta. 434 teste pass. Backend trimitere neatins. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP).
> 3.3b LIVRAT (self-service cheie/creds + admin web + email). US-007 rute web proprii pentru rotire cheie + setare creds RAR scoped pe sesiune (C13, nu endpointul API). US-010 rol admin (`users.is_admin`) + `require_admin`→`AdminRequired`→403 + CLI `tools/account.py set-admin` + bootstrap automat (primul cont care se inregistreaza = admin, citit in `BEGIN IMMEDIATE` anti-race). US-011 panou `/admin` (conturi in asteptare/active, activare/dezactivare cu CSRF + PRG, contul dev id=1 protejat) + link "Panou admin" pe dashboard doar pentru admini + buton logout. US-012 `app/email.py notify_signup` best-effort DEGRADAT fara SMTP (no-op + log, prinde orice exceptie, nu blocheaza signup) + config `smtp_*`. Fix migrare defensiva `users.is_admin`/`email_verified` in `_migrate` (gap prins de VERIFY r1, ca C1 pe `accounts.active`). 2 runde VERIFY context curat (r2 PASS, sweep securitate toate rutele noi sub require_login/require_admin + CSRF, scoped sesiune). `/code-review` high: TOCTOU bootstrap mutat in tranzactie + `_render_admin` extras (anti-duplicare + N+1). 393 teste pass. Urmeaza Etapa 4 (4.1 mapare AI/MCP). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`. SMTP real = follow-up pe US-012.
> 3.3a LIVRAT (self-onboarding web core: `app/users.py` parole scrypt cu eticheta de parametri onorata la verify; `SessionMiddleware` same_site=strict + `app/web/session.py` guard `require_login`→`LoginRequired`; CSRF per-sesiune enforce in prod inclusiv pe login/signup + rate-limit signup & login in-proces; signup `active=0` tranzactie atomica + cheie-o-data + log `SIGNUP`; login/logout; dashboard & import multi-tenant scoped pe sesiune cu regula NULL→cont 1 — toate rutele web care ating date sensibile sub `require_login` + scope; gate worker `claim_one` `LEFT JOIN ... COALESCE(active,1)=1`. 2 runde VERIFY context curat — runda 1 a prins un leak cross-account pe `/_fragments/mapari`, reparat; runda 2 PASS. `/code-review` high a prins 3 findings, reparate. 361 teste pass). Urmeaza 3.3b (self-service cheie/creds + admin web + email). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`. > 3.3a LIVRAT (self-onboarding web core: `app/users.py` parole scrypt cu eticheta de parametri onorata la verify; `SessionMiddleware` same_site=strict + `app/web/session.py` guard `require_login`→`LoginRequired`; CSRF per-sesiune enforce in prod inclusiv pe login/signup + rate-limit signup & login in-proces; signup `active=0` tranzactie atomica + cheie-o-data + log `SIGNUP`; login/logout; dashboard & import multi-tenant scoped pe sesiune cu regula NULL→cont 1 — toate rutele web care ating date sensibile sub `require_login` + scope; gate worker `claim_one` `LEFT JOIN ... COALESCE(active,1)=1`. 2 runde VERIFY context curat — runda 1 a prins un leak cross-account pe `/_fragments/mapari`, reparat; runda 2 PASS. `/code-review` high a prins 3 findings, reparate. 361 teste pass). Urmeaza 3.3b (self-service cheie/creds + admin web + email). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`.
@@ -78,7 +80,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
| 3.2 | Filtrare pe cont a GET-urilor de listare | DONE | 2026-06-17 | scope cheie pe `/v1/prezentari(/{id})`, `/v1/mapari(/pending)`, `/v1/audit/export` (NULL→cont 1); nomenclator global; 404 cross-account identic (B3) + allowlist campuri detaliu (B4) + helper `account_scope_clause` (B2) + index (B5). 14 teste noi, 313 pass. PRD: [prd-3.2](prd/prd-3.2-filtrare-cont-get.md) | | 3.2 | Filtrare pe cont a GET-urilor de listare | DONE | 2026-06-17 | scope cheie pe `/v1/prezentari(/{id})`, `/v1/mapari(/pending)`, `/v1/audit/export` (NULL→cont 1); nomenclator global; 404 cross-account identic (B3) + allowlist campuri detaliu (B4) + helper `account_scope_clause` (B2) + index (B5). 14 teste noi, 313 pass. PRD: [prd-3.2](prd/prd-3.2-filtrare-cont-get.md) |
| 3.3a | Self-onboarding web (core) | DONE | 2026-06-17 | `users` (scrypt) + sesiune (`SessionMiddleware`, same_site=strict) + CSRF (enforce prod, inclusiv login/signup) + rate-limit signup/login + signup/login/logout + dashboard & import scoped pe sesiune (NULL→1, anti-leak C6) + gate worker `active=0` (`COALESCE`). 2 runde VERIFY (leak `/_fragments/mapari` prins+reparat) + code-review (csrf erori, scrypt_params, login rate-limit). 361 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) | | 3.3a | Self-onboarding web (core) | DONE | 2026-06-17 | `users` (scrypt) + sesiune (`SessionMiddleware`, same_site=strict) + CSRF (enforce prod, inclusiv login/signup) + rate-limit signup/login + signup/login/logout + dashboard & import scoped pe sesiune (NULL→1, anti-leak C6) + gate worker `active=0` (`COALESCE`). 2 runde VERIFY (leak `/_fragments/mapari` prins+reparat) + code-review (csrf erori, scrypt_params, login rate-limit). 361 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
| 3.3b | Self-service cheie/creds + admin web + email | DONE | 2026-06-18 | US-007 (rute web proprii `/cont/roteste-cheie`+`/cont/rar-creds` scoped sesiune, C13), US-010 (rol admin `is_admin` + `require_admin`→403 + CLI `set-admin` + bootstrap primul cont=admin), US-011 (`/admin` activare/dezactivare cu CSRF+PRG, link doar pt admini + logout), US-012 (`app/email.py` notify best-effort degradat fara SMTP + log `SIGNUP`). Fix migrare defensiva `users.is_admin`/`email_verified`. 2 runde VERIFY context curat (r1 a prins migrarea lipsa, reparat; r2 PASS) + `/code-review` high (TOCTOU bootstrap admin mutat in tranzactie + extras `_render_admin` anti-duplicare/N+1). 393 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) | | 3.3b | Self-service cheie/creds + admin web + email | DONE | 2026-06-18 | US-007 (rute web proprii `/cont/roteste-cheie`+`/cont/rar-creds` scoped sesiune, C13), US-010 (rol admin `is_admin` + `require_admin`→403 + CLI `set-admin` + bootstrap primul cont=admin), US-011 (`/admin` activare/dezactivare cu CSRF+PRG, link doar pt admini + logout), US-012 (`app/email.py` notify best-effort degradat fara SMTP + log `SIGNUP`). Fix migrare defensiva `users.is_admin`/`email_verified`. 2 runde VERIFY context curat (r1 a prins migrarea lipsa, reparat; r2 PASS) + `/code-review` high (TOCTOU bootstrap admin mutat in tranzactie + extras `_render_admin` anti-duplicare/N+1). 393 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
| 3.4 | Interfata web ergonomica (tab-uri + wizard + microcopy uman) | TODO | | Reorganizare dashboard: tab-uri sus (Acasa/Import/Mapari/Cont/Nomenclator), import ca stepper 4 pasi, ghid de pornire auto-bifat, etichete umane (`labels.py`) in loc de "worker viu". Doar stratul de prezentare (Jinja2+HTMX), fara backend de trimitere. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md) | | 3.4 | Interfata web ergonomica (tab-uri + wizard + microcopy uman) | DONE | 2026-06-18 | Dashboard reorganizat in 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=` + panou activ server-side + lazy pe rest; bara status cu etichete umane (`app/web/labels.py`) + defalcare blocate; import ca stepper 4 pasi (PUR vizual); Acasa onboarding auto-bifat + empty states. Backend trimitere neatins. 434 teste. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md) |
### Etapa 4 — Viitor (Treapta 3) ### Etapa 4 — Viitor (Treapta 3)

View File

@@ -1,6 +1,6 @@
# PRD 3.4 — Interfata web ergonomica (tab-uri + wizard + microcopy uman) # PRD 3.4 — Interfata web ergonomica (tab-uri + wizard + microcopy uman)
**Stare**: aprobat **Stare**: inchis
> Proces complet: `docs/ROADMAP.md` §5. Contractul RAR (sursa de adevar): `docs/api-rar-contract.md`. > Proces complet: `docs/ROADMAP.md` §5. Contractul RAR (sursa de adevar): `docs/api-rar-contract.md`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead). > Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
@@ -75,9 +75,9 @@ de "pagini ca un wizard, intuitive" si "caption-uri utile, relevante, simple, pe
| `error` | **Eroare la trimitere** | Vezi detaliul randului; se reincearca automat sau necesita corectie. | | `error` | **Eroare la trimitere** | Vezi detaliul randului; se reincearca automat sau necesita corectie. |
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] `labels.py` expune o functie/dict care, pentru fiecare stare din `schema.sql`, da `(text, css_class)`. - [x] `labels.py` expune o functie/dict care, pentru fiecare stare din `schema.sql`, da `(text, css_class)`.
- [ ] Nicio stare de submission existenta nu ramane fara eticheta (test parametrizat care iese rosu daca se adauga o stare noua nemapata). - [x] Nicio stare de submission existenta nu ramane fara eticheta (test parametrizat care iese rosu daca se adauga o stare noua nemapata).
- [ ] Functiile sunt pure (fara DB, fara request) — usor de testat unitar. - [x] Functiile sunt pure (fara DB, fara request) — usor de testat unitar.
- **Verificare E2E**: indirect, prin US-002/US-003 (etichetele apar in UI). - **Verificare E2E**: indirect, prin US-002/US-003 (etichetele apar in UI).
### US-002: Bara de status persistenta cu etichete umane (fragment) ### US-002: Bara de status persistenta cu etichete umane (fragment)
@@ -94,9 +94,9 @@ de "pagini ca un wizard, intuitive" si "caption-uri utile, relevante, simple, pe
`labels.py`. Ramane sticky/vizibil sus indiferent de tab-ul activ. Defalca "Necesita atentia ta" `labels.py`. Ramane sticky/vizibil sus indiferent de tab-ul activ. Defalca "Necesita atentia ta"
pe motive. Pastreaza poll-ul HTMX (`every 15s`) deja existent pentru banner. pe motive. Pastreaza poll-ul HTMX (`every 15s`) deja existent pentru banner.
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] `/_fragments/status` randeaza bara cu etichetele din US-001 (scoped pe cont, ca restul UI). - [x] `/_fragments/status` randeaza bara cu etichetele din US-001 (scoped pe cont, ca restul UI).
- [ ] Bara ramane vizibila sus cand se schimba tab-ul (nu e inghitita de panoul activ). - [x] Bara ramane vizibila sus cand se schimba tab-ul (nu e inghitita de panoul activ).
- [ ] Cand exista submissions blocate, bara arata defalcarea pe motiv, nu doar un numar. - [x] Cand exista submissions blocate, bara arata defalcarea pe motiv, nu doar un numar.
- **Verificare E2E**: browser — incarca `/`, bara de status arata text uman; opreste workerul → - **Verificare E2E**: browser — incarca `/`, bara de status arata text uman; opreste workerul →
"Trimitere automata: oprita". "Trimitere automata: oprita".
@@ -122,12 +122,12 @@ de "pagini ca un wizard, intuitive" si "caption-uri utile, relevante, simple, pe
`role="tabpanel"` pe panou, navigare cu sageti intre tab-uri (JS vanilla minim). Mobil: tab-bar se `role="tabpanel"` pe panou, navigare cu sageti intre tab-uri (JS vanilla minim). Mobil: tab-bar se
ruleaza orizontal / se sparge curat (fara meniu hamburger — pastram simplu). ruleaza orizontal / se sparge curat (fara meniu hamburger — pastram simplu).
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] Tab-bar cu cele 6 tab-uri (Acasa · Import · Coada · Mapari · Cont · Nomenclator); "Acasa" implicit la prima incarcare. - [x] Tab-bar cu cele 6 tab-uri (Acasa · Import · Coada · Mapari · Cont · Nomenclator); "Acasa" implicit la prima incarcare.
- [ ] Un singur panou randat la un moment dat; celelalte fragmente NU se cer pana la activarea tab-ului. - [x] Un singur panou randat la un moment dat; celelalte fragmente NU se cer pana la activarea tab-ului.
- [ ] Panoul activ (inclusiv din `?tab=`) e randat **server-side** la full load — fara palpaire la refresh, vizibil si fara JS. - [x] Panoul activ (inclusiv din `?tab=`) e randat **server-side** la full load — fara palpaire la refresh, vizibil si fara JS.
- [ ] Accesibilitate: `role=tablist/tab/tabpanel`, `aria-selected` pe tab-ul activ, navigare cu sageti (nu doar focus vizibil). - [x] Accesibilitate: `role=tablist/tab/tabpanel`, `aria-selected` pe tab-ul activ, navigare cu sageti (nu doar focus vizibil).
- [ ] Refresh pe un tab non-implicit revine pe acelasi tab (deep-link prin query string `?tab=`). - [x] Refresh pe un tab non-implicit revine pe acelasi tab (deep-link prin query string `?tab=`).
- [ ] Toate functiile existente raman accesibile dintr-un tab (nimic pierdut fata de pagina veche). - [x] Toate functiile existente raman accesibile dintr-un tab (nimic pierdut fata de pagina veche).
- **Verificare E2E**: browser — click pe fiecare tab incarca panoul corect; refresh pe `?tab=import` - **Verificare E2E**: browser — click pe fiecare tab incarca panoul corect; refresh pe `?tab=import`
ramane pe Import; navigare cu sageti intre tab-uri functioneaza (citior de ecran anunta tab-ul activ). ramane pe Import; navigare cu sageti intre tab-uri functioneaza (citior de ecran anunta tab-ul activ).
@@ -154,11 +154,11 @@ de "pagini ca un wizard, intuitive" si "caption-uri utile, relevante, simple, pe
panoului de tab (nu vechiul container de la radacina paginii), iar `csrf_token` din formularele de panoului de tab (nu vechiul container de la radacina paginii), iar `csrf_token` din formularele de
import trebuie sa ramana corect. Verificat prin testele de mai sus + regula de aur. import trebuie sa ramana corect. Verificat prin testele de mai sus + regula de aur.
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] Acelasi stepper apare in upload, mapare-coloane si preview, cu pasul corect evidentiat. - [x] Acelasi stepper apare in upload, mapare-coloane si preview, cu pasul corect evidentiat.
- [ ] Pasii deja parcursi sunt marcati ca facuti; cei viitori sunt estompati. - [x] Pasii deja parcursi sunt marcati ca facuti; cei viitori sunt estompati.
- [ ] Fiecare pas are un titlu-actiune + o fraza scurta de ajutor (microcopy din US-001 unde se aplica). - [x] Fiecare pas are un titlu-actiune + o fraza scurta de ajutor (microcopy din US-001 unde se aplica).
- [ ] `hx-target` din fragmentele de import se rezolva in panoul de tab; `csrf_token` pastrat in formulare. - [x] `hx-target` din fragmentele de import se rezolva in panoul de tab; `csrf_token` pastrat in formulare.
- [ ] Fluxul de import functioneaza identic (upload → mapare → preview → confirma) — fara regresie. - [x] Fluxul de import functioneaza identic (upload → mapare → preview → confirma) — fara regresie.
- **Verificare E2E**: browser — urca `test_data.csv`, parcurge cei 4 pasi; stepper-ul avanseaza corect; - **Verificare E2E**: browser — urca `test_data.csv`, parcurge cei 4 pasi; stepper-ul avanseaza corect;
commit → randuri in coada → worker → FINALIZATA la RAR test (regula de aur). commit → randuri in coada → worker → FINALIZATA la RAR test (regula de aur).
@@ -182,11 +182,11 @@ de "pagini ca un wizard, intuitive" si "caption-uri utile, relevante, simple, pe
discreta ("Totul e configurat — vezi coada"), ca sa nu deranjeze utilizatorul experimentat. Sub ghid, discreta ("Totul e configurat — vezi coada"), ca sa nu deranjeze utilizatorul experimentat. Sub ghid,
pe acelasi tab, un rezumat scurt + scurtaturi (coada recenta / actiuni rapide). pe acelasi tab, un rezumat scurt + scurtaturi (coada recenta / actiuni rapide).
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] Pasul "Conecteaza contul RAR" e nebifat fara creds, bifat cand `are_creds` e adevarat. - [x] Pasul "Conecteaza contul RAR" e nebifat fara creds, bifat cand `are_creds` e adevarat.
- [ ] Pasul "Importa primul fisier" se bifeaza cand contul are cel putin un submission. - [x] Pasul "Importa primul fisier" se bifeaza cand contul are cel putin un submission.
- [ ] Cand toti pasii esentiali sunt gata, ghidul e colapsat/discret (nu ocupa tot ecranul). - [x] Cand toti pasii esentiali sunt gata, ghidul e colapsat/discret (nu ocupa tot ecranul).
- [ ] Link-urile din ghid duc la tab-ul corect (Cont / Import). - [x] Link-urile din ghid duc la tab-ul corect (Cont / Import).
- [ ] **Empty states prietenoase**: tab Coada gol → "Nicio trimitere inca — incepe cu Import" (link la - [x] **Empty states prietenoase**: tab Coada gol → "Nicio trimitere inca — incepe cu Import" (link la
tab Import); tab Mapari gol → mesaj scurt + indemn, nu un tabel/lista goala fara context. tab Import); tab Mapari gol → mesaj scurt + indemn, nu un tabel/lista goala fara context.
- **Verificare E2E**: browser — cont nou (fara creds): ghid vizibil cu pasi nebifati + tab Coada arata - **Verificare E2E**: browser — cont nou (fara creds): ghid vizibil cu pasi nebifati + tab Coada arata
empty state cu indemn la Import; dupa setarea credentialelor si un import, pasii se bifeaza si ghidul se restrange. empty state cu indemn la Import; dupa setarea credentialelor si un import, pasii se bifeaza si ghidul se restrange.
@@ -268,6 +268,46 @@ Val 4: [US-004] [US-005] ← ambele depind de shell-ul de tab-uri (US-003
## Raport VERIFY ## Raport VERIFY
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6. > Verificare condusa de lead (utilizatorul a respins E2E cu browser/server). Acoperire: suita
> PASS/FAIL per criteriu, cu dovezi (output pytest citat, E2E browser pe `http://localhost:8000/`, > pytest completa + verificare ACs prin FastAPI TestClient + spot-check de integrare. E2E cu browser
> plus regula de aur: import → worker → FINALIZATA la RAR test). Lipseste pana la VERIFY. > live si regula de aur LIVE (FINALIZATA la RAR test) NU au fost rulate in aceasta sesiune — vezi nota.
**Suita**: `python3 -m pytest -q`**434 passed** (de la 400 baseline: +34 teste noi 3.4). Fara regresie.
### PASS/FAIL per story
- **US-001 (labels.py)** — PASS. `tests/test_web_labels.py` (11 teste). `test_toate_starile_au_eticheta`
parseaza CHECK-ul din `schema.sql` → iese rosu la stare noua nemapata. Functii pure (fara DB/request).
- **US-002 (bara status)** — PASS. `tests/test_web_status_fragment.py`. `/_fragments/status` randeaza
"Trimitere automata" (nu "worker viu"), defalcare blocate pe motiv, poll `every 15s`, scoped pe cont.
- **US-003 (tab-uri)** — PASS. `tests/test_web_tabs.py` (6 teste). TestClient: `role="tablist"` + 6 tab-uri,
Acasa implicit (`aria-selected="true"`), `/?tab=import` randeaza `#import-section` server-side, panou
activ in HTML initial, role=tab/tabpanel + aria-selected, navigare cu sageti (JS vanilla). Fragmentele
inactive NU se cer la load (swap pe click). Deep-link `?tab=` supravietuieste refresh-ului.
- **US-004 (stepper)** — PASS. `tests/test_web_import_stepper.py` (8 teste). Stepper 4 pasi in
upload(1)/mapcoloane(2)/preview(3), pasii facuti marcati, `aria-current="step"` pe activ, `hx-target="#import-section"`
si `csrf_token` pastrate. Fluxul import (upload→mapare→preview→confirma) neatins — endpointuri intacte.
- **US-005 (Acasa onboarding)** — PASS. `tests/test_web_onboarding.py` (6 teste). Checklist auto-bifat
(are_creds/are_trimiteri), ghid colapsat cand totul gata, linkuri `?tab=cont`/`?tab=import`, empty states
prietenoase pe Coada (indemn Import) si Mapari, scoped pe cont.
### Regula de aur (regresie)
Backend de trimitere (worker, mapping, idempotency, import_router, masina de stari) **neatins** — confirmat
prin diff. Fluxul de import pana la enqueue (`queued`) ramane verde prin `tests/test_import_ui.py` +
`tests/test_import_e2e.py`. **Trimiterea LIVE la RAR test (FINALIZATA) NU a fost probata in aceasta sesiune**
(fara browser/creds RAR test) — recomandata o probare manuala `./start.sh test both --send` inainte de prod.
### Defecte gasite si reparate in cursul VERIFY/CLOSE
1. **Izolare teste (429)**: fixturile noi nu reseteau rate-limiterul de login in-proces (`ratelimit._hits`);
rulate impreuna, login-urile depaseau pragul → 429 → 5 teste rosii la rulare subset. Reparat: `ratelimit._hits.clear()`
in fixturile celor 4 fisiere noi (pattern din `test_web_login.py`). Suita completa trecea din noroc de ordine.
2. **Regresie UX** (code-review): avertismentul "Cont in asteptare de activare" (vechiul `_banner.html`) nu
mai era afisat dupa scoaterea `/_fragments/banner`. Re-introdus in bara de status (`account_active`).
3. **Consistenta tema**: culori hardcodate in `_acasa.html` → variabile paletei (`--muted`/`--accent`/`--ok`).
### Cleanup notat, neremediat (non-blocant)
- Duplicare intre `_render_panel_{mapari,cont,nomenclator}` si endpointurile fragment existente.
- `/_fragments/banner` + `_banner.html` raman dead code dupa mutarea avertismentelor in bara de status.
- Ramura moarta in `_render_panel_for_tab` (fallback acasa fara conn) — inaccesibila (tab pre-validat).
- Bara de status e lazy (HTMX `load`), nu server-side: fara JS arata "se incarca…". AC formale indeplinite
(panoul activ e server-side); de reconsiderat la o iteratie viitoare daca conteaza no-JS pe status.

View File

@@ -47,9 +47,17 @@ def _body(**over):
def test_dashboard_renders_with_rar_state(client): def test_dashboard_renders_with_rar_state(client):
r = client.get("/") r = client.get("/")
assert r.status_code == 200 assert r.status_code == 200
# worker neavand heartbeat -> stare RAR necunoscuta (worker oprit) # Dupa US-003 bara de status e incarcata via HTMX (hx-trigger=load, every 15s)
assert "worker oprit" in r.text assert "/_fragments/status" in r.text, "Dashboard-ul trebuie sa referenceze fragmentul de status"
assert "Nomenclator RAR" in r.text # Fragmentul de status contine starea worker (eticheta umana, nu "worker oprit" brut)
rs = client.get("/_fragments/status")
assert rs.status_code == 200
# eticheta_worker(False) => "Trimitere automata: oprita" → fragmentul afiseaza "oprita"
assert "oprita" in rs.text or "Trimitere automata" in rs.text
# Tab-ul Nomenclator e accesat via /_fragments/nomenclator
rn = client.get("/_fragments/nomenclator")
assert rn.status_code == 200
assert "Nomenclator" in rn.text or "Cod" in rn.text or "OE-1" in rn.text
def test_nomenclator_fragment_lists_seed(client): def test_nomenclator_fragment_lists_seed(client):
@@ -63,7 +71,8 @@ def test_nomenclator_fragment_lists_seed(client):
def test_submissions_fragment_empty_state(client): def test_submissions_fragment_empty_state(client):
r = client.get("/_fragments/submissions") r = client.get("/_fragments/submissions")
assert r.status_code == 200 assert r.status_code == 200
assert "Coada e goala" in r.text # US-005: empty state prietenos cu indemn la Import (nu mesajul tehnic vechi)
assert "Nicio trimitere" in r.text or "incepe cu Import" in r.text or "?tab=import" in r.text
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #

View File

@@ -102,8 +102,11 @@ def _seed_op_mapping(client, cod_op: str = "Revizie", cod_prest: str = "OE-1") -
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_dashboard_contine_drop_zone(client): def test_dashboard_contine_drop_zone(client):
"""Dashboard-ul randeaza sectiunea de upload cu drop zone si mesaj warmth.""" """Tab-ul Import randeaza sectiunea de upload cu drop zone si mesaj warmth.
r = client.get("/")
Dupa US-003 sectiunea de import e in tab-ul Import (?tab=import), nu pe pagina principala.
"""
r = client.get("/?tab=import")
assert r.status_code == 200 assert r.status_code == 200
assert "Primul fisier" in r.text assert "Primul fisier" in r.text
assert "drop-zone" in r.text assert "drop-zone" in r.text

View File

@@ -0,0 +1,293 @@
"""Teste US-004: wizard import cu stepper vizual (4 pasi numerotati).
TDD — testele sunt scrise INAINTE de implementare (RED), apoi se face GREEN.
Verifica:
- Pasul 1 activ (aria-current="step") in fragmentul de upload
- Pasul 2 activ in fragmentul mapare-coloane
- Pasul 3 activ in preview
- Pasii 1 si 2 marcati ca "facuti" in preview (clasa/marcaj)
- hx-target="#import-section" pastrat in fragmentele de import
- csrf_token prezent in formularele de import
"""
from __future__ import annotations
import io
import os
import re
import tempfile
import pytest
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear() # izolare: limiterul login e global in-proces
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
# ---------------------------------------------------------------------------
# Helpere
# ---------------------------------------------------------------------------
def _make_csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
import csv
buf = io.StringIO()
if not rows:
return b""
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
def _make_xlsx_bytes(rows: list[dict]) -> bytes:
openpyxl = pytest.importorskip("openpyxl")
wb = openpyxl.Workbook()
ws = wb.active
if not rows:
return b""
headers = list(rows[0].keys())
ws.append(headers)
for row in rows:
ws.append([row.get(h) for h in headers])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
_SAMPLE_ROWS = [
{
"VIN": "WVWZZZ1KZAW000123",
"Nr inmatriculare": "B001TST",
"Data prestatie": "15.06.2026",
"Odometru final": "123456",
"Operatie": "Revizie",
},
{
"VIN": "WVWZZZ1KZAW000456",
"Nr inmatriculare": "B002TST",
"Data prestatie": "16.06.2026",
"Odometru final": "200000",
"Operatie": "Revizie",
},
]
def _seed_op_mapping(client, cod_op: str = "Revizie", cod_prest: str = "OE-1") -> None:
client.post("/v1/mapari", json={
"cod_op_service": cod_op,
"cod_prestatie": cod_prest,
"auto_send": True,
})
def _upload_and_get_import_id(client, rows=None) -> int:
xlsx = _make_xlsx_bytes(rows or _SAMPLE_ROWS)
r = client.post(
"/_import/upload",
files={"file": ("test.xlsx", xlsx,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)
assert r.status_code == 200
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"Nu s-a gasit import_id in raspuns: {r.text[:500]}"
return int(m.group(1))
def _get_preview_via_mapare(client, import_id: int) -> str:
"""Salveaza maparea de coloane si returneaza textul raspunsului preview."""
r = client.post(
f"/_import/{import_id}/mapare-coloane",
data={
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
"format_data": "DD.MM.YYYY",
},
)
assert r.status_code == 200
return r.text
# ---------------------------------------------------------------------------
# US-004 Teste stepper
# ---------------------------------------------------------------------------
def test_stepper_pas1_la_upload(client):
"""Fragmentul de upload contine stepper-ul cu pasul 1 activ.
Verifica prezenta marcajului aria-current='step' pe pasul 'Incarca fisier'
sau clasa activa asociata pasului 1.
"""
r = client.get("/_import/reset")
assert r.status_code == 200
text = r.text
# Stepper-ul trebuie sa fie prezent
assert "stepper" in text or "pasi-import" in text or "step" in text.lower(), \
"Stepper-ul nu a fost gasit in fragmentul de upload"
# Pasul 1 trebuie sa aiba aria-current="step"
assert 'aria-current="step"' in text, \
"aria-current='step' nu a fost gasit in fragmentul de upload (pasul 1)"
# Textul pasului 1 trebuie sa fie prezent
assert "Incarca" in text, "Textul pasului 1 'Incarca' nu a fost gasit"
def test_stepper_pas1_via_tab_import(client):
"""Accesand /?tab=import, panoul contine stepper cu pasul 1 activ."""
r = client.get("/?tab=import")
assert r.status_code == 200
text = r.text
assert 'aria-current="step"' in text, \
"aria-current='step' nu a fost gasit in panoul Import (/?tab=import)"
assert "Incarca" in text, "Textul pasului 1 'Incarca' nu a fost gasit in panoul Import"
def test_stepper_pas2_la_mapare(client):
"""Fragmentul mapare-coloane contine stepper cu pasul 2 activ.
Declanseaza un upload cu coloane NEMAPATE ca sa primesti _mapcoloane.html.
"""
# Upload fara mapare salvata → trebuie sa vina _mapcoloane.html
csv_bytes = _make_csv_bytes(_SAMPLE_ROWS)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", csv_bytes, "text/csv")},
)
assert r.status_code == 200
text = r.text
# Trebuie sa fie formularul de mapare coloane
assert "mapare-coloane" in text, "Nu s-a primit fragmentul de mapare coloane"
# Stepper prezent
assert "stepper" in text or "step" in text.lower(), \
"Stepper-ul nu a fost gasit in fragmentul mapare-coloane"
# Pasul 2 trebuie sa aiba aria-current="step" cu textul "Potriveste"
# (pasul 1 e facut, pasul 2 e activ)
assert 'aria-current="step"' in text, \
"aria-current='step' nu a fost gasit in fragmentul mapare-coloane (pasul 2)"
assert "Potriveste" in text, "Textul pasului 2 'Potriveste' nu a fost gasit"
def test_stepper_pas3_la_preview(client):
"""Preview contine stepper cu pasul 3 activ.
Declanseaza upload + salvare mapare → se ajunge la preview.
"""
_seed_op_mapping(client)
import_id = _upload_and_get_import_id(client)
text = _get_preview_via_mapare(client, import_id)
# Preview trebuie sa fie prezent
assert "Preview" in text or "confirm-form" in text, \
"Nu s-a primit fragmentul de preview"
# Stepper prezent
assert "stepper" in text or "step" in text.lower(), \
"Stepper-ul nu a fost gasit in preview"
# Pasul 3 activ
assert 'aria-current="step"' in text, \
"aria-current='step' nu a fost gasit in preview (pasul 3)"
assert "Verifica" in text, "Textul pasului 3 'Verifica' nu a fost gasit in preview"
def test_stepper_pas3_la_preview_direct_mapare_retinuta(client):
"""Upload cu mapare retinuta sare direct la preview cu pasul 3 activ.
Primul upload + mapare memoreaza configuratia.
Al doilea upload cu acelasi antet sare direct la preview (pas 3).
Pasii 1 si 2 sunt implicit facuti (comportament stepper la pas=3).
"""
_seed_op_mapping(client)
import_id1 = _upload_and_get_import_id(client)
_get_preview_via_mapare(client, import_id1)
# Al doilea upload — mapare retinuta → preview direct
xlsx = _make_xlsx_bytes(_SAMPLE_ROWS)
r = client.post(
"/_import/upload",
files={"file": ("test2.xlsx", xlsx,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)
assert r.status_code == 200
text = r.text
# Preview direct cu mesaj "Mapare retinuta"
assert "Mapare retinuta" in text, "Preview direct (mapare retinuta) nu a fost randat"
# Stepper prezent cu pasul 3 activ
assert 'aria-current="step"' in text, \
"aria-current='step' nu a fost gasit in preview direct (mapare retinuta)"
assert "Verifica" in text, "Textul pasului 3 'Verifica' nu a fost gasit in preview direct"
def test_stepper_marcheaza_pasii_facuti(client):
"""In preview (pas 3), pasii 1 si 2 sunt marcati ca facuti (clasa 'facut').
Verifica prin prezenta clasei CSS sau a marcajului vizual de 'facut'.
"""
_seed_op_mapping(client)
import_id = _upload_and_get_import_id(client)
text = _get_preview_via_mapare(client, import_id)
# Clasa "facut" trebuie sa apara pentru pasii 1 si 2 (index < pas curent)
assert "facut" in text, \
"Clasa/marcajul 'facut' nu a fost gasit in preview (pasii 1 si 2 ar trebui marcati ca facuti)"
# Numarul de aparitii: cel putin 2 pasi marcati ca facuti
count_facut = text.count("facut")
assert count_facut >= 2, \
f"Asteptat cel putin 2 pasi marcati ca 'facut' in preview, gasit {count_facut}"
def test_import_hx_target_in_tab(client):
"""Fragmentele de import pastreaza hx-target='#import-section'.
Fragmentul de upload (/_import/reset) trebuie sa contina
hx-target='#import-section' pentru ca HTMX sa actualizeze corect
containerul din panoul de tab, nu din alta parte.
"""
r = client.get("/_import/reset")
assert r.status_code == 200
text = r.text
assert 'hx-target="#import-section"' in text, \
"hx-target='#import-section' nu a fost gasit in fragmentul de upload"
# Wrapper-ul extern trebuie sa aiba id="import-section"
assert 'id="import-section"' in text, \
"id='import-section' nu a fost gasit in fragmentul de upload"
def test_import_forms_pastreaza_csrf(client):
"""Formularele de import contin csrf_token (input hidden cu valoare).
Testeaza atat fragmentul de upload cat si cel de mapare coloane.
"""
# Fragment upload
r_upload = client.get("/_import/reset")
assert r_upload.status_code == 200
text_upload = r_upload.text
# Trebuie sa contina campul csrf_token (poate fi gol in modul dev fara sesiune,
# dar campul trebuie sa existe)
assert 'name="csrf_token"' in text_upload, \
"name='csrf_token' nu a fost gasit in formularul de upload"
# Fragment mapare coloane
csv_bytes = _make_csv_bytes(_SAMPLE_ROWS)
r_map = client.post(
"/_import/upload",
files={"file": ("test.csv", csv_bytes, "text/csv")},
)
assert r_map.status_code == 200
text_map = r_map.text
if "mapare-coloane" in text_map: # s-a primit fragmentul de mapare
assert 'name="csrf_token"' in text_map, \
"name='csrf_token' nu a fost gasit in formularul mapare-coloane"

145
tests/test_web_labels.py Normal file
View File

@@ -0,0 +1,145 @@
"""
Teste pentru app/web/labels.py — modul de etichete umane (US-001, PRD 3.4).
Ordinea: RED (scrise inainte de implementare), apoi GREEN dupa creare labels.py.
"""
import re
import pytest
from pathlib import Path
# ---------------------------------------------------------------------------
# Utilitara: extrage starile din CHECK constraint in schema.sql
# ---------------------------------------------------------------------------
def _starile_din_schema() -> list[str]:
"""
Parseaza `app/schema.sql` si returneaza lista starilor din CHECK constraint
al coloanei `status` din tabela `submissions`.
Linia relevanta (schema.sql, tabela submissions):
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error'))
Testul devine automat RED daca cineva adauga o stare noua in schema
fara s-o mapeze in labels.py.
"""
schema_path = Path(__file__).parent.parent / "app" / "schema.sql"
sql = schema_path.read_text(encoding="utf-8")
# Cauta blocul CHECK aferent coloanei status din CREATE TABLE submissions.
# Pattern: CHECK (status IN ('a','b',...)) pe una sau mai multe linii.
match = re.search(
r"CHECK\s*\(\s*status\s+IN\s*\(([^)]+)\)\s*\)",
sql,
)
assert match, "Nu am gasit CHECK (status IN (...)) in schema.sql — schema s-a schimbat?"
raw = match.group(1)
# Extrage valorile dintre ghilimele simple
stari = re.findall(r"'([^']+)'", raw)
assert stari, "Lista de stari din CHECK este goala — ceva s-a stricat la parsare."
return stari
# ---------------------------------------------------------------------------
# Import modulul de etichete (va esua la RED, inainte de implementare)
# ---------------------------------------------------------------------------
from app.web.labels import eticheta_stare, eticheta_worker, eticheta_rar # noqa: E402
# ---------------------------------------------------------------------------
# Teste worker
# ---------------------------------------------------------------------------
def test_eticheta_worker_viu():
text, subtext, css_class = eticheta_worker(viu=True)
assert "Trimitere automata" in text, (
f"Textul pentru worker viu trebuie sa contina 'Trimitere automata', got: {text!r}"
)
assert "activa" in text.lower() or "activa" in subtext.lower(), (
f"Starea 'activa' trebuie sa apara in text sau subtext, got: text={text!r}, subtext={subtext!r}"
)
# Nu trebuie sa afiseze cuvintele tehnice brute
assert "viu" not in text.lower(), f"Textul nu trebuie sa contina 'viu': {text!r}"
# Clasa CSS trebuie sa fie definita (non-vida)
assert css_class, "css_class nu trebuie sa fie vida pentru worker viu"
def test_eticheta_worker_mort():
text, subtext, css_class = eticheta_worker(viu=False)
assert "Trimitere automata" in text, (
f"Textul pentru worker mort trebuie sa contina 'Trimitere automata', got: {text!r}"
)
assert "oprita" in text.lower() or "oprita" in subtext.lower(), (
f"Starea 'oprita' trebuie sa apara in text sau subtext, got: text={text!r}, subtext={subtext!r}"
)
assert "mort" not in text.lower(), f"Textul nu trebuie sa contina 'mort': {text!r}"
assert css_class, "css_class nu trebuie sa fie vida pentru worker mort"
# ---------------------------------------------------------------------------
# Teste eticheta_rar
# ---------------------------------------------------------------------------
def test_eticheta_rar_ok():
text, subtext, css_class = eticheta_rar(stare="ok")
assert "Legatura cu RAR" in text, f"got: {text!r}"
assert "functionala" in text.lower() or "functionala" in subtext.lower()
assert css_class
def test_eticheta_rar_indisponibil():
text, subtext, css_class = eticheta_rar(stare="indisponibil")
assert "Legatura cu RAR" in text, f"got: {text!r}"
assert "indisponibila" in text.lower() or "indisponibila" in subtext.lower()
assert css_class
# ---------------------------------------------------------------------------
# Test eticheta_stare pentru fiecare stare de submission
# ---------------------------------------------------------------------------
def test_eticheta_stare_submission():
"""Verifica textele umane concrete pentru fiecare stare cunoscuta."""
cazuri = {
"queued": ("In asteptare", "s-queued"),
"sent": ("Declarate la RAR", "s-sent"),
"sending": ("Se trimite", "s-sending"),
"needs_mapping": ("Lipseste codul", "s-needs_mapping"),
"needs_data": ("Date incomplete", "s-needs_data"),
"error": ("Eroare", "s-error"),
}
for status, (fragment_text, clasa) in cazuri.items():
text, subtext, css_class = eticheta_stare(status)
assert fragment_text.lower() in text.lower(), (
f"Status {status!r}: asteptam '{fragment_text}' in text, got {text!r}"
)
assert css_class == clasa, (
f"Status {status!r}: asteptam css_class={clasa!r}, got {css_class!r}"
)
# ---------------------------------------------------------------------------
# Test parametrizat: TOATE starile din schema au eticheta (anti-drift)
# ---------------------------------------------------------------------------
_STARI_SCHEMA = _starile_din_schema()
@pytest.mark.parametrize("status", _STARI_SCHEMA)
def test_toate_starile_au_eticheta(status: str):
"""
Fiecare stare din CHECK constraint (schema.sql) trebuie sa aiba o eticheta
non-vida si o clasa CSS non-vida in labels.py.
Daca cineva adauga o stare noua in schema fara s-o mapeze, acest test devine RED.
"""
text, subtext, css_class = eticheta_stare(status)
assert text, f"Status {status!r}: textul etichetei este vid."
assert css_class, f"Status {status!r}: clasa CSS este vida."
# Textul nu trebuie sa fie chiar statusul tehnic brut (ex. "queued" afisat ca atare)
assert text.lower() != status.lower(), (
f"Status {status!r}: eticheta umana este identica cu statusul tehnic — nu e o eticheta umana."
)

View File

@@ -0,0 +1,260 @@
"""Teste US-005 (PRD 3.4): pagina Acasa cu ghid de pornire (checklist auto-bifat).
TDD: testele sunt scrise INAINTE de implementare; la inceput pica (RED),
dupa implementare trec (GREEN).
Rute testate:
- GET / (tab Acasa) -> ghid cu pasi bifati/nebifati in functie de starea contului
- GET /_fragments/acasa -> fragment HTMX pentru tab-ul Acasa
- GET /_fragments/submissions -> empty state prietenos cand coada e goala
- GET /_fragments/mapari -> empty state prietenos cand nu sunt mapari pendinte
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# ============================================================
# Helpers
# ============================================================
def _create_account_user(email: str, password: str = "parolasecreta10"):
"""Creeaza cont + user. Intoarce (acct_id, user_id)."""
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, f"Service Test {email}", active=True)
user_id = create_user(conn, acct_id, email, password)
return acct_id, user_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
resp = client.get("/login")
assert resp.status_code == 200
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit pe /login"
csrf = m.group(1)
resp = client.post("/login", data={
"email": email,
"parola": password,
"csrf_token": csrf,
})
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
def _set_rar_creds(acct_id: int) -> None:
"""Seteaza rar_creds_enc pe cont (simuleaza configurarea credentialelor RAR)."""
from app.db import get_connection
from app.crypto import encrypt_creds
conn = get_connection()
try:
enc = encrypt_creds({"email": "test@rar.ro", "password": "parola_rar"})
conn.execute(
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
(enc, acct_id),
)
finally:
conn.close()
def _add_submission(acct_id: int) -> None:
"""Adauga un submission minimal pentru cont (simuleaza un import efectuat)."""
import json
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, 'queued', ?)",
(f"test_key_{acct_id}", acct_id, json.dumps({"test": True})),
)
finally:
conn.close()
# ============================================================
# Fixture
# ============================================================
@pytest.fixture()
def client(monkeypatch):
"""Client cu BD izolata si autentificare web activata."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "onboarding_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() # izolare: limiterul login e global in-proces
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
# ============================================================
# test_checklist_pas_creds_neconfigurat
# ============================================================
def test_checklist_pas_creds_neconfigurat(client):
"""Cont fara creds RAR -> pasul 'Conecteaza contul RAR' e NEbifat."""
acct_id, _ = _create_account_user("nocreds@test.com")
_login(client, "nocreds@test.com")
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# Pasul de conectare RAR trebuie sa apara
assert "Conecteaza" in html or "cont RAR" in html or "RAR" in html, \
"Ghidul nu contine referinta la conectarea contului RAR"
# Cand nu sunt creds, pasul NU trebuie sa fie bifat
# Bifarea e semnalata printr-o clasa 'bifat' sau o checkmark langa text-ul RAR
# Verificam ca nu apare combinatia "bifat" + "RAR" sau "done" + "RAR" in proximitate
# (implementarea exacta e in template, dar pattern-ul de baza: fara `pas-bifat` langa RAR)
assert not re.search(
r'pas-bifat[^<]*Conecteaza|Conecteaza[^<]*pas-bifat',
html, re.DOTALL | re.IGNORECASE
), "Pasul RAR nu trebuie sa fie bifat cand contul nu are creds"
# ============================================================
# test_checklist_pas_creds_bifat_cand_exista
# ============================================================
def test_checklist_pas_creds_bifat_cand_exista(client):
"""Dupa setarea rar_creds_enc pe cont -> pasul 'Conecteaza contul RAR' e bifat."""
acct_id, _ = _create_account_user("withcreds@test.com")
_set_rar_creds(acct_id)
_login(client, "withcreds@test.com")
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# Cand exista creds, pasul trebuie sa fie bifat
# Verificam prezenta unui indicator de bifat (clasa 'bifat' sau 'pas-bifat' sau 'done')
# Cel putin unul dintre pattern-urile de bifat trebuie sa apara
assert re.search(
r'pas-bifat|class="[^"]*bifat|done.*RAR|RAR.*done|checkmark.*RAR|RAR.*checkmark',
html, re.DOTALL | re.IGNORECASE
), "Pasul RAR trebuie sa fie bifat cand contul are creds configurate"
# ============================================================
# test_checklist_ascuns_cand_totul_gata
# ============================================================
def test_checklist_ascuns_cand_totul_gata(client):
"""Creds setate + cel putin un submission -> ghidul se colapseaza/devine discret."""
acct_id, _ = _create_account_user("allset@test.com")
_set_rar_creds(acct_id)
_add_submission(acct_id)
_login(client, "allset@test.com")
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# Cand totul e gata, ghidul compact/discret trebuie sa apara
# Fie "Totul e configurat" fie un link discret catre coada
assert "Totul e configurat" in html or "totul e configurat" in html.lower(), \
"Cand toti pasii sunt gata, trebuie sa apara mesajul discret 'Totul e configurat'"
# Cardul mare de pasi nu trebuie sa ocupe ecranul
# Verificam ca nu mai apare titlul mare al ghidului (Primii pasi)
# SAU ca ghidul e marcat ca colapsat (clasa 'ghid-complet' sau similar)
# Pattern: fie ghid-complet, fie lipsa titlului complet "Primii pasi" in forma de card mare
assert "ghid-complet" in html or "Totul e configurat" in html, \
"Ghidul trebuie sa se colapseze cand toti pasii esentiali sunt finalizati"
# ============================================================
# test_linkuri_ghid_duc_la_taburi
# ============================================================
def test_linkuri_ghid_duc_la_taburi(client):
"""Link-urile din ghid contin ?tab=cont si ?tab=import."""
acct_id, _ = _create_account_user("links@test.com")
_login(client, "links@test.com")
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# Ghidul trebuie sa contina link catre tab-ul Cont
assert "?tab=cont" in html, \
"Ghidul nu contine link catre tab-ul Cont (?tab=cont)"
# Ghidul trebuie sa contina link catre tab-ul Import
assert "?tab=import" in html, \
"Ghidul nu contine link catre tab-ul Import (?tab=import)"
# ============================================================
# test_empty_state_coada_gol
# ============================================================
def test_empty_state_coada_gol(client):
"""Tab Coada fara submissions -> indemn prietenos catre Import, nu mesaj tehnic."""
acct_id, _ = _create_account_user("emptyq@test.com")
_login(client, "emptyq@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Nu trebuie sa apara mesajul tehnic vechi cu POST /v1/prezentari
assert "POST /v1/prezentari" not in html, \
"Empty state coada nu trebuie sa contina mesajul tehnic vechi 'POST /v1/prezentari'"
# Trebuie sa contina un indemn catre Import
assert "import" in html.lower() or "Import" in html, \
"Empty state coada trebuie sa contina indemn catre Import"
# Trebuie sa contina link catre ?tab=import
assert "?tab=import" in html, \
"Empty state coada trebuie sa contina link ?tab=import"
# ============================================================
# test_empty_state_mapari_gol
# ============================================================
def test_empty_state_mapari_gol(client):
"""Tab Mapari fara pending -> mesaj prietenos cu indemn (nu lista goala fara context)."""
acct_id, _ = _create_account_user("emptym@test.com")
_login(client, "emptym@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
# Trebuie sa apara un mesaj prietenos cand nu sunt mapari pendinte
# Nu verificam exact textul, dar trebuie sa existe un indemn/explicatie
assert "Nicio operatie nemapata" in html or "totul" in html.lower() or "import" in html.lower(), \
"Empty state mapari trebuie sa contina mesaj prietenos"
# Trebuie sa contina un indemn catre Import sau o explicatie clara
# (cel putin link catre import sau mentionarea cuvantului)
assert "import" in html.lower() or "?tab=import" in html, \
"Empty state mapari trebuie sa contina indemn catre Import"

View File

@@ -0,0 +1,187 @@
"""Teste US-002 (PRD 3.4): bara de status persistenta cu etichete umane.
TDD: testele se scriu INAINTE de implementare; la inceput pica (RED),
dupa implementare trec (GREEN).
Rute testate:
- GET /_fragments/status -> bara de status cu etichete umane, scoped pe cont
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str = "user@test.com", password: str = "parolasecreta10"):
"""Creeaza cont + user. Intoarce (acct_id, user_id)."""
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 Status", active=True)
user_id = create_user(conn, acct_id, email, password)
return acct_id, user_id
finally:
conn.close()
def _login(client, email: str, password: str) -> None:
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
resp = client.get("/login")
assert resp.status_code == 200
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit pe /login"
csrf = m.group(1)
resp = client.post("/login", data={
"email": email,
"parola": password,
"csrf_token": csrf,
})
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
def _insert_submission(status: str, account_id: int) -> None:
"""Insereaza un submission cu status dat pentru un cont dat."""
from app.db import get_connection
import json
conn = get_connection()
try:
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(
f"test-key-{status}-{account_id}-{os.urandom(4).hex()}",
account_id,
status,
json.dumps({"vin": "TEST", "status": status}),
),
)
conn.commit()
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
"""Client cu BD izolata si autentificare web activata."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "status_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() # izolare: limiterul login e global in-proces
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
# ============================================================
# test_status_fragment_text_uman
# ============================================================
def test_status_fragment_text_uman(client):
"""GET /_fragments/status (autentificat) -> contine 'Trimitere automata', NU 'worker viu'."""
_create_account_user("status@test.com", "parolasecreta10")
_login(client, "status@test.com", "parolasecreta10")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
# Trebuie sa contina textul uman din eticheta_worker (labels.py)
assert "Trimitere automata" in html, (
f"Fragmentul nu contine 'Trimitere automata'. HTML (primele 500 ch): {html[:500]}"
)
# NU trebuie sa contina textul brut tehnic
assert "worker viu" not in html.lower(), (
f"Fragmentul contine 'worker viu' (text tehnic brut). HTML (primele 500 ch): {html[:500]}"
)
# NU trebuie sa contina "mort" (stare tehnica bruta)
# (poate aparea in 'oprita' -> acceptam; 'mort' singur -> nu)
# Verificam ca nu apare 'mort' ca eticheta standalone
assert "viu</div>" not in html, (
"Fragmentul contine eticheta bruta 'viu'"
)
# ============================================================
# test_status_blocate_defalcare
# ============================================================
def test_status_blocate_defalcare(client):
"""Cu submissions blocate in DB, fragmentul arata defalcarea pe motiv (texte umane)."""
acct_id, _ = _create_account_user("blocate@test.com", "parolasecreta10")
_login(client, "blocate@test.com", "parolasecreta10")
# Insereaza submissions blocate din fiecare tip
_insert_submission("needs_mapping", acct_id)
_insert_submission("needs_mapping", acct_id)
_insert_submission("needs_data", acct_id)
_insert_submission("error", acct_id)
resp = client.get("/_fragments/status")
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]}"
)
# 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"
)
assert "Date incomplete" in html, (
"Fragmentul nu arata eticheta pentru needs_data"
)
assert "Eroare la trimitere" in html, (
"Fragmentul nu arata eticheta 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"
# ============================================================
# test_status_se_reincarca_htmx
# ============================================================
def test_status_se_reincarca_htmx(client):
"""Fragmentul contine atribut hx-trigger cu poll periodic (every 15s)."""
_create_account_user("htmx@test.com", "parolasecreta10")
_login(client, "htmx@test.com", "parolasecreta10")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
# Trebuie sa contina hx-trigger periodic
assert "hx-trigger" in html, (
f"Fragmentul nu contine atribut hx-trigger. HTML: {html[:500]}"
)
assert "every 15s" in html, (
f"Fragmentul nu contine poll 'every 15s'. HTML: {html[:500]}"
)
# Trebuie sa aiba endpoint corect pentru auto-refresh
assert "/_fragments/status" in html, (
"Fragmentul nu contine referinta la /_fragments/status pentru hx-get"
)
# Trebuie sa aiba un id stabil pe containerul radacina
assert 'id="status-bar"' in html, (
"Fragmentul nu are id='status-bar' pe containerul radacina"
)

212
tests/test_web_tabs.py Normal file
View File

@@ -0,0 +1,212 @@
"""Teste US-003 (PRD 3.4): navigare cu tab-uri (shell dashboard).
TDD: testele se scriu INAINTE de implementare; la inceput pica (RED),
dupa implementare trec (GREEN).
Rute testate:
- GET / -> dashboard cu tab-bar si panou activ randat server-side
- GET /?tab=<name> -> deep-link, panoul corespunzator randat server-side
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str = "tabs@test.com", password: str = "parolasecreta10"):
"""Creeaza cont + user. Intoarce (acct_id, user_id)."""
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 Tabs", active=True)
user_id = create_user(conn, acct_id, email, password)
return acct_id, user_id
finally:
conn.close()
def _login(client, email: str, password: str) -> None:
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
resp = client.get("/login")
assert resp.status_code == 200
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit pe /login"
csrf = m.group(1)
resp = client.post("/login", data={
"email": email,
"parola": password,
"csrf_token": csrf,
})
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
@pytest.fixture()
def client(monkeypatch):
"""Client cu BD izolata si autentificare web activata."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "tabs_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() # izolare: limiterul login e global in-proces
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
# ============================================================
# test_dashboard_are_tabbar
# ============================================================
def test_dashboard_are_tabbar(client):
"""Dashboard-ul contine un tab-bar cu cele 6 tab-uri."""
_create_account_user("tabbar@test.com", "parolasecreta10")
_login(client, "tabbar@test.com", "parolasecreta10")
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
assert 'role="tablist"' in html, "Lipseste role=tablist"
# Cele 6 tab-uri trebuie sa fie prezente
for label in ("Acasa", "Import", "Coada", "Mapari", "Cont", "Nomenclator"):
assert label in html, f"Lipseste tab-ul '{label}' din tab-bar"
# ============================================================
# test_tab_implicit_acasa
# ============================================================
def test_tab_implicit_acasa(client):
"""Fara ?tab=, tab-ul Acasa are aria-selected=true."""
_create_account_user("implicit@test.com", "parolasecreta10")
_login(client, "implicit@test.com", "parolasecreta10")
resp = client.get("/")
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"
# ============================================================
# test_deeplink_tab_import
# ============================================================
def test_deeplink_tab_import(client):
"""/?tab=import randeaza panoul Import server-side la full load."""
_create_account_user("deeplink@test.com", "parolasecreta10")
_login(client, "deeplink@test.com", "parolasecreta10")
resp = client.get("/?tab=import")
assert resp.status_code == 200
html = resp.text
# Panoul Import trebuie sa contina id="import-section" (din _upload.html)
assert 'id="import-section"' in html, (
"Panoul Import nu contine id='import-section' la full load cu ?tab=import"
)
# ============================================================
# test_tab_activ_randat_server_side
# ============================================================
def test_tab_activ_randat_server_side(client):
"""Panoul activ e in HTML-ul initial, nu doar cerut prin 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
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"
# Import tab server-side
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"
# ============================================================
# test_fragmentele_inactive_lazy
# ============================================================
def test_fragmentele_inactive_lazy(client):
"""Panourile inactive nu se cer la load — fara hx-trigger=load pe fragmentele inactive."""
_create_account_user("lazy@test.com", "parolasecreta10")
_login(client, "lazy@test.com", "parolasecreta10")
# La tab implicit (Acasa): panoul de submissions (Coada) NU trebuie sa fie in HTML
# cu hx-trigger="load" (ar insemna ca se incarca automat la deschiderea paginii)
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# Verificam ca nu exista un container de submissions cu hx-trigger care include "load"
# cand Coada NU e tab-ul activ
# Pattern: hx-get="/_fragments/submissions" ... hx-trigger="load..."
# Aceasta combinatie NU trebuie sa apara cand tab-ul activ e Acasa
submissions_load_pattern = re.search(
r'hx-get="/_fragments/submissions"[^>]*hx-trigger="[^"]*load|'
r'hx-trigger="[^"]*load[^"]*"[^>]*hx-get="/_fragments/submissions"',
html
)
assert not submissions_load_pattern, (
"Fragmentul de submissions (Coada) are hx-trigger=load cand tab-ul activ nu e Coada"
)
# La ?tab=coada: panoul de submissions TREBUIE sa fie in HTML (randat server-side sau cu poll)
resp2 = client.get("/?tab=coada")
assert resp2.status_code == 200
html2 = resp2.text
# Cand Coada e activ, containerul de submissions trebuie sa existe
assert "/_fragments/submissions" in html2 or "Coada submissions" in html2, (
"Panoul Coada nu contine referinta la submissions cand e tab-ul activ"
)
# ============================================================
# test_tabbar_aria
# ============================================================
def test_tabbar_aria(client):
"""Prezenta atributelor ARIA: role=tablist/tab/tabpanel, aria-selected."""
_create_account_user("aria@test.com", "parolasecreta10")
_login(client, "aria@test.com", "parolasecreta10")
resp = client.get("/")
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"