- RAR posibil indisponibil — coada de mai jos arata ultima stare cunoscuta (local), nu live din RAR.
-
-
+
+
+ {{ panel_html | safe }}
-
+
{% endblock %}
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 373bfa3..c514b00 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -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:
> 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`.
@@ -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.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.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)
diff --git a/docs/prd/prd-3.4-ux-dashboard-web.md b/docs/prd/prd-3.4-ux-dashboard-web.md
index 47a393c..3560bd1 100644
--- a/docs/prd/prd-3.4-ux-dashboard-web.md
+++ b/docs/prd/prd-3.4-ux-dashboard-web.md
@@ -1,6 +1,6 @@
# 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`.
> 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. |
- **Acceptance criteria**:
- - [ ] `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).
- - [ ] Functiile sunt pure (fara DB, fara request) — usor de testat unitar.
+ - [x] `labels.py` expune o functie/dict care, pentru fiecare stare din `schema.sql`, da `(text, css_class)`.
+ - [x] Nicio stare de submission existenta nu ramane fara eticheta (test parametrizat care iese rosu daca se adauga o stare noua nemapata).
+ - [x] Functiile sunt pure (fara DB, fara request) — usor de testat unitar.
- **Verificare E2E**: indirect, prin US-002/US-003 (etichetele apar in UI).
### 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"
pe motive. Pastreaza poll-ul HTMX (`every 15s`) deja existent pentru banner.
- **Acceptance criteria**:
- - [ ] `/_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).
- - [ ] Cand exista submissions blocate, bara arata defalcarea pe motiv, nu doar un numar.
+ - [x] `/_fragments/status` randeaza bara cu etichetele din US-001 (scoped pe cont, ca restul UI).
+ - [x] Bara ramane vizibila sus cand se schimba tab-ul (nu e inghitita de panoul activ).
+ - [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 →
"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
ruleaza orizontal / se sparge curat (fara meniu hamburger — pastram simplu).
- **Acceptance criteria**:
- - [ ] 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.
- - [ ] 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).
- - [ ] 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] Tab-bar cu cele 6 tab-uri (Acasa · Import · Coada · Mapari · Cont · Nomenclator); "Acasa" implicit la prima incarcare.
+ - [x] Un singur panou randat la un moment dat; celelalte fragmente NU se cer pana la activarea tab-ului.
+ - [x] Panoul activ (inclusiv din `?tab=`) e randat **server-side** la full load — fara palpaire la refresh, vizibil si fara JS.
+ - [x] Accesibilitate: `role=tablist/tab/tabpanel`, `aria-selected` pe tab-ul activ, navigare cu sageti (nu doar focus vizibil).
+ - [x] Refresh pe un tab non-implicit revine pe acelasi tab (deep-link prin query string `?tab=`).
+ - [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`
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
import trebuie sa ramana corect. Verificat prin testele de mai sus + regula de aur.
- **Acceptance criteria**:
- - [ ] Acelasi stepper apare in upload, mapare-coloane si preview, cu pasul corect evidentiat.
- - [ ] 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).
- - [ ] `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] Acelasi stepper apare in upload, mapare-coloane si preview, cu pasul corect evidentiat.
+ - [x] Pasii deja parcursi sunt marcati ca facuti; cei viitori sunt estompati.
+ - [x] Fiecare pas are un titlu-actiune + o fraza scurta de ajutor (microcopy din US-001 unde se aplica).
+ - [x] `hx-target` din fragmentele de import se rezolva in panoul de tab; `csrf_token` pastrat in formulare.
+ - [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;
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,
pe acelasi tab, un rezumat scurt + scurtaturi (coada recenta / actiuni rapide).
- **Acceptance criteria**:
- - [ ] 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.
- - [ ] 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).
- - [ ] **Empty states prietenoase**: tab Coada gol → "Nicio trimitere inca — incepe cu Import" (link la
+ - [x] Pasul "Conecteaza contul RAR" e nebifat fara creds, bifat cand `are_creds` e adevarat.
+ - [x] Pasul "Importa primul fisier" se bifeaza cand contul are cel putin un submission.
+ - [x] Cand toti pasii esentiali sunt gata, ghidul e colapsat/discret (nu ocupa tot ecranul).
+ - [x] Link-urile din ghid duc la tab-ul corect (Cont / Import).
+ - [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.
- **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.
@@ -268,6 +268,46 @@ Val 4: [US-004] [US-005] ← ambele depind de shell-ul de tab-uri (US-003
## Raport VERIFY
-> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
-> PASS/FAIL per criteriu, cu dovezi (output pytest citat, E2E browser pe `http://localhost:8000/`,
-> plus regula de aur: import → worker → FINALIZATA la RAR test). Lipseste pana la VERIFY.
+> Verificare condusa de lead (utilizatorul a respins E2E cu browser/server). Acoperire: suita
+> pytest completa + verificare ACs prin FastAPI TestClient + spot-check de integrare. E2E cu browser
+> 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.
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index f9a69e6..8b8ecf7 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -47,9 +47,17 @@ def _body(**over):
def test_dashboard_renders_with_rar_state(client):
r = client.get("/")
assert r.status_code == 200
- # worker neavand heartbeat -> stare RAR necunoscuta (worker oprit)
- assert "worker oprit" in r.text
- assert "Nomenclator RAR" in r.text
+ # Dupa US-003 bara de status e incarcata via HTMX (hx-trigger=load, every 15s)
+ assert "/_fragments/status" in r.text, "Dashboard-ul trebuie sa referenceze fragmentul de status"
+ # 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):
@@ -63,7 +71,8 @@ def test_nomenclator_fragment_lists_seed(client):
def test_submissions_fragment_empty_state(client):
r = client.get("/_fragments/submissions")
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
# --------------------------------------------------------------------------- #
diff --git a/tests/test_import_ui.py b/tests/test_import_ui.py
index 5fa051a..81a90b4 100644
--- a/tests/test_import_ui.py
+++ b/tests/test_import_ui.py
@@ -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):
- """Dashboard-ul randeaza sectiunea de upload cu drop zone si mesaj warmth."""
- r = client.get("/")
+ """Tab-ul Import randeaza sectiunea de upload cu drop zone si mesaj warmth.
+
+ 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 "Primul fisier" in r.text
assert "drop-zone" in r.text
diff --git a/tests/test_web_import_stepper.py b/tests/test_web_import_stepper.py
new file mode 100644
index 0000000..0bd4720
--- /dev/null
+++ b/tests/test_web_import_stepper.py
@@ -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"
diff --git a/tests/test_web_labels.py b/tests/test_web_labels.py
new file mode 100644
index 0000000..2d139d4
--- /dev/null
+++ b/tests/test_web_labels.py
@@ -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."
+ )
diff --git a/tests/test_web_onboarding.py b/tests/test_web_onboarding.py
new file mode 100644
index 0000000..566250e
--- /dev/null
+++ b/tests/test_web_onboarding.py
@@ -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"
diff --git a/tests/test_web_status_fragment.py b/tests/test_web_status_fragment.py
new file mode 100644
index 0000000..7bf60fd
--- /dev/null
+++ b/tests/test_web_status_fragment.py
@@ -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
" 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"
+ )
diff --git a/tests/test_web_tabs.py b/tests/test_web_tabs.py
new file mode 100644
index 0000000..e2474b4
--- /dev/null
+++ b/tests/test_web_tabs.py
@@ -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=