feat(5.10): UX trimiteri (pill filtre, paginare, editare) + Mapari in meniu + branding ROMFAST

14 stories TDD prin echipa de workeri (lead orchestreaza, 3 teammates pe valuri cu fisiere disjuncte; routes.py + base.html serializate ca fisiere fierbinti).

- US-001 fix filtrare data (_iso_date_prefix pe garda+comparatie, prinde timestamp cu ora)
- US-002/007 operatie service distincta in payload_view + afisare in detaliu
- US-003 pill-uri categorii (button/aria-pressed; needs_mapping --warn, needs_data/error --err); fara lista ID-uri/dropdown
- US-004 paginare numerotata 25/pag (total ramificat SQL-COUNT vs fetch-all+slice, clamp page, poll pastreaza pagina)
- US-005 VIN block-level sub nr
- US-006/006b editare cod RAR + validare nomenclator + recalcul idempotency (needs_data/needs_mapping via /corecteaza, error via /repune)
- US-008 card eroare 3-niveluri doar pe read-only + rezumat top-of-form
- US-009 Mapari in meniu hamburger; scoatere tab-bar + role=tablist orfan
- US-010/011 pagina Mapari consolidata + butoane icon SVG + dirty-state (fara kebab/emoji)
- US-012/012b header centrat + logo ROMFAST (/static/romfast_logo.png) in header
- US-013 paleta azur ROMFAST (#2E74D6/#1F66C9) + IBM Plex Sans/Mono self-host (woff2 reale)
- US-014 selector tema ciclic Light/Dark/Petrol/Auto + anti-FOUC pe 4 stari

Backend trimitere (worker/masina stari/idempotenta/mapping) + schema NEATINSE (UI/UX pur + 1 fix de filtrare).
VERIFY context curat PASS; /code-review high: 1 finding material reparat (US-006b). Regresie 896 passed, 1 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-25 20:20:58 +00:00
parent 3bc0825e0b
commit 5a964a1a8d
43 changed files with 3949 additions and 414 deletions

150
DESIGN.md Normal file
View File

@@ -0,0 +1,150 @@
# DESIGN.md — Sistem de design AutoPass (by ROMFAST)
> Sursa de adevar pentru identitatea vizuala a dashboard-ului. Implementarea concreta sta in
> `app/web/templates/base.html` (variabile CSS `:root` + `[data-theme="light"]`). Acest fisier
> spune *ce* si *de ce*; base.html spune *cum*.
## Lucrul de retinut
> „Software serios pentru o obligatie legala serioasa — dar parte din familia ROMFAST/ROA, nu un
> tool anonim." Operatorul de service trebuie sa simta ca declara la stat printr-un instrument de
> incredere, cu identitatea producatorului (ROMFAST) prezenta discret, nu griul generic de azi.
## Context produs
Gateway web care declara prezentari de service-auto la RAR AUTOPASS (L.142/2023). Utilizatori:
operatori de service-auto si integratori ROAAUTO. Face parte din familia **ROA — Romfast
Applications** (ERP romanesc, modul Service Auto). Referinta de brand: **romfast.ro** — alb curat,
accent albastru azur, pill-uri rotunjite, comutator de tema, logo rosu+albastru.
## Decizie cromatica
Accentul functional = **albastrul ROMFAST** (acelasi cu „FAST" din logo si cu accentul de pe
romfast.ro), nu albastrul generic SaaS de pana acum. Rosul apare DOAR in wordmark-ul „ROM" — nu ca
accent de UI, fiindca rosul e rezervat starilor de eroare. Un singur accent, restul neutre, ca
sistemul sa ramana discret.
### Paleta — Dark (default)
```
--bg: #0f1218 fundal aplicatie
--card: #181c24 suprafete (carduri, modal, inputuri pe fundal)
--ink: #e6e9ef text principal
--muted: #8b93a7 text secundar (label-uri, coduri, „by")
--line: #262b36 borduri, separatoare
--accent:#2E74D6 azur ROMFAST — butoane primare, pill activ, linkuri, focus
--ok: #2FBF8F sent / succes
--warn: #E0A93B sending / atentie / Lipsa cod
--err: #E05D5D error / needs_data / Date incomplete
```
### Paleta — Light (`[data-theme="light"]`)
```
--bg: #f5f7fa fundal (alb-rece ca romfast.ro)
--card: #ffffff suprafete
--ink: #1a1d24 text principal
--muted: #5c6473 text secundar
--line: #e2e5ea borduri
--accent:#1F66C9 azur, variantă mai inchisa pentru contrast AA pe alb
--ok: #15803d verde AA pe alb
--warn: #b45309 chihlimbar AA pe alb
--err: #dc2626 rosu AA pe alb
```
### Paleta — Petrol (`[data-theme="petrol"]`, tema selectabila)
Tema intunecata alternativa, cu accent petrol-teal (directia initiala aleasa, pastrata ca optiune).
Aceleasi neutre-calde inchise; doar accentul difera de azur.
```
--bg: #0e1416 fundal petrol-inchis
--card: #161e20 suprafete
--ink: #e6e9ef text principal
--muted: #8b93a7 text secundar
--line: #232c2e borduri
--accent:#0E7C7B teal petrol — butoane, pill activ, linkuri, focus
--ok: #2FBF8F sent
--warn: #E0A93B atentie
--err: #E05D5D eroare
```
### Culori de brand (doar wordmark, NU variabile de UI)
```
ROM: #D1342F rosu logo
FAST: #2E74D6 albastru logo (= accentul de UI in dark)
```
Contrast: textul principal pe fundal ramane AA in ambele teme; accentul pe alb foloseste varianta
mai inchisa (`#1F66C9`) ca text/linkul sa treaca AA.
## Tipografie
- **UI / titluri**: **IBM Plex Sans** — sans-serif cu caracter ingineresc, open-source, potrivit
pentru „software serios", parte din limbajul vizual tehnic. Fallback: `system-ui, sans-serif`.
- **Coduri / monospace**: **IBM Plex Mono** — pentru coduri RAR (REV2), VIN, numar inmatriculare,
detalii tehnice. Inlocuieste `ui-monospace/Menlo` actual cu o familie coerenta cu UI-ul.
- **Incarcare**: self-host `woff2` (subset latin + latin-ext pentru diacritice romanesti) in
`app/web/static/fonts/`, `font-display: swap`. Fara CDN extern (gateway intern, fara dependente
de retea la runtime). Pana la self-host, fallback la stiva de sistem nu strica layout-ul.
## Header & branding
- Titlul „Gateway RAR AUTOPASS" **centrat** pe header.
- Sub titlu, mic: **logo-ul ROMFAST** (`/static/romfast_logo.png`, ~28px inaltime). Decizie user
(2026-06-25, US-012b): se foloseste PNG-ul real al logo-ului (ROM rosu + FAST albastru, fundal
transparent — lizibil pe light/dark/petrol), NU wordmark-ul text. Wordmark-ul text (`by ROM FAST`
cu `ROM #D1342F` / `FAST #2E74D6`) ramane documentat ca alternativa, dar livrabila finala
foloseste imaginea.
- Controalele (comutator tema, versiune, hamburger ☰) raman la **dreapta**, fara a strica
centrarea optica a titlului (ex. grila 3 coloane: stanga goala/echilibru, centru titlu, dreapta
controale).
- Responsiv: pe mobil, wordmark-ul ramane sub titlu; controalele nu se suprapun (degrada elegant,
eventual titlu mai mic).
## Selector de tema
Inlocuieste comutatorul binar soare/luna cu un **buton ciclic** (pattern ca demoanaf.ro): un
singur buton care roteste la fiecare click prin setul de teme, cu iconita + tooltip/`aria-label`
care arata tema curenta („Tema: Light" etc.).
Ordinea ciclului: **Light → Dark → Petrol → Auto → (inapoi la Light)**.
- `Light``data-theme="light"` (azur pe alb)
- `Dark``data-theme="dark"` (azur pe inchis, comportamentul implicit actual)
- `Petrol``data-theme="petrol"` (teal pe petrol-inchis)
- `Auto` → urmeaza `prefers-color-scheme`; rezolva la Light azur sau Dark azur in functie de OS
(nu seteaza `data-theme` fix, ci il deriva la paint).
Persistenta: preferinta explicita (inclusiv „Auto") in `localStorage`, doar la click. Scriptul
anti-FOUC din `<head>` trebuie sa rezolve „Auto"→light/dark inainte de primul paint (fara blink).
Iconite: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto. Default la prima vizita = Auto (OS-aware), ca azi.
## Componente — note de aplicare
- **Pill-uri de stare/filtru**: rotunjite (`border-radius:99px`), ca badge-ul „ROA" de pe
romfast.ro. Pill activ = fundal accent discret (`color-mix(in srgb, var(--accent) ...)`), text
pe accent. Categoriile de problema isi pastreaza registrul: Date incomplete/Eroare = `--err`,
Lipsa cod = `--warn`.
- **Butoane primare**: fundal `--accent`, text alb (neschimbat ca structura, doar culoarea noua).
- **Linkuri / sugestii**: `--accent`.
- **Focus**: `outline:2px solid var(--accent)` (deja folosit pe randuri).
- **Suprafete de stare** (banner, flash, eroare-3n): raman pe `color-mix` peste `--err/--warn/--ok`,
deci se adapteaza automat la noua paleta si la light/dark.
## Ce NU schimbam
- Mecanismul light/dark existent (anti-FOUC, persistenta `localStorage`, comutator) — il pastram,
doar reimprospatam variabilele.
- Nu introducem rosu ca accent de UI (conflict cu eroare).
- ~~Nu folosim PNG-ul logo cu efect 3D in interfata (wordmark redat ca text).~~ REVIZUIT
(decizie user 2026-06-25): logo-ul PNG real e folosit in header (US-012b). Fundal transparent +
culori proprii il fac lizibil pe toate temele; nu aplicam filtre.
- Nu adaugam un al doilea accent — sistemul ramane monocrom-accent + neutre.
## Legatura cu implementarea (PRD 5.10)
US-012 (header „by ROMFAST" + titlu centrat) si US-013 (paleta) din
`docs/prd/prd-5.10-ux-filtre-pill-paginare-mapari-meniu.md` implementeaza acest sistem. Valorile de
mai sus sunt sursa pentru variabilele din `base.html`.

View File

@@ -115,6 +115,16 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
# cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0"
cod_rar = _clean_cod_rar(item.get("cod_prestatie"))
# US-002: operatia de service originala (codul intern + denumire venita prin API/import),
# distincta de operatia RAR mapata (cod_rar).
# Conventie goala: aceste campuri NOI intorc "" (string gol) cand lipsesc — NU EMPTY="—".
# Motivul: US-007 decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`.
# Campurile vechi (vehicul_nr, vin, operatie etc.) pastreaza conventia EMPTY="—".
op_service_cod = _clean_str(item.get("cod_op_service"))
# op_service_denumire e relevant doar cand exista un cod de operatie de service;
# altfel ar expune denumirea RAR drept op. de service, ceea ce e semantic incorect.
op_service_denumire = _clean_str(item.get("denumire")) if op_service_cod else ""
return {
"vehicul_nr": nr or EMPTY,
"vin": vin or EMPTY,
@@ -124,4 +134,7 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
"odometru": odo or EMPTY,
"cod": cod or EMPTY,
"cod_rar": cod_rar or EMPTY,
# US-002: chei noi cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
"op_service_cod": op_service_cod,
"op_service_denumire": op_service_denumire,
}

View File

@@ -13,6 +13,7 @@ from __future__ import annotations
import hashlib
import json
import math
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
@@ -555,43 +556,26 @@ def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
return rezultat
# Cate randuri blocate identificam nominal sub fiecare categorie din banner (US-014).
_BLOCATE_SAMPLE = 3
def _pills_categorii(counts: dict[str, int]) -> list[dict]:
"""Pill-uri pentru starile cu problema (US-003 PRD 5.10).
def _blocate_actionabil(conn, account_id: int) -> list[dict]:
"""Categorii blocate cu identificatorii primelor randuri + deep-link (US-014).
Pentru fiecare stare blocata cu n>0: eticheta umana, contorul, primii N identificatori
(VIN partial + nr inmatriculare + #id — PII doar partial, ca jurnalul) si cati raman.
Scoped pe cont (regula NULL->1). Lista goala -> banner-ul nu se randeaza (se stinge).
Inlocuieste _blocate_actionabil (care incarca PII/VIN per rand).
Reutilizeaza contoarele deja calculate din _status_counts.
Returneza lista goala daca nu exista nicio stare blocata.
"""
from ..security import vin_partial
scope_sql, scope_params = account_scope_clause(account_id)
out: list[dict] = []
for status in ("needs_mapping", "needs_data", "error"):
rows = conn.execute(
f"SELECT id, payload_json FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC",
scope_params + [status],
).fetchall()
if not rows:
continue
sample = []
for r in rows[:_BLOCATE_SAMPLE]:
prez = prezentare_din_payload(r["payload_json"])
sample.append({
"id": r["id"],
"vin": vin_partial(prez.get("vin") or ""),
"nr": prez.get("vehicul_nr") or "",
})
out.append({
"status": status,
"eticheta": eticheta_stare(status),
"n": len(rows),
"randuri": sample,
"rest": max(0, len(rows) - len(sample)),
})
return out
# DESIGN.md §Componente: Lipsa cod = --warn (chihlimbar), celelalte categorii = --err (rosu).
# Culoarea e CSS variable name (nu clasa), injectata direct in style tag al pill-ului,
# pentru ca s-needs_mapping in base.html e tot --err (incorect pentru pill).
PILL_DEFS = [
("needs_mapping", "Lipsa cod", "--warn"),
("needs_data", "Date incomplete", "--err"),
("error", "Eroare", "--err"),
]
return [
{"status": status, "label": label, "color_var": color_var, "n": counts.get(status, 0)}
for status, label, color_var in PILL_DEFS
if counts.get(status, 0) > 0
]
@router.get("/_fragments/status", response_class=HTMLResponse)
@@ -630,23 +614,31 @@ def fragment_status(request: Request) -> HTMLResponse:
"counts_sent": counts.get("sent", 0),
"blocate_total": blocate_total,
"blocate_defalcat": _blocate_defalcat(counts),
"blocate_actionabil": _blocate_actionabil(conn, account_id),
"pills_categorii": _pills_categorii(counts),
"account_active": _account_active(conn, account_id),
})
finally:
conn.close()
def _is_iso_date(value: object) -> bool:
"""True daca `value` e o data ISO YYYY-MM-DD (comparabila lexicografic corect)."""
def _iso_date_prefix(value: object) -> str | None:
"""Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel None.
Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda
(ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data
fara a exclude timestamp-urile (bug-ul fix US-001: _is_iso_date cerea len==10).
Valori care nu incep cu o data ISO valida (ex. '05.12.2024') intorc None si
sunt excluse din filtru — comportament actual pastrat.
"""
s = str(value or "").strip()
if len(s) != 10:
return False
if len(s) < 10:
return None
prefix = s[:10]
try:
datetime.strptime(s, "%Y-%m-%d")
return True
datetime.strptime(prefix, "%Y-%m-%d")
return prefix
except (ValueError, TypeError):
return False
return None
# Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana
@@ -693,6 +685,9 @@ def _submission_row_view(r) -> dict:
}
_PAGE_SIZE = 25 # Marime pagina fixa (US-004 PRD 5.10)
@router.get("/_fragments/submissions", response_class=HTMLResponse)
def fragment_submissions(
request: Request,
@@ -700,12 +695,14 @@ def fragment_submissions(
vehicul: str | None = None,
data_de: str | None = None,
data_pana: str | None = None,
page: int = 1,
) -> HTMLResponse:
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale (US-009).
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare (US-009, US-004).
Filtrarea pe stare se face in SQL (foloseste idx_submissions_account_status);
filtrarea pe vehicul (nr/VIN, case-insensitive) si pe interval data_prestatie
se face dupa parsarea payload_json in Python (plafon perf notat — eng review).
US-004 H1: totalul se calculeaza DIFERIT dupa tipul de filtru:
- FARA filtru Python (status-only / niciun filtru): SQL COUNT(*) + LIMIT/OFFSET
- CU filtru vehicul/data activ: fetch-all -> filtreaza Python -> total=len -> slice
SQL COUNT/LIMIT pe calea cu filtru Python ar da total gresit (taie inainte de filtru).
"""
account_id = require_login(request)
status = (status or "").strip() or None
@@ -713,6 +710,9 @@ def fragment_submissions(
data_de = (data_de or "").strip() or None
data_pana = (data_pana or "").strip() or None
filtru_activ = bool(status or vehicul_q or data_de or data_pana)
filtru_python = bool(vehicul_q or data_de or data_pana) # filtru care necesita Python
page = max(1, page) # pre-clamp >= 1
conn = get_connection()
try:
@@ -722,45 +722,79 @@ def fragment_submissions(
if status:
where.append("status=?")
params.append(status)
# Filtrarea pe vehicul/data se face in Python (dupa parsarea payload). Daca am
# taia la LIMIT inainte de filtru, am rata silentios randuri mai vechi care
# potrivesc. Cand un filtru text/data e activ, scoatem LIMIT-ul din SQL si plafonam
# afisarea dupa filtrare (OK la scara actuala — plafon perf notat, eng review).
limit_sql = "" if (vehicul_q or data_de or data_pana) else " LIMIT 200"
rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
"updated_at, payload_json FROM submissions "
f"WHERE {' AND '.join(where)} ORDER BY id DESC{limit_sql}",
params,
).fetchall()
where_sql = " AND ".join(where)
view = []
for r in rows:
v = _submission_row_view(r)
prez = v["prez"]
if vehicul_q:
hay = f"{prez['vehicul_nr']} {prez['vin']}".upper()
if vehicul_q not in hay:
continue
if data_de or data_pana:
d = prez["data_prestatie"]
# Comparam doar date in format ISO (YYYY-MM-DD); altfel comparatia de string
# ar fi gresita (ex. "05.12.2024"). Valori ne-ISO sunt excluse din filtru.
if not _is_iso_date(d):
continue
if data_de and d < data_de:
continue
if data_pana and d > data_pana:
continue
view.append(v)
if len(view) >= 200:
break
if filtru_python:
# Calea B: fetch-all, filtreaza in Python, slice (US-004 H1)
# FARA LIMIT — altfel paginile >8 ar disparea silentios (bug PRD H1)
rows_db = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC",
params,
).fetchall()
view_all: list[dict] = []
for r in rows_db:
v = _submission_row_view(r)
prez = v["prez"]
if vehicul_q:
hay = f"{prez['vehicul_nr']} {prez['vin']}".upper()
if vehicul_q not in hay:
continue
if data_de or data_pana:
# Extragem portiunea YYYY-MM-DD (US-001 fix).
d_prefix = _iso_date_prefix(prez["data_prestatie"])
if d_prefix is None:
continue
if data_de and d_prefix < data_de:
continue
if data_pana and d_prefix > data_pana:
continue
view_all.append(v)
total = len(view_all)
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
page = max(1, min(page, pages)) # clamp H2
offset = (page - 1) * _PAGE_SIZE
view = view_all[offset:offset + _PAGE_SIZE]
else:
# Calea A: SQL COUNT(*) + LIMIT/OFFSET (eficient, fara filtru Python activ)
total = conn.execute(
f"SELECT COUNT(*) FROM submissions WHERE {where_sql}", params
).fetchone()[0]
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
page = max(1, min(page, pages)) # clamp H2
offset = (page - 1) * _PAGE_SIZE
rows_db = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC "
"LIMIT ? OFFSET ?",
params + [_PAGE_SIZE, offset],
).fetchall()
view = [_submission_row_view(r) for r in rows_db]
page_start = (page - 1) * _PAGE_SIZE + 1 if total > 0 else 0
page_end = min(page * _PAGE_SIZE, total)
return templates.TemplateResponse("_submissions.html", {
"request": request,
"rows": view,
"filtru_activ": filtru_activ,
"csrf_token": get_csrf_token(request),
# Paginare (US-004)
"total": total,
"page": page,
"pages": pages,
"page_start": page_start,
"page_end": page_end,
# Filtre curente pentru linkurile de paginare (pastreaza filtrele, H2)
"f_status": status or "",
"f_vehicul": vehicul_q or "",
"f_data_de": data_de or "",
"f_data_pana": data_pana or "",
})
finally:
conn.close()
@@ -768,6 +802,9 @@ def fragment_submissions(
# Stari ne-trimise blocate pe care le putem corecta inline (US-010).
_CORECTABILE = ("needs_data", "needs_mapping")
# US-006b: stari cu select editabil cod_prestatie (superset al _CORECTABILE: error
# primeste select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields).
_EDITABILE_OP = ("needs_data", "needs_mapping", "error")
# Stari gestionabile prin lifecycle web (US-011): sterge / re-pune in coada.
_GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping")
@@ -851,12 +888,31 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
eticheta = eticheta_stare(row["status"])
nemapate_inline: list[dict] = []
nomenclator: list[dict] = []
# Variabila interna: nomenclatorul complet (incarcat pentru needs_mapping, refolosit pt US-006)
_nomenclator_complet: list[dict] = []
if conn is not None and row["status"] == "needs_mapping":
# Un singur SELECT pe nomenclator: il refolosim si pentru sugestii si pentru dropdown.
nomenclator = load_nomenclator(conn)
nemapate_inline = _nemapate_pentru_submission(row, nomenclator)
if not nemapate_inline:
nomenclator = [] # nu expunem dropdown-ul cand nu exista operatii de mapat
_nomenclator_complet = load_nomenclator(conn)
nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet)
nomenclator = _nomenclator_complet if nemapate_inline else []
# US-006/US-006b: nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in
# formularul /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet
# daca e deja incarcat (needs_mapping), altfel incarca fresh.
nomenclator_rar: list[dict] = []
if conn is not None and row["status"] in _EDITABILE_OP:
nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn)
# US-006: cod_prestatie curent din prima prestatie (pentru pre-selectare in select)
cod_prestatie_curent = ""
try:
_pd = json.loads(row["payload_json"] or "{}")
_prestatii = (_pd.get("prestatii") or []) if isinstance(_pd, dict) else []
if _prestatii and isinstance(_prestatii[0], dict):
cod_prestatie_curent = (_prestatii[0].get("cod_prestatie") or "").strip().upper()
except (ValueError, TypeError):
pass
ctx = {
"request": request,
"csrf_token": get_csrf_token(request),
@@ -882,6 +938,9 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
# PRD 5.7: mapare inline (operatii nemapate ale acestui rand + nomenclator)
"nemapate_inline": nemapate_inline,
"nomenclator": nomenclator,
# US-006: select cod_prestatie pentru stari editabile
"nomenclator_rar": nomenclator_rar,
"cod_prestatie_curent": cod_prestatie_curent,
"corectie_msg": message,
"corectie_error": error,
"corectie_errors": corectie_errors or [],
@@ -1011,6 +1070,31 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
if isinstance(val, str) and val.strip() != "":
content[camp] = val.strip()
# US-006: injectare cod_prestatie din form INAINTE de resolve_prestatii.
# Oglindeste validarea din post_mapeaza_inline (nomenclator check). Codul nou
# e injectat in prima prestatie (index 0); build_key il include in hash (CLAUDE.md
# invariant "build_key hashuieste cod_prestatie, idempotency.py:34").
_cod_raw = form.get("cod_prestatie")
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
if cod_prestatie_form:
exists_nom = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie_form,)
).fetchone()
if not exists_nom:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id, error=True,
message=f"Cod RAR necunoscut in nomenclator: {cod_prestatie_form}. "
"Alege un cod valid din lista.",
),
)
prestatii_form = content.get("prestatii")
if isinstance(prestatii_form, list) and prestatii_form:
p0 = dict(prestatii_form[0])
p0["cod_prestatie"] = cod_prestatie_form
content["prestatii"] = [p0] + list(prestatii_form[1:])
# Re-rezolva prestatiile cu maparea curenta (ca reresolve_account): NU re-pune
# niciodata in coada un cod nemapat (codPrestatie null) — FINALIZATA e ireversibil
# la RAR. Corectia campurilor de continut nu poate deebloca o operatie nemapata.
@@ -1133,14 +1217,114 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse:
"""Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard.
Scoped pe sesiune (404 cross-account/inexistent, 409 sent/sending). Re-randeaza
panoul de detaliu cu starea noua + nudge `trimiteriChanged` pentru lista.
US-006b: daca randul e in starea `error` si formularul contine `cod_prestatie`,
actualizeaza codul in payload, recalculeaza cheia de idempotency si re-pun in coada
direct (fara `requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune
(404 cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu
starea noua + nudge `trimiteriChanged` pentru lista.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
conn = get_connection()
try:
# US-006b: prelucrare cod_prestatie pentru starea error (inaintea requeue_submission
# standard, care nu actualizeaza cheia de idempotency).
_cod_raw = form.get("cod_prestatie")
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
if cod_prestatie_form:
row = _fetch_submission_scoped(conn, account_id, submission_id)
if not row:
raise HTTPException(status_code=404, detail="trimitere inexistenta")
if row["status"] != "error":
# cod_prestatie acceptat DOAR pentru starea error prin /repune
raise HTTPException(
status_code=409,
detail="modificarea cod_prestatie prin repune e valida doar pentru starea error",
)
# Valideaza cod-ul fata de nomenclator
exists_nom = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie_form,)
).fetchone()
if not exists_nom:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id,
error=True,
message=f"Cod RAR necunoscut: {cod_prestatie_form}. Alege un cod valid.",
),
)
# Parseaza payload si injecteaza cod_prestatie
try:
content = json.loads(row["payload_json"]) if row["payload_json"] else {}
if not isinstance(content, dict):
content = {}
except (ValueError, TypeError):
content = {}
prestatii = content.get("prestatii") or []
if isinstance(prestatii, list) and prestatii:
p0 = dict(prestatii[0])
p0["cod_prestatie"] = cod_prestatie_form
# sterge cod_op_service/denumire daca exista (codul direct preia prioritate)
p0.pop("cod_op_service", None)
content["prestatii"] = [p0] + list(prestatii[1:])
# Re-rezolva prestatii cu noul cod
mapping_meta = load_mapping_meta(conn, account_id)
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, account_id)
resolved, _unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
content["prestatii"] = resolved
# Canonicalize + rebuild idempotency key
canon = canonicalize_row(content)
payload_json = json.dumps(content, ensure_ascii=False)
new_key = build_key(account_id, canon)
# Verifica coliziune (numai daca cheia s-a schimbat)
if new_key != row["idempotency_key"]:
dup = conn.execute(
"SELECT id FROM submissions WHERE idempotency_key=? AND id<>?",
(new_key, row["id"]),
).fetchone()
if dup:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id,
error=True,
message=f"Exista deja o trimitere identica (rand #{dup['id']}).",
),
)
try:
conn.execute(
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
"updated_at=datetime('now') WHERE id=? AND account_id=?",
(new_key, payload_json, row["id"], account_id),
)
conn.commit()
except sqlite3.IntegrityError:
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row, conn=conn, account_id=account_id,
error=True,
message="Exista deja o trimitere identica. Operatia a fost oprita.",
),
)
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
resp = templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(
request, row2, conn=conn, account_id=account_id,
message="Cod actualizat — randul a fost re-pus in coada.",
),
)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
# Cale normala: fara cod_prestatie → delega la requeue_submission
try:
requeue_submission(conn, account_id, submission_id)
except SubmissionNotFound:

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -28,21 +28,13 @@
hx-swap="innerHTML"
hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']"
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
<div>
<label for="f-status" class="muted" style="display:block; font-size:12px;">Stare</label>
{# US-014/T13: status_filtru (din deep-link ?tab=acasa&status=) pre-selecteaza
starea, iar submissions-wrap (hx-include #filtre-trimiteri) o incarca filtrat. #}
{% set sf = status_filtru | default('') %}
<select id="f-status" name="status">
<option value="" {% if not sf %}selected{% endif %}>toate</option>
<option value="queued" {% if sf == 'queued' %}selected{% endif %}>in asteptare</option>
<option value="sent" {% if sf == 'sent' %}selected{% endif %}>declarate la RAR</option>
<option value="needs_mapping" {% if sf == 'needs_mapping' %}selected{% endif %}>lipsa cod</option>
<option value="needs_data" {% if sf == 'needs_data' %}selected{% endif %}>date incomplete</option>
<option value="error" {% if sf == 'error' %}selected{% endif %}>eroare</option>
<option value="sending" {% if sf == 'sending' %}selected{% endif %}>se trimite</option>
</select>
</div>
{# US-003 (PRD 5.10): dropdown status eliminat — inlocuit cu pill-uri in bara de status.
Filtrul de stare vine de la pill-uri (/_fragments/submissions?status=X direct).
Camp hidden permite reset stare la submit manual din form (Filtreaza). #}
<input type="hidden" name="status" value="{{ status_filtru | default('') }}">
{# US-004 (PRD 5.10): pagina curenta — actualizata prin OOB swap din _submissions.html.
Poll-ul (hx-include="#filtre-trimiteri") include automat pagina curenta (L2 PRD). #}
<input type="hidden" id="f-page" name="page" value="1">
<div>
<label for="f-vehicul" class="muted" style="display:block; font-size:12px;">Vehicul (nr/VIN)</label>
<input id="f-vehicul" type="text" name="vehicul" placeholder="ex. B123 sau VIN" style="max-width:180px;">

View File

@@ -21,26 +21,11 @@
{# US-005 (5.5): antet standard + link Ajutor ca <details> nativ (fara JS). Toata proza
care inainte se repeta inline (scopul maparilor, Auto/Manual) traieste acum AICI,
o singura data, ascunsa implicit. #}
{# US-010: sectiunea de ajutor (details.ajutor-mapari) eliminata.
Empty-state „Nicio operatie nemapata" eliminat — sectiunea ramane goala (fara text). #}
<h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2>
<details class="ajutor-mapari" style="margin:0 0 12px;">
<summary class="cardlink" style="display:inline-flex; color:var(--accent); cursor:pointer; padding:4px 0;">Ajutor</summary>
<div class="muted" style="font-size:13px; margin-top:8px; max-width:680px;">
Maparile leaga o operatie din softul tau (cod intern ROAAUTO) de un cod RAR oficial.
Operatiile necunoscute raman blocate in <span class="s-needs_mapping">needs_mapping</span>
si NU pleaca la RAR pana le mapezi. Sugestiile (%) vin din potrivire fuzzy pe denumire —
verifica-le inainte sa salvezi. <strong>In coada</strong>: <strong>Auto</strong> = la
urmatoarele fisiere cu aceasta operatie randurile intra automat in coada;
<strong>Manual</strong> = raman pentru verificare, nimic nu pleaca la RAR pana confirmi.
La schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
</div>
</details>
{% if not pending %}
<div class="empty">
Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR.
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
</div>
{% else %}
{% if pending %}
<div data-dt="10">
<div class="dt-tools">
<input type="search" data-dt-search class="dt-search"
@@ -165,15 +150,24 @@
{{ ui.autosend_toggle(form_id="map-salv-" ~ loop.index, checked=m.auto_send) }}
</td>
<td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni">
{# Salveaza/Sterge in meniu contextual (kebab) — randul ramane ingust. Butoanele se
leaga prin form= de cele doua form-uri hx-post definite in prima celula a randului. #}
<details class="kebab">
<summary aria-label="Actiuni pentru {{ m.cod_op_service }}">&#8943;</summary>
<div class="kebab-menu">
<button type="submit" form="map-salv-{{ loop.index }}">Salveaza</button>
<button type="submit" form="map-del-{{ loop.index }}" class="danger">Sterge</button>
</div>
</details>
{# US-011: butoane icon mereu vizibile (fara kebab). SVG aria-hidden; aria-label pe buton.
data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand,
JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
<button type="submit" form="map-salv-{{ loop.index }}"
class="icon-btn"
data-dirty-form="map-salv-{{ loop.index }}"
aria-label="Salveaza maparea pentru {{ m.cod_op_service }}">
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M2 2a1 1 0 011-1h7.5L13 3.5V14a1 1 0 01-1 1H3a1 1 0 01-1-1V2zm5 10a2 2 0 100-4 2 2 0 000 4zM3 3v3h6V3H3z"/>
</svg>
</button>
<button type="submit" form="map-del-{{ loop.index }}"
class="icon-btn danger"
aria-label="Sterge maparea pentru {{ m.cod_op_service }}">
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6zM14 3a1 1 0 01-1 1H3a1 1 0 110-2h3.5l1-1h2l1 1H13a1 1 0 011 1zm-1 1H3v9a1 1 0 001 1h8a1 1 0 001-1V4z"/>
</svg>
</button>
</td>
</tr>
{% endfor %}
@@ -187,79 +181,8 @@
</div>
<!-- ============================================================ -->
<!-- Sectiunea 3: Formate de coloane salvate (column_mappings) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
{% if not column_formats %}
<div class="empty">
Niciun format de coloane salvat inca. La primul import, maparea coloanelor fisierului
se retine aici si se reaplica automat la fisierele cu acelasi antet.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat.
</p>
<div data-dt="10">
<div class="dt-tools">
<input type="search" data-dt-search class="dt-search"
placeholder="Cauta coloana sau camp..." aria-label="Cauta in formatele de coloane">
</div>
<div class="tablewrap tabel-card">
<table>
<thead><tr>
<th>Coloane</th>
<th>Mapari (coloana &rarr; camp)</th>
<th>Format data</th>
<th></th>
</tr></thead>
<tbody>
{% for f in column_formats %}
<tr>
<td style="white-space:nowrap;" data-eticheta="Coloane">
<strong>{{ f.columns | length }} coloane</strong>
</td>
<td class="muted" style="font-size:12px; white-space:normal; max-width:340px;" data-eticheta="Mapari (coloana &rarr; camp)">
{% for col, camp in f.mappings.items() %}
<span class="sugg">{{ col }}</span> &rarr; {{ camp }}{% if not loop.last %}; {% endif %}
{% endfor %}
</td>
<td data-eticheta="Format data">
<form id="fmt-edit-{{ loop.index }}" hx-post="/formate-coloane/editeaza"
hx-target="#mapari-section" hx-swap="outerHTML"
style="display:flex; gap:6px; align-items:center;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
<input type="text" name="format_data" value="{{ f.format_data or '' }}"
placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;">
<button type="submit">Salveaza data</button>
</form>
</td>
<td>
<form hx-post="/formate-coloane/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
hx-confirm="Stergi acest format de coloane?">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="dt-empty" data-dt-empty style="display:none;">Niciun format nu se potriveste cautarii.</div>
<div class="dt-pager" data-dt-pager></div>
</div>
{% endif %}
</div>
<!-- ============================================================ -->
<!-- Sectiunea 4: Reguli automate pe text (operation_text_rules) -->
<!-- Sectiunea 3: Reguli automate pe text (operation_text_rules) -->
<!-- US-010: mutata pe pozitia 3 (inainte de Formate de coloane) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 8px;">Reguli automate (text)</h2>
@@ -354,4 +277,77 @@
</div>
</div>
<!-- ============================================================ -->
<!-- Sectiunea 4: Formate de coloane salvate (column_mappings) -->
<!-- US-010: mutata pe pozitia 4 (dupa Reguli automate) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
{% if not column_formats %}
<div class="empty">
Niciun format de coloane salvat inca. La primul import, maparea coloanelor fisierului
se retine aici si se reaplica automat la fisierele cu acelasi antet.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat.
</p>
<div data-dt="10">
<div class="dt-tools">
<input type="search" data-dt-search class="dt-search"
placeholder="Cauta coloana sau camp..." aria-label="Cauta in formatele de coloane">
</div>
<div class="tablewrap tabel-card">
<table>
<thead><tr>
<th>Coloane</th>
<th>Mapari (coloana &rarr; camp)</th>
<th>Format data</th>
<th></th>
</tr></thead>
<tbody>
{% for f in column_formats %}
<tr>
<td style="white-space:nowrap;" data-eticheta="Coloane">
<strong>{{ f.columns | length }} coloane</strong>
</td>
<td class="muted" style="font-size:12px; white-space:normal; max-width:340px;" data-eticheta="Mapari (coloana &rarr; camp)">
{% for col, camp in f.mappings.items() %}
<span class="sugg">{{ col }}</span> &rarr; {{ camp }}{% if not loop.last %}; {% endif %}
{% endfor %}
</td>
<td data-eticheta="Format data">
<form id="fmt-edit-{{ loop.index }}" hx-post="/formate-coloane/editeaza"
hx-target="#mapari-section" hx-swap="outerHTML"
style="display:flex; gap:6px; align-items:center;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
<input type="text" name="format_data" value="{{ f.format_data or '' }}"
placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;">
<button type="submit">Salveaza data</button>
</form>
</td>
<td>
<form hx-post="/formate-coloane/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
hx-confirm="Stergi acest format de coloane?">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="dt-empty" data-dt-empty style="display:none;">Niciun format nu se potriveste cautarii.</div>
<div class="dt-pager" data-dt-pager></div>
</div>
{% endif %}
</div>
</div>

View File

@@ -1,3 +1,15 @@
<style>
/* Pill-uri categorii blocate (US-003 PRD 5.10)
Culoarea e injectata inline (color_var: --warn / --err) dupa DESIGN.md:
Lipsa cod = --warn (chihlimbar), Date incomplete + Eroare = --err (rosu).
Activ = fundal pe culoarea categoriei (NU accent albastru — S1/A5). */
.pill-cat { transition: background 0.15s, color 0.15s; }
.pill-cat:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Activ: background = culoarea categoriei (currentColor = var(--err/--warn) din inline style),
text = var(--card) (contrast AA). NU accent albastru (S1/A5 DESIGN.md). */
.pill-cat[aria-pressed="true"] { background: currentColor !important; color: var(--card) !important; border-color: currentColor !important; }
.pill-cat[aria-pressed="true"] span { background: var(--card) !important; color: currentColor; }
</style>
<div id="status-bar" class="status-bar card"
hx-get="/_fragments/status"
hx-trigger="every 15s"
@@ -47,36 +59,49 @@
</span>
</div>
<!-- Necesita atentia ta (US-014): categorii actionabile — link la lista filtrata
+ identificatorii primelor randuri blocate. Se randeaza DOAR daca exista randuri
blocate; cand contorul ajunge 0 (sters/re-pus/purjat), sectiunea dispare. -->
{% if blocate_actionabil %}
<!-- Pill-uri categorii blocate (US-003 PRD 5.10): inlocuiesc lista de ID-uri.
<button> reale cu aria-pressed, focalizabile, activare Enter/Space.
Inactiv = contur+text pe culoarea categoriei; activ = umplere pe culoarea categoriei.
Pill ascuns cand n=0 (lista pills_categorii filtreaza deja). -->
{% if pills_categorii %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
<div style="font-size:13px; font-weight:600; margin-bottom:8px;">Necesita atentia ta</div>
<div style="display:flex; gap:18px; flex-wrap:wrap;">
{% for cat in blocate_actionabil %}
<div style="min-width:200px;">
{# Link: filtreaza lista Trimiteri pe aceasta stare (HTMX in-place) cu fallback
deep-link server-side (?tab=acasa&status=...). #}
<a class="{{ cat.eticheta[2] }}" style="font-size:13px; font-weight:600; text-decoration:none;"
href="/?tab=acasa&status={{ cat.status }}"
hx-get="/_fragments/submissions?status={{ cat.status }}"
hx-target="#submissions-wrap" hx-swap="innerHTML"
onclick="var s=document.getElementById('trimiteri-section'); if(s) s.scrollIntoView({behavior:'smooth'});">
{{ cat.eticheta[0] }} ({{ cat.n }}) &rsaquo;
</a>
<ul style="list-style:none; margin:6px 0 0; padding:0;">
{% for r in cat.randuri %}
<li class="muted" style="font-size:12px;">
#{{ r.id }} {{ r.vin }}{% if r.nr %} / {{ r.nr }}{% endif %}
</li>
{% endfor %}
{% if cat.rest %}
<li class="muted" style="font-size:12px;">…si inca {{ cat.rest }}</li>
{% endif %}
</ul>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<span style="font-size:12px; color:var(--muted);">Necesita atentie:</span>
{% for pill in pills_categorii %}
<button type="button"
class="pill-cat"
aria-pressed="false"
hx-get="/_fragments/submissions?status={{ pill.status }}"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
onclick="(function(b){
var pressed=b.getAttribute('aria-pressed')==='true';
document.querySelectorAll('.pill-cat').forEach(function(x){x.setAttribute('aria-pressed','false');});
if(!pressed){b.setAttribute('aria-pressed','true');}
var s=document.getElementById('trimiteri-section');
if(s){s.scrollIntoView({behavior:'smooth'});}
})(this)"
style="display:inline-flex; align-items:center; gap:5px;
padding:3px 10px; border-radius:99px; font-size:12px; font-weight:600;
cursor:pointer; border:1.5px solid var({{ pill.color_var }}); color:var({{ pill.color_var }});
background:transparent; transition:background 0.15s, color 0.15s;">
{{ pill.label }}
<span style="font-size:11px; font-weight:700; background:var({{ pill.color_var }}); color:var(--card);
padding:0 5px; border-radius:99px; min-width:18px; text-align:center;">{{ pill.n }}</span>
</button>
{% endfor %}
{# Buton "Toate" — reseteaza filtrul de categorie #}
<button type="button"
class="pill-cat-reset"
aria-label="Arata toate trimiterile"
hx-get="/_fragments/submissions"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
onclick="document.querySelectorAll('.pill-cat').forEach(function(x){x.setAttribute('aria-pressed','false');})"
style="padding:3px 10px; border-radius:99px; font-size:12px; cursor:pointer;
border:1px solid var(--line); background:transparent; color:var(--muted);">
Toate
</button>
</div>
</div>
{% endif %}

View File

@@ -1,3 +1,10 @@
{#
OOB: actualizeaza inputul id="f-page" din #filtre-trimiteri (US-004 L2).
Poll-ul de 15s (hx-include="#filtre-trimiteri") preia automat pagina curenta.
Elementul OOB e extras din continutul normal de HTMX inainte de swap in #submissions-wrap.
#}
<input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
{% if rows %}
{# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
(gestionabil); sent/sending/queued nu au checkbox (read-only). #}
@@ -61,7 +68,8 @@
<td class="col-vehicul" data-eticheta="Vehicul">
{{ r.prez.vehicul_nr }}
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
<span class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</span>
{# US-005: VIN pe rand separat sub nr (element block, nu span inline) #}
<div class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</div>
{% endif %}
</td>
<td class="col-operatie" data-eticheta="Operatie">
@@ -83,6 +91,105 @@
</table>
</div>
</form>
{#
Paginare numerotata (US-004 PRD 5.10).
Afisata doar cand exista mai mult de o pagina.
Fiecare link pastreaza filtrele curente (status, vehicul, data_de, data_pana).
Pagina curenta: aria-current="page" (semantic).
#}
{% if total is defined %}
<div aria-live="polite"
style="font-size:12px; color:var(--muted); text-align:right; margin-top:6px; margin-bottom:2px;">
{% if total == 0 %}
0 trimiteri
{% else %}
{{ page_start }}{{ page_end }} din {{ total }}
{% endif %}
</div>
{% endif %}
{% if pages is defined and pages > 1 %}
{#
Construim param-string pentru filtrele curente (fara page) — refolosit in fiecare link.
Filtrul status vine din pill-uri (nu din form); il pastram in URL.
#}
{% set pq = "" %}
{% if f_status %}{% set pq = pq + "&status=" + f_status %}{% endif %}
{% if f_vehicul %}{% set pq = pq + "&vehicul=" + f_vehicul %}{% endif %}
{% if f_data_de %}{% set pq = pq + "&data_de=" + f_data_de %}{% endif %}
{% if f_data_pana %}{% set pq = pq + "&data_pana=" + f_data_pana %}{% endif %}
<nav aria-label="Paginare trimiteri"
style="display:flex; justify-content:center; gap:4px; flex-wrap:wrap; margin-top:10px;">
{# Buton Anterior #}
{% if page > 1 %}
<button type="button"
hx-get="/_fragments/submissions?page={{ page - 1 }}{{ pq }}"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
border:1px solid var(--line); background:var(--card); color:var(--fg);"
aria-label="Pagina anterioara">
&laquo;
</button>
{% else %}
<button type="button" disabled
style="padding:3px 10px; border-radius:6px; font-size:13px;
border:1px solid var(--line); background:var(--card); color:var(--muted);
opacity:0.4; cursor:default;"
aria-label="Pagina anterioara (indisponibila)">
&laquo;
</button>
{% endif %}
{# Numerele de pagina #}
{% for p in range(1, pages + 1) %}
{% if p == page %}
<button type="button"
aria-current="page"
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:default;
border:1px solid var(--accent); background:var(--accent); color:#fff;
font-weight:700;">
{{ p }}
</button>
{% else %}
<button type="button"
hx-get="/_fragments/submissions?page={{ p }}{{ pq }}"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
border:1px solid var(--line); background:var(--card); color:var(--fg);">
{{ p }}
</button>
{% endif %}
{% endfor %}
{# Buton Urmator #}
{% if page < pages %}
<button type="button"
hx-get="/_fragments/submissions?page={{ page + 1 }}{{ pq }}"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
border:1px solid var(--line); background:var(--card); color:var(--fg);"
aria-label="Pagina urmatoare">
&raquo;
</button>
{% else %}
<button type="button" disabled
style="padding:3px 10px; border-radius:6px; font-size:13px;
border:1px solid var(--line); background:var(--card); color:var(--muted);
opacity:0.4; cursor:default;"
aria-label="Pagina urmatoare (indisponibila)">
&raquo;
</button>
{% endif %}
</nav>
{% endif %}
{% elif filtru_activ %}
<div class="empty">
Nimic pe filtrul curent.

View File

@@ -19,8 +19,10 @@
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p>
{% endif %}
{# === R10 (2): bloc eroare blocanta cand exista === #}
{% if erori_3n %}
{# === R10 (2): bloc eroare blocanta — DOAR in read-only (US-008).
In editare, cardul 3-niveluri e inlocuit cu: erori per-camp in macro `camp`
(text simplu .s-error) + rezumat top-of-form pentru erori fara camp (mai jos). === #}
{% if not editabil and erori_3n %}
<div style="margin:0 0 14px;">
{{ card_erori(erori_3n) }}
</div>
@@ -88,6 +90,13 @@
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
{% endif %}
{# US-008 (M6): erori fara camp (field None) nu dispar silentios in editare —
cardul 3n e ascuns, deci adaugam un rezumat simplu top-of-form.
Erori cu camp raman afisate per-camp de macro-ul `camp` de mai jos. #}
{% for e in erori_3n if not e.field %}
<div class="s-error" style="font-size:13px; margin:0 0 10px;" role="alert">{{ e.problema }}</div>
{% endfor %}
{% macro camp(nume, eticheta, valoare, tip='text') %}
<div style="margin-bottom:10px;">
<label for="c-{{ nume }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
@@ -105,11 +114,40 @@
hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{# US-006: select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator.
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). #}
{% if nomenclator_rar %}
<div style="margin:0 0 12px;">
<label for="c-cod-prestatie" class="muted" style="font-size:12px; display:block;">Operatie RAR (cod prestatie)</label>
{% if prez.operatie and prez.operatie != '—' %}
<div class="muted" style="font-size:12px; margin-bottom:4px;">{{ prez.operatie }}</div>
{% endif %}
<select id="c-cod-prestatie" name="cod_prestatie" style="max-width:380px; width:100%;"
aria-label="Alege operatia RAR din nomenclator">
<option value="">— pastrat ({{ cod_afis }}) —</option>
{% for n in nomenclator_rar %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
{% else %}
{# Operatie + cod RAR read-only deasupra campurilor (R9, fara eticheta „Cod RAR"). #}
<div style="margin:0 0 12px;">
<div class="muted" style="font-size:12px;">Operatie</div>
<div>{{ prez.operatie }} &middot; {{ cod_afis }}</div>
</div>
{% endif %}
{# US-007: operatie service (cod intern + denumire venita prin API/import), distinct de
operatia RAR mapata. Conventie US-002: op_service_cod="" cand lipseste → randul absent. #}
{% if prez.op_service_cod %}
<div style="margin:0 0 12px;">
<div class="muted" style="font-size:12px;">Operatie service</div>
<div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div>
</div>
{% endif %}
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }}
@@ -139,6 +177,12 @@
<div style="word-break:break-all;">{{ prez.vin }}</div>
</div>
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }} &middot; {{ cod_afis }}</div></div>
{# US-007: operatie service (cod intern + denumire), distinct de operatia RAR.
Conventie US-002: op_service_cod="" cand lipseste → randul absent (fara "—"). #}
{% if prez.op_service_cod %}
<div><div class="muted" style="font-size:12px;">Operatie service</div>
<div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div></div>
{% endif %}
<div><div class="muted" style="font-size:12px;">Data prestatie</div><div>{{ prez.data_prestatie }}</div></div>
<div><div class="muted" style="font-size:12px;">Odometru final</div><div>{{ prez.odometru }}</div></div>
</div>
@@ -147,12 +191,30 @@
{# === R10 (5): actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT (R2/R11) === #}
{% if status == 'error' or gestionabil %}
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
{# R2: error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil). #}
{# R2: error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil pentru #}
{# campuri vehicul, dar US-006b permite schimbarea cod_prestatie prin acelasi formular). #}
{% if status == 'error' %}
<form hx-post="/trimitere/{{ id }}/repune"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
hx-disabled-elt="find button" style="margin:0 0 10px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{# US-006b: select cod_prestatie optional in formularul /repune (doar pentru error). #}
{% if nomenclator_rar %}
<label for="cod-rar-error-{{ id }}" style="display:block; font-size:12px; color:var(--muted); margin-bottom:4px;">
Operatie RAR (optional — schimba codul si re-pune)
</label>
<select id="cod-rar-error-{{ id }}" name="cod_prestatie"
aria-label="Alege operatia RAR din nomenclator"
style="width:100%; margin-bottom:8px; font-size:13px;">
<option value="">— pastrat ({{ cod_prestatie_curent }}) —</option>
{% for item in nomenclator_rar %}
<option value="{{ item.cod_prestatie }}"
{% if item.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
{{ item.cod_prestatie }} — {{ item.nome_prestatie }}
</option>
{% endfor %}
</select>
{% endif %}
<button type="submit">Re-pune in coada</button>
</form>
{% endif %}

View File

@@ -14,12 +14,16 @@
htmx.config.useTemplateFragments = true;
</script>
<script>
// Anti-FOUC (US-001 PRD 5.3): citeste preferinta tema din localStorage inainte de
// primul paint; seteaza data-theme pe <html> sincron, fara blink dark->light.
// Anti-FOUC (US-001 PRD 5.3, extins US-014 PRD 5.10): citeste preferinta tema din
// localStorage inainte de primul paint; seteaza data-theme pe <html> sincron, fara blink.
// Cunoaste toate cele 4 teme: light/dark/petrol/auto. Valoare legacy/necunoscuta -> auto.
// 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink).
(function() {
var VALID = {light:1, dark:1, petrol:1, auto:1};
try {
var t = localStorage.getItem('theme');
if (!t) {
if (!t || !VALID[t]) t = 'auto'; // fallback: valoare lipsa sau legacy -> auto
if (t === 'auto') {
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
document.documentElement.setAttribute('data-theme', t);
@@ -29,19 +33,103 @@
})();
</script>
<style>
:root { --bg:#0f1115; --card:#181b22; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
--ok:#3ecf8e; --warn:#e6b34a; --err:#e5605e; --accent:#5b8def; }
[data-theme="light"] { --bg:#f6f7f9; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#2563eb; }
/* US-013 (PRD 5.10): IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti).
font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex).
FOUT pe tabular-nums: IBM Plex Sans are metrici apropiate de system-ui; reflow-ul vizibil
pe VIN/coduri e acceptat explicit — fontul se incarca din /static/ (acelasi origin).
IBM Plex Sans/Mono self-host, subset latin + latin-ext de pe fontsource
(@fontsource/ibm-plex-sans + @fontsource/ibm-plex-mono, v5.0.8), woff2 valide. */
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Regular-latin-ext.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Regular-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Medium-latin-ext.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Medium-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Bold-latin-ext.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/static/fonts/IBMPlexSans-Bold-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/IBMPlexMono-Regular-latin-ext.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
}
/* Paleta dark (default) — accent azur ROMFAST conform DESIGN.md */
:root { --bg:#0f1218; --card:#181c24; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; }
/* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */
[data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; }
/* Paleta Petrol (US-014) — tema intunecata alternativa, accent teal #0E7C7B.
Wordmark-ul FAST #2E74D6 coexista armonios: ambele sunt reci/saturate, contrast AA pe --card #161e20. */
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e;
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; }
* { box-sizing:border-box; }
/* PRD 5.9 US-006 — CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
CSS custom properties NU functioneaza in `@media`, deci pragul nu poate fi o
variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si
`@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout
desktop neschimbat (fara regresie). Orice regula mobila noua reutilizeaza 767px. */
body { margin:0; font:15px/1.5 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
body { margin:0; font:15px/1.5 "IBM Plex Sans",system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; }
header { padding:16px 24px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
/* US-012 (PRD 5.10): grila 3 coloane — stanga (env badge echilibru) | centru (titlu+wordmark) | dreapta (controale). */
header { padding:16px 24px; border-bottom:1px solid var(--line);
display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; }
.header-left { display:flex; align-items:center; }
.header-center { display:flex; flex-direction:column; align-items:center; text-align:center; }
.header-right { display:flex; align-items:center; justify-content:flex-end; gap:8px; }
/* US-012b: logo PNG ROMFAST sub titlu — 28px inaltime, centrat, fara filtre de culoare.
Logo are fundal transparent + culori proprii (ROM rosu + FAST albastru) -> ok pe toate temele. */
.brand-logo { height:28px; width:auto; display:block; margin:3px auto 0; }
header h1 { font-size:20px; margin:0; font-weight:700; letter-spacing:-.01em; }
header .env { font-size:12px; color:var(--muted); border:1px solid var(--line); padding:2px 8px; border-radius:99px; }
main { padding:24px; max-width:1100px; margin:0 auto; }
@@ -115,7 +203,7 @@
border-radius:0 6px 6px 0; }
.eroare-3n-sep { margin-top:6px; }
.eroare-3n-problema { font-weight:600; color:var(--err); font-size:13px; }
.eroare-3n-camp { font-family:monospace; font-size:12px; opacity:.85; }
.eroare-3n-camp { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:12px; opacity:.85; }
.eroare-3n-cauza { color:var(--muted); font-size:12px; margin-top:3px; }
.eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; }
.eroare-3n-label { font-weight:500; }
@@ -127,6 +215,11 @@
border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px;
line-height:1; display:inline-flex; align-items:center; justify-content:center; }
.icon-btn:hover { background:var(--line); }
/* US-011: variante icon-btn — dirty (modificari nesalvate) + danger (destructiv) */
.icon-btn.dirty { background:var(--accent); color:#fff; border-color:var(--accent); }
.icon-btn.dirty:hover { filter:brightness(0.9); }
.icon-btn.danger { color:var(--err); border-color:var(--err); }
.icon-btn.danger:hover, .icon-btn.danger:focus-visible { background:var(--err); color:#fff; }
.cont-menu { position:absolute; right:0; top:calc(100% + 8px); min-width:180px; z-index:50;
background:var(--card); border:1px solid var(--line); border-radius:8px; padding:6px;
box-shadow:0 8px 24px rgba(0,0,0,.18); display:flex; flex-direction:column; gap:2px; }
@@ -182,7 +275,7 @@
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
/* PRD 5.9 US-002: codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:"IBM Plex Mono",ui-monospace,monospace;
font-size:12px; padding:1px 7px; border:1px solid var(--line);
border-radius:99px; color:var(--muted); }
/* PRD 5.9 US-002 (R1): eticheta umana scurta sub pill — text mic; clasa `s-error`
@@ -248,9 +341,13 @@
/* US-004 (R11): actiunile de jos din detaliu (Re-pune / Sterge) full-width stivuit pe mobil. */
.detaliu-actiuni-jos button { width:100%; }
/* Header + nav colapsate: header se rupe pe linii, fara scroll orizontal de pagina;
tintele touch (toggle tema/cont, taburi, itemi meniu cont) cresc la >=44px. */
header { padding:12px 16px; flex-wrap:wrap; gap:8px; }
/* Header + nav colapsate: pe mobil trece de la grid la flex wrap.
Randul 1: [env badge stanga] [controale dreapta] (margin-left:auto pe .header-right).
Randul 2: [titlu + wordmark centrat, full-width]. Fara scroll orizontal, tinte >=44px. */
header { display:flex; flex-wrap:wrap; padding:12px 16px; gap:8px; align-items:center; }
.header-left { order:0; flex:0 0 auto; }
.header-center { order:2; width:100%; text-align:center; }
.header-right { order:1; margin-left:auto; flex:0 0 auto; }
header h1 { font-size:17px; }
main { padding:16px; }
.icon-btn { min-height:44px; min-width:44px; }
@@ -315,10 +412,21 @@
</style>
</head>
<body>
{# US-012 (PRD 5.10): grila 3 coloane — stanga (env badge) | centru (titlu+wordmark) | dreapta (controale). #}
<header>
<h1>Gateway RAR AUTOPASS</h1>
<span class="env">{{ rar_env }}</span>
<div style="margin-left:auto; display:flex; align-items:center; gap:8px;">
{# Celula stanga: badge env (test/prod) — echilibru optic fata de controalele din dreapta #}
<div class="header-left">
<span class="env">{{ rar_env }}</span>
</div>
{# Celula centru: titlu + wordmark 'by ROMFAST' #}
<div class="header-center">
<h1>Gateway RAR AUTOPASS</h1>
{# US-012b (decizie user): logo PNG real in loc de wordmark text.
288x175 RGBA fundal transparent — lizibil pe light/dark/petrol fara filtre. #}
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
</div>
{# Celula dreapta: comutator tema + versiune + meniu cont #}
<div class="header-right">
<button id="tema-toggle" class="icon-btn"
aria-label="Comuta tema (luminos/intunecat)"
title="Comuta tema">&#9728;</button>
@@ -331,6 +439,10 @@
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
aria-label="Meniu cont" title="Meniu cont">&#9776;</button>
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
{# US-009 (PRD 5.10): Mapari mutat din tab-bar in meniu, cu badge needs_mapping. #}
{% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
<a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
<hr>
<a role="menuitem" href="/?tab=cont">Cont</a>
<a role="menuitem" href="/?tab=integrare">Integrare</a>
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
@@ -346,6 +458,9 @@
{% endif %}
</div>
</header>
{# aria-live pentru anuntarea schimbarilor de tema (US-014, accesibilitate) #}
<span id="tema-live" role="status" aria-live="polite"
style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;"></span>
<main>{% block content %}{% endblock %}</main>
{# Modal detaliu trimitere (PRD 5.9 US-003): container global, SIBLING al <main>
(nu descendent), ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el (R7).
@@ -360,36 +475,46 @@
</div>
</div>
<script>
// Handler comutator tema (US-002 PRD 5.3): click toggle light<->dark, persista in localStorage.
// Separare init (doar sincronizare iconita) de persistenta (doar la click explicit).
// Motivatie: scrierea in localStorage la init ar ingloba imediat preferinta OS-aware ca alegere
// explicita, impiedicand urmarirea ulterioara a modificarilor de tema ale sistemului de operare.
// Comutator tema ciclic (US-014 PRD 5.10): click cicleaza Light->Dark->Petrol->Auto.
// Separare init (sincronizare iconita/label) de persistenta (doar la click explicit).
// 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat.
(function() {
var btn = document.getElementById('tema-toggle');
if (!btn) return;
// Sincronizeaza iconita si aria-label dupa tema curenta -- FARA efecte secundare in localStorage.
function _syncIcon(t) {
if (t === 'light') {
btn.innerHTML = '&#9790;';
btn.setAttribute('aria-label', 'Comuta tema (intunecat)');
btn.title = 'Comuta tema (intunecat)';
} else {
btn.innerHTML = '&#9728;';
btn.setAttribute('aria-label', 'Comuta tema (luminos)');
btn.title = 'Comuta tema (luminos)';
}
var CYCLE = ['light', 'dark', 'petrol', 'auto'];
var VALID = {light:1, dark:1, petrol:1, auto:1};
// Iconite per tema: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto
var ICONS = {light:'&#9728;', dark:'&#9790;', petrol:'&#9680;', auto:'&#9689;'};
var LABELS = {light:'Light', dark:'Dark', petrol:'Petrol', auto:'Auto'};
var NEXT = {light:'Dark', dark:'Petrol', petrol:'Auto', auto:'Light'};
var TOOLTIP_CICLU = 'Ciclu: Light → Dark → Petrol → Auto';
function _stored() {
try { var v = localStorage.getItem('theme'); return (v && VALID[v]) ? v : 'auto'; } catch(e) { return 'auto'; }
}
function _resolved(stored) {
if (stored !== 'auto') return stored;
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
function _syncButton(stored) {
var s = VALID[stored] ? stored : 'auto';
btn.innerHTML = ICONS[s];
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
btn.title = 'Tema: ' + LABELS[s] + '. ' + TOOLTIP_CICLU;
}
// Aplica o tema noua, seteaza data-theme si persista in localStorage -- apelat DOAR la click.
function _setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
document.documentElement.setAttribute('data-theme', _resolved(t));
try { localStorage.setItem('theme', t); } catch(e) {}
_syncIcon(t);
_syncButton(t);
var live = document.getElementById('tema-live');
if (live) live.textContent = 'Tema: ' + LABELS[t] + (t === 'auto' ? ' (urmeaza sistemul)' : '');
}
// La init: sincronizeaza doar iconita din data-theme curent (setat deja de scriptul anti-FOUC).
_syncIcon(document.documentElement.getAttribute('data-theme') || 'dark');
// Init: sincronizeaza iconita din starea stocata (fara a scrie in localStorage).
_syncButton(_stored());
btn.addEventListener('click', function() {
var cur = document.documentElement.getAttribute('data-theme') || 'dark';
_setTheme(cur === 'dark' ? 'light' : 'dark');
var cur = _stored();
var idx = CYCLE.indexOf(cur);
_setTheme(CYCLE[(idx + 1) % CYCLE.length]);
});
})();
</script>
@@ -470,6 +595,19 @@
window.addEventListener('resize', function() { closeAll(null); });
})();
</script>
<script>
// US-011: dirty state pentru butoanele de salvare din tabelele de mapari.
// Cand utilizatorul schimba un select dintr-un form de mapare, butonul de salvare
// legat prin data-dirty-form devine evidentiat (clasa "dirty" → fundal --accent).
// Starea "dirty" e efemera per-render: un swap outerHTML o reseteaza automat.
// Delegare pe document → supravietuieste swap-urilor HTMX (#mapari-section).
document.addEventListener('change', function(e) {
var el = e.target;
if (el.tagName !== 'SELECT' || !el.form || !el.form.id) return;
var saveBtn = document.querySelector('button[data-dirty-form="' + el.form.id + '"]');
if (saveBtn) saveBtn.classList.add('dirty');
});
</script>
<script>
// Cautare + paginare client-side pentru tabele mari (data-dt="<page_size>"). Filtreaza si
// pagineaza DOM-ul deja randat (fara cereri server) — potrivit pentru maparile care pot creste

View File

@@ -1,10 +1,12 @@
{% extends "base.html" %}
{% block content %}
{# US-007 (5.5): nav-ul ad-hoc (Panou admin + logout) a fost mutat in meniul de cont (☰)
din header (base.html). Aici raman doar bara de status + tab-bar-ul de lucru zilnic. #}
{# US-009 (PRD 5.10): tab-bar-ul Acasa/Mapari a fost eliminat. Mapari s-a mutat in meniul
hamburger (#cont-menu in base.html). Acasa e continutul principal direct — nicio schela ARIA
role="tablist"/"tab"/"tabpanel" orfana. Rutele /_fragments/* si deep-link-urile ?tab=
raman valide (navigare prin meniu → full page reload). #}
<!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
<!-- Bara de status: mereu vizibila -->
<div id="status-bar" class="status-bar card"
hx-get="/_fragments/status"
hx-trigger="load, every 15s"
@@ -12,80 +14,9 @@
<div class="empty muted" style="padding:8px 0;">se incarca starea…</div>
</div>
<!-- Tab-bar: navigare intre sectiuni -->
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
{# US-007 (5.5): tab-bar redus la suprafetele de LUCRU ZILNIC (Acasa·Mapari).
Cont/Integrare/Nomenclator s-au mutat in meniul de cont (☰) din header — rutele
`/_fragments/{cont,integrare,nomenclator}` + deep-link `?tab=` raman valide. #}
{% set tabs = [
("acasa", "Acasa", "tab-acasa"),
("mapari", "Mapari", "tab-mapari")
] %}
{% for tab_id, tab_label, tab_elem_id in tabs %}
{% set badge = (badges.get(tab_id, 0) if badges else 0) %}
<a id="{{ tab_elem_id }}"
role="tab"
href="/?tab={{ tab_id }}"
aria-selected="{{ 'true' if active_tab == tab_id else 'false' }}"
aria-controls="tab-panel"
{% if badge %}aria-label="{{ tab_label }}, {{ badge }} necesita atentie"{% endif %}
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 }}{% if badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ badge }}</span>{% endif %}</a>
{% endfor %}
</div>
<!-- Panou activ: randat server-side la full load; HTMX inlocuieste continutul la click pe tab -->
<div id="tab-panel" role="tabpanel" aria-labelledby="tab-{{ active_tab }}" class="tab-panel">
<!-- Panou activ: randat server-side la full load (Acasa implicit, sau ?tab= prin meniu) -->
<div id="tab-panel" class="tab-panel">
{{ panel_html | safe }}
</div>
<script>
(function() {
/* Navigare cu sageti intre tab-uri (ARIA pattern) — scoped pe fiecare tablist.
Folosim querySelectorAll pentru a suporta multiple tablist-uri pe pagina
(tab-bar principal + tab-urile interne din panoul Integrare). */
document.querySelectorAll('[role="tablist"]').forEach(function(tablist) {
var tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
if (!tabs.length) return;
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();
}
});
/* La click pe tab: actualizeaza aria-selected + tabindex (scoped pe tablist-ul curent) */
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) {
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
t.classList.remove('tab-activ');
});
tab.setAttribute('aria-selected', 'true');
tab.setAttribute('tabindex', '0');
tab.classList.add('tab-activ');
});
});
});
})();
</script>
{% endblock %}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,430 @@
<!-- /autoplan restore point: ~/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260625-120049.md -->
# PRD 5.10 — UX trimiteri (pill filtre, paginare, detaliu) + Mapari in meniu
**Stare**: inchis (2026-06-25 — 14 stories + fix US-006b TDD prin echipa; VERIFY PASS; `/code-review high` 1 finding material reparat; regresie 896 passed / 1 skipped / 0 failed; fonturi IBM Plex reale)
> 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).
## 1. Obiectiv
Curatare UX a dashboard-ului pe doua zone: (a) **tabelul de trimiteri** — pill-uri de filtrare
pe categorii de problema in locul listei expandate de ID-uri, filtrare pe interval de data care
chiar functioneaza pe timestamp-uri, paginare numerotata, VIN sub numarul de inmatriculare,
editarea operatiei RAR si afisarea operatiei de service in detaliu, plus erori mai putin verbose
in formularul de editare; (b) **Mapari** — mutata in meniul hamburger (fara tab-uri pe pagina
principala), o singura pagina cu sectiunile consolidate si butoane de salvare/stergere vizibile.
Pur UI/UX + un fix de filtrare backend. Backend-ul de trimitere (worker, masina de stari,
idempotenta, mapping-rezolvare) ramane **NEATINS**.
## 2. Non-Goals (anti scope-creep)
- **Nu** atingem worker-ul, masina de stari, idempotenta sau logica de mapare operatie→cod.
- **Nu** permitem editarea operatiei RAR pe stari trimise/in curs (`sent`/`sending`/`queued`) —
la RAR `FINALIZATA` e terminal; editarea apare doar pe `needs_data`/`needs_mapping`/`error`.
- **Nu** schimbam paginarea client-side existenta din tabelele Mapari (datatable `data-dt`).
- **Nu** schimbam mecanismul de persistenta/anti-FOUC al temei (PRD 5.3) — il extindem cu teme noi, nu il rescriem.
- **Nu** adaugam filtre noi (doar pill-uri pe categoriile de problema deja existente + fix data).
- **Nu** modificam contractul API `POST /v1/prezentari` / `GET /v1/prezentari` (operatia de
service afisata vine din payload-ul deja stocat, nu cere camp nou).
- **Nu** reproiectam meniul hamburger in sine — doar adaugam intrarea „Mapari" si scoatem tab-bar-ul.
- **Acceptat explicit (review C4)**: doua idiomuri de paginare coexista — server-side numerotat pe Trimiteri (US-004) vs client-side `data-dt` (Inapoi/Inainte) pe Mapari. Diferenta e intentionata, nu „de reparat" ulterior.
- **Acceptat explicit (review C2)**: butonul ciclic de tema (US-014) are cost de descoperire (4 teme fara optiuni vizibile). Compensat prin `aria-label` curenta+urmatoarea + tooltip; nu trecem la meniu/popover in aceasta livrabila.
## 3. Stories atomice
> Fiecare story: cea mai mica unitate care lasa sistemul functional. Backend + UI pentru acelasi
> comportament = 2 stories.
### US-001: Fix filtrare pe interval de data (backend)
**Ca** operator **vreau** ca filtrul „Data de la / pana la" sa returneze toate trimiterile din
acea perioada **pentru ca** acum, fiindca `data_prestatie` poate avea ora/minut/secunda,
comparatia de string exclude randurile si tabelul apare gol.
- **Depinde de**: —
- **Fisiere**: `app/web/routes.py` (`fragment_submissions`, `_is_iso_date`), `tests/test_web_filtre_submissions.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_filtre_submissions.py``test_filtru_data_include_timestamp_cu_ora`, `test_filtru_data_interval_inclusiv_capete`, `test_filtru_data_ignora_valori_ne_data`
- **Acceptance criteria**:
- [ ] **(must-fix, review C1)** Cauza reala = garda `_is_iso_date` cere `len(s)==10` (`routes.py:640-649`), deci orice `data_prestatie` cu ora e **exclusa inainte** de comparatie. Fix: introdu `_iso_date_prefix(value) -> str | None` (intoarce `value[:10]` daca parseaza ca `YYYY-MM-DD`, altfel `None`) si foloseste-l **atat** pentru garda **cat si** pentru comparatie. Nu modifica doar liniile de comparatie — altfel bug-ul ramane.
- [ ] Filtrarea compara doar **portiunea de data** (primele 10 caractere, `YYYY-MM-DD`) a lui `data_prestatie`, chiar daca valoarea contine ora/minut/secunda (ex. `2026-06-20 14:35:07` sau `2026-06-20T14:35:07`).
- [ ] Intervalul e **inclusiv la ambele capete**: `data_de <= data(rand) <= data_pana`.
- [ ] O singura limita (doar `data_de` sau doar `data_pana`) functioneaza corect.
- [ ] Valorile care nu incep cu o data ISO valida raman excluse din filtru (comportament actual pastrat).
- [ ] `python3 -m pytest tests/test_web_filtre_submissions.py -q` trece.
- **Verificare E2E**: browser pe `/` — import/seed cu o trimitere cu `data_prestatie` ce contine ora; filtru pe acea zi → randul apare.
### US-002: Expune operatia de service in view-ul de rand si detaliu (backend)
**Ca** operator **vreau** sa vad operatia de service originala (codul intern / denumirea venita
prin API sau import CSV) **pentru ca** vreau sa stiu ce a cerut service-ul, nu doar codul RAR mapat.
- **Depinde de**: —
- **Fisiere**: `app/payload_view.py` (sau `app/web/routes.py` `_detaliu_ctx`/`prezentare_din_payload`), `tests/test_payload_view.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_payload_view.py``test_operatie_service_din_cod_op_service`, `test_operatie_service_din_denumire`, `test_fara_operatie_service_cand_lipseste`
- **Acceptance criteria**:
- [ ] **(must-fix, review M1)** `prezentare_din_payload` colapseaza acum service-op si RAR-op in chei suprapuse (`operatie = denumire or cod`, `cod_rar = cod_prestatie`; `payload_view.py:111-113`). Adauga **chei noi distincte** (ex. `op_service_cod` + `op_service_denumire`) din `cod_op_service`/`denumire`, separate de operatia RAR mapata.
- [ ] **Conventie goala (must-fix, review M1)**: payload_view emite `EMPTY="—"` pentru valori lipsa (`payload_view.py:119-127`), dar US-007 cere „randul nu apare deloc (fara — gol)". Alege O conventie: fie campul nou intoarce `""`/`None` (nu `—`), fie template-ul testeaza fata de `'—'` (ca randul VIN, `_submissions.html:63`). Documenteaz-o aici si in US-007.
- [ ] Cand payload-ul nu contine operatie de service (a venit direct cu `cod_prestatie`), campul e gol conform conventiei de mai sus, fara a arunca.
- [ ] Helper-ul ramane pur (fara DB), defensiv la payload lipsa/corupt.
- [ ] `python3 -m pytest tests/test_payload_view.py -q` trece.
- **Verificare E2E**: `POST /v1/prezentari` cu `cod_op_service`+`denumire` → randul are operatia de service in context.
### US-003: Pill-uri de filtrare pe categorii de problema (UI)
**Ca** operator **vreau** pill-uri „Date incomplete / Lipsa cod / Eroare" cu numar in sectiunea de
filtrare, in loc de lista expandata `#40 TMB...3456 / B28ERR ...si inca 3` **pentru ca** lista de
ID-uri e zgomotoasa; vreau sa apas un pill ca sa filtrez direct pe acea categorie.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_coada.html`, `app/web/templates/_status.html`, `app/web/routes.py` (counts per categorie pe fragmentul de filtre), `tests/test_web_pill_filtre.py` (~4 fisiere)
- **Test intai (RED)**: `tests/test_web_pill_filtre.py``test_pill_per_categorie_cu_numar`, `test_pill_click_seteaza_status`, `test_fara_lista_id_uri`
- **Acceptance criteria**:
- [ ] In sectiunea de filtrare apar pill-uri: **Date incomplete** (`needs_data`), **Lipsa cod** (`needs_mapping`), **Eroare** (`error`), fiecare cu numarul total scoped pe cont; pill-ul lipseste/e ascuns cand numarul e 0.
- [ ] **(must-fix, review M3/design-H1)** Lista de ID-uri si blocul `blocate_actionabil` sunt in **`_status.html:68-77`** (nu `_coada.html`), generate de `_blocate_actionabil` (`routes.py:562-594`). La eliminarea ID-urilor, scoate si codul mort care calcula `prezentare_din_payload`/`vin_partial` per rand. Pill-urile reutilizeaza contoarele deja calculate (`_status_counts`/`n`) — fara filtru backend nou.
- [ ] **(must-fix, review design-M1)** Decide explicit soarta intregului bloc „Necesita atentia ta" + a contorului „Blocate": pill-urile **inlocuiesc** link-urile de categorie (nu triplu-encoda aceeasi informatie cu pill + link + dropdown).
- [ ] **(must-fix, review M3/A3)** Pill-urile sunt elemente focalizabile reale (`<button>`/`<a>`, nu `<span onclick>`), cu `focus-visible`, activare Enter/Space, si stare activa via `aria-pressed` (nu doar culoare).
- [ ] Click pe un pill filtreaza tabelul pe acea stare (seteaza `status=` pe `fragment_submissions`, deja suportat `routes.py:699`); pill activ evidentiat; click pe activ revine la „toate".
- [ ] **(must-fix, review M3)** Sincronizare cu dropdown-ul `status` din `_coada.html:36-44`: ori dropdown-ul reflecta pill-ul activ, ori e eliminat — fara desync (click pill vs valoare dropdown stale).
- [ ] Exista si un control „Toate" (toggle) care reseteaza filtrul de categorie. Stare „toate zero" (steady-state sanatos): definita explicit (rand ascuns vs afisaj muted „nicio problema"), nu gap neexplicat.
- [ ] **(must-fix, review S1/A5)** Matrice de stil pill rezolvata fara contradictie rosu/galben vs accent: inactiv = contur/text pe culoarea categoriei (`--err`/`--warn`); activ = umplere pe **culoarea categoriei** (NU accent albastru — altfel pill rosu „Date incomplete" devine albastru cand e activ). Contrast text pe pill-ul activ verificat AA in toate cele 3 teme.
- [ ] `python3 -m pytest tests/test_web_pill_filtre.py -q` trece.
- **Verificare E2E**: browser pe `/` — pill-uri cu numere; click pe „Date incomplete" → tabel filtrat; click din nou → toate.
### US-004: Paginare numerotata pe tabelul de trimiteri (backend + UI)
**Ca** operator **vreau** paginare numerotata pe tabelul de trimiteri **pentru ca** acum se
incarca max 200 randuri fara navigare si nu pot ajunge la cele mai vechi.
- **Depinde de**: US-001 (filtrul de data corect intra in acelasi handler)
- **Fisiere**: `app/web/routes.py` (`fragment_submissions`: param `page`, total + slice ramificat — vezi C1), `app/web/templates/_submissions.html`, `app/web/templates/_coada.html` (poll `hx-include` + `page`), `tests/test_web_paginare_submissions.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_paginare_submissions.py``test_pagina_implicita_25`, `test_pagina_2_offset`, `test_total_si_numar_pagini`, `test_paginarea_pastreaza_filtrele`, `test_pagina_peste_total_revine_la_ultima`, `test_poll_pastreaza_pagina`
- **Acceptance criteria**:
- [ ] **(must-fix, review H1)** Numararea totalului ramifica dupa tipul de filtru: **fara** filtru Python (status-only / niciun filtru) → SQL `COUNT(*)` + `LIMIT 25 OFFSET (page-1)*25`; **cu** filtru vehicul/data activ → fetch-all (fara LIMIT, ca azi) → filtreaza in Python → `total=len(filtrat)` → slice `[offset:offset+25]`. SQL `COUNT(*)/LIMIT/OFFSET` e **gresit** cand filtrul Python e activ. Plafonul de 200 randuri din bucla se inlocuieste cu „fetch-all-then-slice" pe calea filtrata (altfel paginile >8 dispar silentios).
- [ ] Marime pagina fixa **25** randuri; raspunsul include numarul total si pagina curenta.
- [ ] **(must-fix, review H2)** `page` in afara intervalului se clampeaza la `[1, ceil(total/25)]` (nu pagina goala); schimbarea unui filtru reseteaza `page` la 1.
- [ ] Controale: ` Inapoi`, numere de pagina, `Inainte `; pagina curenta evidentiata cu `aria-current="page"`; capetele dezactivate (`disabled`) la prima/ultima pagina; pager-ul e **ascuns** cand `pages<=1` sau `total==0` (empty state inlocuieste tabelul). „afiseaza X-Y din N" intr-o regiune `aria-live="polite"`.
- [ ] Schimbarea paginii **pastreaza** filtrele active (status/pill, vehicul, data_de, data_pana) — link-urile de paginare poarta toti parametrii de filtru curenti.
- [ ] **(must-fix, review L2)** Poll-ul de 15s (`_coada.html` `hx-include="#filtre-trimiteri"`) NU trebuie sa reseteze pagina: include `page` curent in include-ul de poll (hidden input actualizat de paginare) SAU documenteaza explicit reset-pe-poll. Test: `test_poll_pastreaza_pagina`.
- [ ] `python3 -m pytest tests/test_web_paginare_submissions.py -q` trece.
- **Verificare E2E**: browser pe `/` cu >25 trimiteri — navigare intre pagini, filtru aplicat ramane la schimbarea paginii, poll-ul nu te scoate de pe pagina curenta.
### US-005: VIN sub numarul de inmatriculare in tabel (UI)
**Ca** operator **vreau** ca VIN-ul sa apara pe rand propriu sub numarul de inmatriculare **pentru
ca** pe aceeasi linie e ingramadit si greu de citit.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_submissions.html`, `tests/test_web_submissions_layout.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_submissions_layout.py``test_vin_pe_rand_separat_sub_nr`
- **Acceptance criteria**:
- [ ] **(nota, review L1)** VIN-ul e deja randat in `_submissions.html:61-65`, dar **inline** (`<span>` dupa nr, aceeasi celula). Schimbarea e mica: element block sub nr (nu structura noua). Testul asserteaza un element **block-level**, nu doar prezenta.
- [ ] In coloana Vehicul, numarul de inmatriculare e pe primul rand; VIN-ul (sau partiala VIN) apare **dedesubt**, in stil muted, nu pe aceeasi linie.
- [ ] Cand VIN-ul lipseste, nu apare rand gol (garda `!= '—'` exista deja, `_submissions.html:63`).
- [ ] Layout-ul ramane fara scroll orizontal pe tabel (scopat `.tabel-trimiteri`, consistent cu PRD 5.8).
- [ ] `python3 -m pytest tests/test_web_submissions_layout.py -q` trece.
- **Verificare E2E**: browser pe `/` — VIN sub numar in fiecare rand.
### US-006: Editare operatie RAR in formularul de detaliu (UI + backend corectie)
**Ca** operator **vreau** sa pot schimba operatia RAR (`cod_prestatie`) din formularul de detaliu pe
trimiterile blocate **pentru ca** uneori codul mapat e gresit si vreau sa-l corectez inainte de re-trimitere.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_trimitere_detaliu.html`, `app/web/routes.py` (`post_corectie_trimitere` ~979-1124), `tests/test_web_editare_op_rar.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_editare_op_rar.py``test_editabil_arata_select_cod_rar`, `test_salvare_schimba_cod_si_repune_in_coada`, `test_idempotency_key_se_schimba`, `test_cod_invalid_respins`, `test_sent_nu_arata_select`
- **Acceptance criteria**:
- [ ] Pe `needs_data`/`needs_mapping`/`error`, formularul afiseaza un **select cu codurile din nomenclator** pentru operatia RAR, pre-selectat pe codul curent.
- [ ] **(must-fix, review H3)** Riscul „refoloseste exact calea existenta" e GRESIT formulat: `post_corectie_trimitere` aplica DOAR campurile vehiculului (`vin/nr/data/odometru`), nu citeste niciun cod de prestatie (`routes.py:1009-1012`). Story-ul cere logica **noua** in handler: (a) camp nou `cod_prestatie` in form; (b) validare fata de nomenclator (oglindeste `routes.py:953-961`); (c) injectare in `content["prestatii"][i]["cod_prestatie"]` inainte de `resolve_prestatii`. DUPA injectare, restul caii existente (canonicalize → `build_key` → check coliziune idempotency → re-queue, `routes.py:1028-1104`) recalculeaza corect payload+cheie (`build_key` hashuieste `cod_prestatie`, `idempotency.py:34`).
- [ ] Codul nou e validat fata de nomenclator; un cod necunoscut e **respins** (nu se injecteaza, nu se trimite raw — vezi invariantul `cod_prestatie` validat la ingestie din CLAUDE.md).
- [ ] Test `test_idempotency_key_se_schimba`: cheia de idempotency **difera** dupa schimbarea codului (nu doar `status=queued`).
- [ ] Daca payload-ul are mai multe prestatii, story-ul tinteste prima/operatia editabila explicit (nu ambiguu).
- [ ] Pe `sent`/`sending`/`queued` operatia RAR ramane **read-only** (fara select).
- [ ] Scoped pe sesiune + CSRF, 404 cross-account.
- [ ] `python3 -m pytest tests/test_web_editare_op_rar.py -q` trece.
- **Verificare E2E**: browser pe `/` — pe o trimitere `needs_data`, schimba codul RAR din select, salveaza → cod nou aplicat + cheie idempotency noua; cod invalid → respins; pe o trimitere `sent`, codul e read-only.
### US-007: Afisare operatie de service in detaliu (UI)
**Ca** operator **vreau** sa vad operatia de service originala in formularul de detaliu **pentru
ca** vreau sa stiu ce a cerut service-ul prin API/CSV, alaturi de codul RAR.
- **Depinde de**: US-002
- **Fisiere**: `app/web/templates/_trimitere_detaliu.html`, `tests/test_web_detaliu_op_service.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_detaliu_op_service.py``test_detaliu_arata_operatie_service`, `test_detaliu_omite_cand_lipseste`
- **Acceptance criteria**:
- [ ] In detaliu apare „Operatie service" (cod intern + denumire) cand a existat in payload, distinct de „Operatie RAR".
- [ ] Cand operatia de service lipseste (a venit direct cu `cod_prestatie`), randul nu apare deloc (fara „—" gol).
- [ ] Apare atat in contextul editabil cat si in cel read-only.
- [ ] `python3 -m pytest tests/test_web_detaliu_op_service.py -q` trece.
- **Verificare E2E**: browser pe `/` — trimitere venita cu `cod_op_service` arata operatia de service in detaliu; una venita cu `cod_prestatie` direct nu o arata.
### US-008: Simplificare eroare in formularul de editare (UI)
**Ca** operator **vreau** ca in formularul de editare sa apara doar textul simplu al erorii (subliniat),
nu blocul verbose pe 3 niveluri **pentru ca** „De ce / Cum repari" + prefixul tehnic dubleaza un
mesaj deja descriptiv (ex. „odometruFinal trebuie sa fie un numar intreg (ca string).").
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_trimitere_detaliu.html` (zona editabila), `tests/test_web_detaliu_eroare_simpla.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_detaliu_eroare_simpla.py``test_form_editare_fara_card_3niveluri`, `test_eroare_pe_camp_doar_text_simplu`
- **Acceptance criteria**:
- [ ] **(must-fix, review M4)** Blocul `erori_3n`/`card_erori` e acum randat **inainte** de form, in afara ramurii `{% if editabil %}` (`_trimitere_detaliu.html:22-27`) — deci apare si pe read-only. Ascunde-l DOAR in editare: muta-l/infasoara-l in `{% if not editabil %}` ca sa-l pastrezi pe contextele read-only (catalogul PRD 5.4).
- [ ] Eroarea per camp ramane sub input ca **text simplu subliniat** (mesajul descriptiv). Nota: macro-ul `camp` printeaza deja doar `err_map.get(nume)` (mesajul, fara cod de camp) — verifica doar ca `message` nu inglobeaza numele campului.
- [ ] **(must-fix, review M6/design)** Erorile **fara camp** (`field is None`) nu trebuie sa dispara silentios cand scoatem cardul 3n: defineste unde apar (ex. un rezumat simplu top-of-form) in formularul de editare.
- [ ] Restul contextelor (lista compacta / detaliu read-only) raman neschimbate — simplificarea e scopata doar pe formularul de editare.
- [ ] `python3 -m pytest tests/test_web_detaliu_eroare_simpla.py -q` trece.
- **Verificare E2E**: browser pe `/` — corectie cu odometru invalid → sub camp apare doar textul erorii subliniat, fara cardul pe 3 niveluri.
### US-009: Mapari in meniul hamburger + scoatere tab-uri (UI)
**Ca** operator **vreau** ca Mapari sa fie o intrare in meniul hamburger, nu tab pe pagina
principala **pentru ca** vreau pagina principala curata (doar Acasa), iar tab-bar-ul Acasa/Mapari
incurca.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/base.html` (meniu `#cont-menu`), `app/web/templates/dashboard.html` (tab-bar), `app/web/routes.py` (rutare pagina Mapari, ex. `/?tab=mapari` sau `/mapari`), `tests/test_web_mapari_meniu.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_mapari_meniu.py``test_meniu_contine_mapari`, `test_pagina_principala_fara_tabbar_mapari`, `test_ruta_mapari_randeaza_sectiunea`
- **Acceptance criteria**:
- [ ] Meniul hamburger contine o intrare **Mapari** (cu badge pentru `needs_mapping` daca exista contoare). Badge-ul muta din tab-bar pe item-ul de meniu — sursa contorului ramane `badges.mapari` (`routes.py:423-424`); verifica sa nu ramana badge dangling.
- [ ] Tab-bar-ul Acasa/Mapari de pe pagina principala e **eliminat**; Acasa devine continutul principal direct.
- [ ] **(must-fix, review design-C1)** Scoate si schela ARIA `role="tablist"` orfana + JS-ul de navigare cu sageti din `dashboard.html:16-88` (un `role="tablist"` cu un singur tab e o violare ARIA). Curata plumbing-ul `active_tab` ramas fara sens daca nu mai e folosit.
- [ ] Exista o ruta dedicata care randeaza pagina Mapari (server-side, deep-link). `?tab=mapari` ramane valid (`_TABS_VALIDE` include `mapari`, `routes.py:155`; randat de `_render_panel_for_tab`) — nu da 404. Verifica sa nu ramana `hx-get` catre un element de tab eliminat (banner/badge).
- [ ] `python3 -m pytest tests/test_web_mapari_meniu.py -q` trece.
- **Verificare E2E**: browser pe `/` — fara tab-bar; din ☰ → Mapari → pagina Mapari.
### US-010: Restructurare pagina Mapari intr-o singura pagina consolidata (UI)
**Ca** operator **vreau** o singura pagina Mapari cu „De rezolvat" prima, apoi salvate / reguli text /
formate coloane, fara sectiunea de ajutor si fara textul gol **pentru ca** sectiunile separate si
textele de ajutor ma incurca.
- **Depinde de**: US-009
- **Fisiere**: `app/web/templates/_mapari.html`, `tests/test_web_mapari_layout.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_mapari_layout.py``test_de_rezolvat_prima`, `test_fara_ajutor_si_empty_text`, `test_ordine_sectiuni`
- **Acceptance criteria**:
- [ ] Ordinea sectiunilor: (1) **De rezolvat** prima, (2) Mapari operatii salvate, (3) Reguli automate (text), (4) Formate de coloane salvate.
- [ ] Sectiunea de ajutor (`<details class="ajutor-mapari">`) e **eliminata**.
- [ ] Textul empty-state „Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR. Importa un fisier nou..." e **eliminat** (sectiunea „De rezolvat" goala nu mai afiseaza acel paragraf).
- [ ] Fiecare rand din „De rezolvat" pastreaza select-ul + butonul de salvare vizibil (vezi US-011).
- [ ] `python3 -m pytest tests/test_web_mapari_layout.py -q` trece.
- **Verificare E2E**: browser pe pagina Mapari — ordine corecta, fara ajutor, fara empty-text.
### US-012: Branding header „by ROMFAST" + titlu centrat (UI)
**Ca** utilizator **vreau** ca header-ul sa aiba branding-ul „by ROMFAST" si titlul pe mijloc
**pentru ca** vreau identitate vizuala clara, parte din familia ROMFAST/ROA, nu un header anonim.
> Sistem de design complet: `DESIGN.md` (sectiunile „Header & branding" + „Culori de brand").
- **Depinde de**: —
- **Fisiere**: `app/web/templates/base.html` (header + CSS), `tests/test_web_header_branding.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_header_branding.py``test_header_contine_by_romfast`, `test_titlu_centrat`
- **Acceptance criteria**:
- [ ] Titlul „Gateway RAR AUTOPASS" e **centrat** in header (grila 3 coloane: controale — tema, versiune, ☰ — la **dreapta**, fara a strica centrarea optica a titlului).
- [ ] **(must-fix, review S2)** Plaseaza explicit badge-ul `env` (test/prod, azi `base.html:320`) in grila — celula stanga (echilibru) sau langa titlu — si defineste ordinea de colaps pe mobil (3-col cu titlu centrat + controale care wrap e fragil).
- [ ] Sub titlu, mic: wordmark **„by ROMFAST"** redat ca text stilizat — `by` in `--muted`, `ROM` in `#D1342F` (rosu logo), `FAST` in `#2E74D6` (albastru logo). **NU** se foloseste PNG-ul 3D al logo-ului.
- [ ] Responsiv: pe mobil wordmark-ul ramane sub titlu, controalele nu se suprapun (degrada elegant).
- [ ] Light + dark OK (wordmark pe culori proprii, lizibil pe ambele fundaluri).
- [ ] `python3 -m pytest tests/test_web_header_branding.py -q` trece.
- **Verificare E2E**: browser pe `/` desktop + mobil — titlu centrat, „by ROMFAST" cu ROM rosu / FAST albastru, controale la dreapta.
### US-013: Tema de culori ROMFAST + tipografie (UI)
**Ca** utilizator **vreau** o paleta cu accent albastru ROMFAST (ca romfast.ro) si o tipografie
coerenta **pentru ca** acum totul e gri si fara identitate, iar produsul e parte din familia ROA.
> Valori complete: `DESIGN.md` (sectiunile „Decizie cromatica" + „Tipografie").
- **Depinde de**: design-consultation (DONE — `DESIGN.md` scris)
- **Fisiere**: `app/web/templates/base.html` (variabile `:root` + `[data-theme="light"]` + `font-family`), `app/web/static/fonts/` (woff2 IBM Plex), `tests/test_web_tema_culori.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_tema_culori.py``test_paleta_accent_azur_definita`, `test_font_ibm_plex_aplicat`, `test_contrast_aa_pe_text_principal`
- **Acceptance criteria**:
- [ ] Accentul devine **azur ROMFAST**: `--accent:#2E74D6` (dark) / `#1F66C9` (light), aplicat prin variabile CSS (butoane primare, pill activ, linkuri, focus) — fara culori noi hardcodate imprastiate.
- [ ] Neutrele actualizate conform `DESIGN.md` (dark `--bg:#0f1218`/`--card:#181c24`; light `--bg:#f5f7fa`/`--card:#ffffff`); stari `--ok/--warn/--err` pastrate AA per tema.
- [ ] **Tipografie**: `font-family` UI = IBM Plex Sans (fallback `system-ui`); monospace (coduri RAR/VIN/nr.) = IBM Plex Mono. Self-host woff2 cu `font-display:swap` (subset latin-ext pentru diacritice). Fallback de sistem nu strica layout-ul.
- [ ] **(must-fix, review M3/design)** `font-display:swap` produce un FOUT system-ui→IBM Plex; pe coloanele tabulare (VIN/coduri, `tabular-nums` `base.html:54`) o nepotrivire de metrici da reflow. Defineste fallback cu metrici ajustate (`size-adjust`/`ascent-override`) SAU accepta explicit FOUT-ul si confirma ca tabularele nu fac reflow vizibil.
- [ ] Contrastul textului principal ramane **AA** in ambele teme; accentul-ca-text pe alb foloseste varianta inchisa (`#1F66C9`).
- [ ] Comutatorul light/dark existent (PRD 5.3) + anti-FOUC functioneaza in continuare.
- [ ] `python3 -m pytest tests/test_web_tema_culori.py -q` trece.
- **Verificare E2E**: browser pe `/` — accent azur + IBM Plex in light si dark, pill-uri rotunjite ca pe romfast.ro, contrast verificat.
### US-014: Selector de tema ciclic (Light/Dark/Petrol/Auto) (UI)
**Ca** utilizator **vreau** ca butonul de tema sa cicleze prin mai multe teme (inclusiv una petrol)
**pentru ca** vreau sa-mi aleg aspectul, nu doar light/dark, ca pe demoanaf.ro.
> Spec complet: `DESIGN.md` (sectiunile „Selector de tema" + paleta „Petrol").
- **Depinde de**: US-013 (paleta azur + variabilele de baza)
- **Fisiere**: `app/web/templates/base.html` (script anti-FOUC + `[data-theme="petrol"]` + JS comutator + buton), `tests/test_web_selector_tema.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_selector_tema.py``test_petrol_theme_definit`, `test_buton_cicleaza_temele`
- **Acceptance criteria**:
- [ ] Butonul de tema **cicleaza** la click: Light → Dark → Petrol → Auto → Light; iconita + `aria-label`/`title` reflecta tema curenta.
- [ ] **(must-fix, review A2/design)** Buton accesibil: `aria-label` anunta curenta + urmatoarea („Tema: Petrol, apasa pentru Auto"); schimbarea anuntata via regiune `aria-live="polite"`. Tooltip enumera ciclul. Cost de descoperire (4 teme intr-un buton fara optiuni vizibile) acceptat explicit in Non-Goals.
- [ ] Tema noua **Petrol** definita ca `[data-theme="petrol"]` cu valorile din `DESIGN.md` (accent `--accent:#0E7C7B`, neutre petrol-inchise).
- [ ] **Auto** urmeaza `prefers-color-scheme` (rezolva la Light/Dark azur); nu seteaza `data-theme` fix.
- [ ] **(must-fix, review M4/design)** Scriptul anti-FOUC din `<head>` (`base.html:19-29`) cunoaste azi doar `light`/`dark`. Extinde-l atomic sa enumere toate cele 4 stari; o valoare `localStorage.theme` **legacy/necunoscuta** are fallback definit (nu blink, nu stare invalida). Rezolva „Auto" la light/dark inainte de primul paint.
- [ ] **(must-fix, review S3/design)** Wordmark-ul „FAST" `#2E74D6` (albastru) coexista pe ecranul Petrol cu accentul teal `#0E7C7B` — verifica armonia/contrastul wordmark-ului (ROM rosu + FAST albastru) pe **toate cele 3 teme** concrete, nu doar light/dark.
- [ ] Toate suprafetele raman lizibile (AA pe text principal) in fiecare din cele 3 teme concrete.
- [ ] `python3 -m pytest tests/test_web_selector_tema.py -q` trece.
- **Verificare E2E**: browser pe `/` — click pe buton cicleaza Light→Dark→Petrol→Auto; refresh pastreaza tema; Petrol are accent teal, fara blink la load.
### US-011: Butoane icon salvare/stergere vizibile + evidentiere modificari nesalvate (UI)
**Ca** operator **vreau** butoane mici cu icon de salvare/stergere mereu vizibile pe randurile de
mapari, evidentiate cand am modificari nesalvate **pentru ca** acum trebuie sa intru intr-un meniu
contextual (kebab) si nu imi dau seama ca trebuie sa apas „Salveaza".
- **Depinde de**: US-010
- **Fisiere**: `app/web/templates/_mapari.html`, `app/web/templates/base.html` (CSS/JS mic pentru stare „dirty"), `tests/test_web_mapari_actiuni.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_mapari_actiuni.py``test_butoane_icon_vizibile_pe_rand`, `test_fara_kebab_menu`
- **Acceptance criteria**:
- [ ] Pe „Mapari operatii salvate" si „Reguli automate (text)", actiunile Salveaza/Sterge sunt **butoane mici cu icon mereu vizibile** pe rand, nu ascunse in meniu kebab.
- [ ] **(must-fix, review A1 — decizie de taste, vezi poarta)** Icon-urile sunt **SVG/text stilizate ca icon** (reuse `.icon-btn`), NU emoji brute 💾/🗑: emoji-ul nu se recoloreaza pe teme/dirty-state si randeaza inconsistent intre OS-uri. Numele accesibil din `aria-label`; glifa decorativa `aria-hidden`.
- [ ] Meniul kebab (`position:fixed`) e **eliminat**.
- [ ] **(must-fix, review S4)** Cand utilizatorul schimba select-ul unui rand, butonul de salvare devine **evidentiat** concret (ex. fundal `--accent` + nu emoji) ca semnal de „modificari nesalvate"; in starea normala e discret. Starea „dirty" e efemera per-render (un swap `outerHTML` o reseteaza — fara persistenta asteptata).
- [ ] Confirmarea la stergere (`hx-confirm`) se pastreaza.
- [ ] **(must-fix, review A6)** Accesibil: butoanele au `aria-label` descriptiv; pe mobil regula 44px e scopata ca icon-urile sa ramana **icon-size** (nu full-width ca butoanele normale `min-height:44px;width:100%`), cu zona de atingere adecvata.
- [ ] `python3 -m pytest tests/test_web_mapari_actiuni.py -q` trece.
- **Verificare E2E**: browser pe pagina Mapari — schimba un cod RAR la o mapare salvata → butonul de salvare se evidentiaza; salveaza/sterge din icon-uri vizibile.
## 4. Riscuri
- **Filtrarea pe data (US-001)**: `data_prestatie` poate veni in formate diferite (ISO cu/ fara ora,
`T` vs spatiu). Mitigare: normalizeaza la primele 10 caractere doar cand sunt o data ISO valida;
pastreaza excluderea valorilor ne-ISO (comportament existent) + test parametrizat pe formate.
- **Paginare + filtru in Python (US-004)**: filtrele vehicul/data se aplica post-SQL; numararea
totalului trebuie sa respecte filtrul, nu doar `COUNT(*)` SQL. Mitigare: numara dupa filtrare la
scara actuala (plafon perf deja notat in cod) si testeaza `total` cu filtru activ.
- **Editare operatie RAR (US-006)**: schimbarea codului trebuie sa recalculeze payload + idempotency
ca la corectia `needs_data` existenta, altfel risca chei divergente. Mitigare: refoloseste exact
calea de corectie/mapare existenta, nu una noua.
- **Mutare Mapari din tab (US-009)**: deep-link-uri vechi `?tab=mapari` pot exista. Mitigare:
alias/redirect, test ca nu da 404.
- **Regresie pe contexte de eroare (US-008)**: simplificarea trebuie scopata DOAR pe formularul de
editare; lista compacta + detaliu read-only + API raman pe catalogul 3-niveluri (PRD 5.4).
## 5. Intrebari deschise
> Rezolvate cu utilizatorul inainte de executie (poarta de aprobare PRD). Toate clarificate prin
> AskUserQuestion in sesiunea de planificare:
- [x] Pill filtre: **un pill per categorie** (Date incomplete / Lipsa cod / Eroare) cu numar, nu un singur pill combinat.
- [x] Paginare: **pagini numerotate**, 25 randuri/pagina, total vizibil, pastreaza filtrele.
- [x] Editare operatie RAR: **doar pe stari editabile** (`needs_data`/`needs_mapping`/`error`); read-only pe `sent`/`sending`/`queued`.
- [x] Layout Mapari: **De rezolvat prima**, apoi salvate / reguli text / formate coloane; fara ajutor, fara empty-text.
- [x] Butoane icon: **evidentiere la modificare** (dirty state) pe langa vizibilitatea permanenta.
- [x] Branding + paleta (US-012/US-013): rezolvate prin `design-consultation``DESIGN.md`.
Wordmark „by ROMFAST" sub titlu (ROM rosu `#D1342F` + FAST albastru `#2E74D6`); accent functional
**azur ROMFAST** (`#2E74D6`/`#1F66C9`), consistent cu romfast.ro; font IBM Plex Sans + Mono.
- [x] Selector de tema (US-014): **buton ciclic** ca demoanaf.ro, set Light/Dark/Petrol/Auto;
petrolul (directia initiala) revine ca tema selectabila.
## 6. Valuri de executie (graful de dependente)
```
Val 1 (paralel, fisiere disjuncte):
[US-001] fix filtrare data (routes.py)
[US-002] operatie service in view (payload_view.py)
[US-003] pill-uri filtre (_coada.html / _status.html)
[US-005] VIN sub nr (_submissions.html)
[US-008] eroare simpla in editare (_trimitere_detaliu.html)
[US-009] Mapari in meniu (base.html / dashboard.html)
Val 2 (deblocate de Val 1):
[US-004] paginare ← US-001 (acelasi handler)
[US-006] editare op RAR ← (independent, dar atinge _trimitere_detaliu.html → dupa US-008)
[US-007] op service detaliu ← US-002
[US-010] restructurare Mapari ← US-009
Val 3:
[US-011] butoane icon + dirty state ← US-010
Branding/tema (dupa design-consultation, ating base.html → serializeaza intre ele):
[US-013] paleta azur ROMFAST + IBM Plex ← DESIGN.md (DONE)
[US-012] header „by ROMFAST" + titlu centrat ← (independent, dar atinge base.html → dupa US-013)
[US-014] selector tema ciclic + Petrol/Auto ← US-013
```
> Nota orchestrare: US-006, US-007, US-008 ating toate `_trimitere_detaliu.html` → serializeaza-le
> pe acelasi fisier (nu in worktree-uri paralele). La fel US-010/US-011 pe `_mapari.html`, si
> US-012/US-013/US-014 pe `base.html` (toate trei ating header/CSS/script — un singur autor secvential).
---
## Raport autoplan (review CEO + Design + Eng)
> Rulat 2026-06-25. **Codex indisponibil** (usage limit pe contul OpenAI pana 2026-07-18) →
> toate fazele degradate la **subagent-only** (`[codex-unavailable]`, single-model). Voci
> independente Claude pe fiecare faza (fara context prealabil). Constatarile tehnice critice
> verificate direct in cod de lead inainte de aplicare.
### Consensus (subagent-only — o singura voce per dimensiune)
```
CEO : Premise valide (bug US-001 confirmat real). Recomandare = split branding/tema.
DESIGN : Tare pe backend; lacune in stari vizuale + a11y controale noi (emoji, ciclu tema, pill).
ENG : 4 must-fix verificate in cod (C1 _is_iso_date prefix; H1 total post-filtru;
H3 US-006 nu e reuse pur; M4 card 3n in afara ramurii editabil).
```
### Cross-phase themes (semnalate de 2+ voci independent)
- **US-006 e cel mai riscant story** — CEO (riscant, confirma calea) + Eng (H3: NU e reuse pur, cere logica noua + validare nomenclator + assert pe cheia idempotency). Aplicat in AC.
- **A11y a controalelor noi** — Design (emoji, ciclu tema, pill fara semantica) e clasa de probleme pe care TDD pe substring Jinja o trece dar livreaza un experience rupt la tastatura/screen-reader. Aplicat (SVG/text, aria-pressed, aria-live).
- **Triplu-encoding contoare problema** — Design (pills + „Necesita atentia ta" + badge Mapari). Aplicat: pills inlocuiesc link-urile.
### Decizii auto (principiile 6) — must-fix aplicate in AC
| # | Faza | Decizie | Clasificare | Principiu | Rationament |
|---|------|---------|-------------|-----------|-------------|
| 1 | Eng | US-001: `_iso_date_prefix` (garda+comparatie pe `[:10]`) | Mecanic | P1/P5 | Fara asta bug-ul ramane; o singura solutie corecta |
| 2 | Eng | US-004: total ramificat (SQL count fara filtru Python / fetch-all+slice cu filtru) | Mecanic | P1 | Contradictie Fisiere↔AC; SQL COUNT gresit cu filtru Python |
| 3 | Eng | US-004: clamp `page`, ascunde pager la `pages<=1`, poll pastreaza pagina | Mecanic | P1 | Edge-cases + regresie poll |
| 4 | Eng | US-006: camp `cod_prestatie` + validare nomenclator + assert cheie idempotency (NU reuse pur) | Mecanic | P1/P5 | Verificat in `routes.py:1009-1012` |
| 5 | Eng | US-002/007: cheie payload distincta + conventie goala (`—` vs None) | Mecanic | P5 | Chei suprapuse azi; conventie ambigua intre stories |
| 6 | Eng | US-008: muta cardul 3n in `{% if not editabil %}` + erori fara camp | Mecanic | P1 | Cardul e azi in afara ramurii editabil |
| 7 | Design | US-003: pill-uri butoane reale (aria-pressed), inlocuiesc link-urile, matrice culori categorie (nu accent), sincron cu dropdown | Mecanic | P1 | A11y + anti triplu-encoding |
| 8 | Design | US-009: scoate schela `role="tablist"` orfana + JS | Mecanic | P1 | Un tab in tablist = violare ARIA |
| 9 | Design | US-011: icon-uri SVG/text (nu emoji brute), dirty-state concret, 44px scopat | Taste→aplicat | P1/P5 | User a scris emoji; SVG satisface intentia + a11y + recolorare (vezi poarta) |
| 10 | Design | US-012/013/014: env badge in grila; FOUT pe tabulare; legacy `localStorage.theme`; ciclu a11y; wordmark pe 3 teme | Mecanic | P1 | Stari vizuale lipsa |
### Decizii de taste / User Challenge — la poarta (NU auto-decise)
- **UC-1 (CEO, single-voice)**: split branding/tema (US-012/013/014) intr-un PRD separat + taie Petrol & selectorul ciclic. **Default = PASTRAM** (user a ales explicit in sesiune: Petrol ca tema, ciclu ca demoanaf, IBM Plex). CEO ruleaza ca o singura voce (codex jos) — recomandare, nu decizie.
- **UC-2 (CEO)**: paginarea (US-004) poate fi prematura la scara actuala (purge 90z). **Default = PASTRAM** (alegere user); cost low (1 query de validat numarul max randuri/cont inainte de build).
- **T-1 (Design A1)**: emoji 💾/🗑 (scrise de user) → SVG/text icon. Aplicat ca SVG (satisface ambele), surfata la poarta fiindca user a scris emoji literal.
### Deferat (TODOS, nu in 5.10)
- Hardening GET-uri de listare globale/neprotejate (CLAUDE.md, semnalat de CEO ca valoare mai mare) — separat, nu expandam acest PRD.
- Validare empirica „>200 randuri/cont" pentru US-004 si „cerere reala >2 teme" pentru US-014.
### Verdict poarta (2026-06-25)
- **UC-1 → PASTRAM tot in 5.10** (14 stories, functional + branding/tema). User a confirmat la poarta; CEO single-voice = recomandare, nu verdict. Riscul FOUC/FOUT mitigat prin AC-urile adaugate.
- **UC-2 → PASTRAM paginarea** (US-004). Cost mic, specificata corect dupa review.
- **T-1 → icon-uri SVG/text** (nu emoji brute), aplicat in US-011.
- **APROBAT.** Toate must-fix-urile aplicate in AC. Gata de executie (TDD prin echipa, vezi valuri §6) sau `/ralph`.
---
## Raport VERIFY
> Completat de subagentul verificator (context curat, fara transcriptul executiei) — ROADMAP §5.6. 2026-06-25.
**Verdict: PASS** — toate cele 14 stories PASS, 0 FAIL. Regresie completa **892 passed / 1 skipped / 0 failed** (+49 teste fata de baseline 843; skipped = test `live` RAR, opt-in). Backend trimitere (worker/masina stari/idempotenta/mapping) + schema-send NEATINSE (Non-Goals respectate).
PASS per story cu dovezi verificate direct in cod:
- US-001 PASS — `_iso_date_prefix` folosit ATAT pentru garda CAT si comparatie (`routes.py`), interval inclusiv, ne-ISO excluse.
- US-002 PASS — chei distincte `op_service_cod`/`op_service_denumire`, conventie goala `""` (nu `—`).
- US-003 PASS — pill-uri `<button>` cu `aria-pressed`; needs_mapping `--warn`, needs_data/error `--err`; dropdown status eliminat (fara desync); lista ID-uri eliminata.
- US-004 PASS — total ramificat (SQL COUNT fara filtru Python / fetch-all+slice cu filtru); clamp page; poll pastreaza pagina (OOB `f-page`); pager ascuns la `pages<=1`.
- US-005 PASS — VIN element block-level (`<div>`) sub nr, garda VIN lipsa.
- US-006 PASS — logica noua in `post_corectie_trimitere` (citeste `cod_prestatie`, valideaza nomenclator, injecteaza in `content["prestatii"][0]` inainte de `resolve_prestatii`); cheia idempotency difera; read-only pe sent/sending/queued.
- US-007 PASS — Operatie service distinct de Operatie RAR, absent cand lipseste, in editabil + read-only.
- US-008 PASS — card 3n in `{% if not editabil %}`; erori fara camp = rezumat top-of-form in editare.
- US-009 PASS — Mapari in `#cont-menu` cu badge; tab-bar + `role=tablist` + JS sageti eliminate; `?tab=mapari` nu da 404.
- US-010 PASS — ordine sectiuni corecta; `ajutor-mapari` + empty-text eliminate.
- US-011 PASS — butoane `.icon-btn` SVG (fara emoji); kebab eliminat; dirty-state pe `change` select; `hx-confirm` pastrat.
- US-012 PASS — header grid 3col centrat; env badge in grila; wordmark `by ROMFAST` (ROM `#D1342F`, FAST `#2E74D6`) text, nu PNG.
- US-013 PASS — accent `#2E74D6`/`#1F66C9`/`#0E7C7B`; `@font-face` IBM Plex Sans/Mono. **Rezerva fonturi woff2 placeholder REZOLVATA post-VERIFY**: cele 8 fisiere au fost inlocuite cu subseturile reale IBM Plex (fontsource `@fontsource/ibm-plex-*@5.0.8`, woff2 valide latin + latin-ext, 400/500/700 sans + 400 mono).
- US-014 PASS — `[data-theme=petrol]` `#0E7C7B`; ciclu Light->Dark->Petrol->Auto; anti-FOUC extins la 4 stari + fallback legacy; `aria-label` curenta+urmatoarea + `aria-live`.
**Limitari documentate:**
- E2E browser NEPROBAT in sandbox (fara browser interactiv, consistent cu 5.8/5.9). Recomandat la deploy: `./start.sh test both --send` + browser pe `http://localhost:8000/` pentru proba vizuala (pill-uri/click, paginare >25, VIN sub nr, ciclu tema fara blink, header centrat, Mapari din ☰).
- Live RAR `--send` neprobat (UI pur; backend trimitere neatins — risc minim).
### CLOSE — `/code-review high` (2026-06-25)
1 finding material reparat TDD (US-006b): US-006 acoperea doar `needs_data`/`needs_mapping`, dar AC + intrebarea deschisa cer si `error`. Fix: constanta separata `_EDITABILE_OP=(needs_data,needs_mapping,error)` pentru selectul de cod RAR; pe `error` editarea codului trece prin `/repune` (re-queue), cu validare nomenclator, re-rezolvare, canonicalize + `build_key` (cheie idempotency noua), check coliziune (pre-UPDATE + `IntegrityError`), `error→queued` (rar_error NULL / retry_count 0). `_CORECTABILE` + `post_corectie_trimitere` neatinse (fara regresie US-010/US-011). 9/9 teste editare; regresie completa **896 passed / 1 skipped / 0 failed**.
Findings minore (debt acceptat, non-blocante): (a) comparatie data bruta `d_prefix > data_pana` — sigura prin UI (`<input type=date>` zero-padded), vulnerabila doar la URL fabricat; (b) dublu `load_nomenclator` cand nomenclatorul e legitim gol; (c) linkurile de paginare reafiseaza `vehicul` cu majuscule (cosmetic); (d) duplicare cale validare+inject+idempotency intre `post_corectie_trimitere` si `post_repune_trimitere` (candidat de extras intr-un helper).
**US-012b (decizie user post-review):** in header se foloseste LOGO-ul PNG real (`/static/romfast_logo.png`, `.brand-logo` ~28px) in loc de wordmark-ul text din US-012. Fundal transparent + culori proprii -> lizibil pe toate temele. `test_web_header_branding.py` actualizat sa verifice `<img romfast_logo.png>` + `alt`. DESIGN.md actualizat. Regresie ramane 896 passed.
**Stare finala: VERIFY PASS + fix code-review (US-006b) + logo header (US-012b) aplicate. Gata de commit (poarta umana).**

BIN
docs/romfast_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -50,14 +50,16 @@ def _seed_submission(status: str = "sent", n: int = 1) -> None:
def test_tab_bar_fara_trimiteri(client):
"""Tab-bar-ul nu mai contine tab-ul 'Trimiteri' (coada); raman 4 tab-uri."""
"""US-009: tab-bar eliminat; 'Coada' nu exista; Mapari/Cont/Nomenclator raman in meniu."""
r = client.get("/")
assert r.status_code == 200
html = r.text
# "Coada" nu trebuie sa existe nici ca tab, nici ca link in meniu
assert 'id="tab-coada"' not in html
assert 'href="/?tab=coada"' not in html
for label in ("Acasa", "Mapari", "Cont", "Nomenclator"):
assert f">{label}" in html or f"{label}<" in html, f"lipseste tab {label}"
# US-009: tab-bar eliminat; Mapari/Cont/Nomenclator sunt in meniul hamburger
for label in ("Mapari", "Cont", "Nomenclator"):
assert f">{label}" in html or f"{label}<" in html, f"lipseste intrarea {label} in meniu"
def test_acasa_contine_sectiunea_trimiteri(client):

View File

@@ -146,3 +146,69 @@ def test_operatie_ramane_denumire_sau_op():
})
assert d3["operatie"] == "Verificare"
assert d3["cod"] == "oe-2" # cod ramas neschimbat (nu uppercase)
# --- US-002: op_service_cod + op_service_denumire distincte ---
def test_operatie_service_din_cod_op_service():
"""cod_op_service prezent -> op_service_cod contine valoarea; string gol cand lipseste."""
# cod_op_service prezent -> op_service_cod populated
d = prezentare_din_payload({
"prestatii": [{"cod_op_service": "OP-77", "denumire": "Verificare faruri"}]
})
assert d["op_service_cod"] == "OP-77"
# cod_op_service absent (vine direct cu cod_prestatie) -> op_service_cod == "" (NU EMPTY="—")
d2 = prezentare_din_payload({
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}]
})
assert d2["op_service_cod"] == ""
assert d2["op_service_cod"] != ""
def test_operatie_service_din_denumire():
"""denumire prezenta cu cod_op_service -> op_service_denumire contine valoarea."""
# ambele prezente -> op_service_denumire = denumire
d = prezentare_din_payload({
"prestatii": [{"cod_op_service": "OP-77", "denumire": "Verificare faruri"}]
})
assert d["op_service_denumire"] == "Verificare faruri"
# cod_op_service prezent dar fara denumire -> op_service_denumire == ""
d2 = prezentare_din_payload({
"prestatii": [{"cod_op_service": "OP-77"}]
})
assert d2["op_service_denumire"] == ""
# cod_op_service absent + denumire prezenta -> op_service_denumire == "" (nu expune denumire RAR)
d3 = prezentare_din_payload({
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}]
})
assert d3["op_service_denumire"] == ""
def test_fara_operatie_service_cand_lipseste():
"""Payload fara cod_op_service -> op_service_cod si op_service_denumire sunt "" (nu ""), fara exceptie."""
# vine direct cu cod_prestatie
d = prezentare_din_payload({
"prestatii": [{"cod_prestatie": "R-FRANE"}]
})
assert d["op_service_cod"] == ""
assert d["op_service_denumire"] == ""
assert d["op_service_cod"] != ""
assert d["op_service_denumire"] != ""
# fara prestatii deloc
d2 = prezentare_din_payload({"vin": "WVWZZZ1JZXW000001"})
assert d2["op_service_cod"] == ""
assert d2["op_service_denumire"] == ""
# payload None/gol
d3 = prezentare_din_payload(None)
assert d3["op_service_cod"] == ""
assert d3["op_service_denumire"] == ""
# payload JSON invalid
d4 = prezentare_din_payload("nu-e-json")
assert d4["op_service_cod"] == ""
assert d4["op_service_denumire"] == ""

View File

@@ -71,13 +71,15 @@ def test_paleta_light_definita(client):
def test_dark_ramane_default(client):
""":root contine inca paleta dark exacta: --bg:#0f1115, --card:#181b22, --ink:#e6e9ef."""
""":root contine paleta dark exacta: --bg:#0f1218, --card:#181c24, --ink:#e6e9ef.
Valorile actualizate la US-013 (PRD 5.10) conform DESIGN.md (accent azur ROMFAST).
"""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "--bg:#0f1115" in html, "Paleta dark --bg:#0f1115 a fost modificata sau stearsa"
assert "--card:#181b22" in html, "Paleta dark --card:#181b22 a fost modificata sau stearsa"
assert "--bg:#0f1218" in html, "Paleta dark --bg:#0f1218 a fost modificata sau stearsa"
assert "--card:#181c24" in html, "Paleta dark --card:#181c24 a fost modificata sau stearsa"
assert "--ink:#e6e9ef" in html, "Paleta dark --ink:#e6e9ef a fost modificata sau stearsa"

View File

@@ -74,7 +74,7 @@ def client(monkeypatch):
def test_badge_mapari(client):
"""Cu operatii needs_mapping, tab-ul Mapari poarta un numar + aria-label."""
"""US-009: cu operatii needs_mapping, intrarea Mapari din meniu poarta un badge cu numar."""
acct = _create_account_user("bm@test.com")
_ins(acct, "needs_mapping")
_ins(acct, "needs_mapping")
@@ -82,10 +82,14 @@ def test_badge_mapari(client):
resp = client.get("/")
assert resp.status_code == 200
link = _tab_link(resp.text, "tab-mapari")
assert "tab-badge" in link
assert "2" in link
assert "necesita atentie" in link # aria-label
html = resp.text
# US-009: Mapari e acum in meniu (nu tab); badgeul apare in intrarea meniului
idx = html.find('href="/?tab=mapari"')
assert idx != -1, "Intrarea Mapari lipseste din meniu"
# Cauta badgeul in contextul link-ului Mapari
window = html[idx:idx + 300]
assert "tab-badge" in window, "Badgeul (tab-badge) trebuie sa apara langa intrarea Mapari"
assert "2" in window, "Contorul 2 trebuie sa apara in badge-ul Mapari"
def test_badge_trimiteri_blocate(client):

View File

@@ -85,9 +85,7 @@ def test_tab_import_redirect(client):
html = resp.text
# Echivalent Acasa: contine upload-ul (import-section)
assert 'id="import-section"' in html
# Acasa e tab-ul activ (import nu mai e tab valid separat)
assert re.search(r'id="tab-acasa"[^>]*aria-selected="true"', html), \
"?tab=import ar trebui sa cada pe Acasa activ"
# US-009: tab-bar eliminat, nu mai exista tab-uri cu aria-selected
def test_tab_bar_fara_import(client):

View File

@@ -0,0 +1,207 @@
"""Teste US-008 (PRD 5.10): simplificare erori in formularul de editare.
Problema actuala: cardul erori_3n/card_erori (clasa eroare-3n) e randat INAINTE de form,
in afara ramurii `{% if editabil %}` — deci apare si in contextul de editare.
US-008 cere:
- In editare: cardul 3-niveluri (`eroare-3n`) DISPARUT; erori per-camp raman ca text simplu
subliniat (.s-error); erori fara camp (field None) apar ca rezumat simplu top-of-form.
- In read-only: cardul 3-niveluri se pastreaza (comportament existent).
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
def _csrf(client) -> str:
resp = client.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m
return m.group(1)
def _insert(acct: int, *, status: str, rar_error: str | None = None,
vin: str = "WVWZZZ1JZXW000099", nr: str = "B100TST") -> int:
from app.db import get_connection
conn = get_connection()
try:
payload = json.dumps({
"vin": vin,
"nr_inmatriculare": nr,
"data_prestatie": "2026-06-20",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1"}],
})
cur = conn.execute(
"INSERT INTO submissions "
"(idempotency_key, account_id, status, payload_json, rar_error) "
"VALUES (?, ?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", acct, status, payload, rar_error),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "eroare_simpla.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_form_editare_fara_card_3niveluri(client):
"""In contextul editabil (needs_data), cardul erori_3n (eroare-3n) NU apare.
Problema curenta: cardul e randat INAINTE de form, in afara ramurii editabil,
deci apare atat in editare cat si read-only. US-008 il muta in `{% if not editabil %}`.
"""
acct = _create_account_user("edit3n@test.com")
# needs_data cu rar_error care contine o eroare cu field — format {field, message}
rar_error = json.dumps([
{"field": "odometruFinal", "message": "Odometru trebuie sa fie un numar intreg (ca string)."}
])
sid = _insert(acct, status="needs_data", rar_error=rar_error)
_login(client, "edit3n@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# In editare: cardul cu 3 niveluri NU trebuie sa apara
assert 'class="eroare-3n"' not in html, (
"Cardul erori_3n (eroare-3n) NU trebuie sa apara in contextul editabil (needs_data). "
"US-008: muta-l in '{% if not editabil %}'."
)
assert 'eroare-3n-item' not in html, (
"Itemii card-ului 3n (eroare-3n-item) nu trebuie sa apara in editare."
)
# Formularul de editare trebuie sa ramana prezent
assert 'hx-post=' in html and 'corecteaza' in html, "Formularul de corectie trebuie sa existe"
def test_eroare_pe_camp_doar_text_simplu(client):
"""Dupa o corectie invalida, eroarea per-camp apare ca .s-error text simplu, nu card 3n.
Macro-ul `camp` deja printeaza doar mesajul simplu — testul verifica ca
`eroare-3n` nu exista in raspuns (nu e dobla-randat odata prin card si odata prin macro).
"""
acct = _create_account_user("simplu@test.com")
sid = _insert(acct, status="needs_data", vin="WVWZZZ1JZXW000001", nr="B100TST")
_login(client, "simplu@test.com")
csrf = _csrf(client)
# POST cu odometru invalid (non-numeric) — ramane needs_data + eroare per-camp
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"nr_inmatriculare": "B100TST",
"vin": "WVWZZZ1JZXW000001",
"data_prestatie": "2026-06-20",
"odometru_final": "nu-e-numar",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
html = resp.text
# Eroarea per-camp trebuie sa apara ca text simplu (.s-error)
assert 's-error' in html, "Eroarea per-camp trebuie sa apara ca .s-error text simplu"
# Cardul 3-niveluri NU trebuie sa apara in contextul editabil
assert 'class="eroare-3n"' not in html, (
"Cardul eroare-3n NU trebuie sa apara in contextul editabil. "
"US-008: randat doar in read-only ({% if not editabil %})."
)
def test_eroare_fara_camp_apare_ca_rezumat_in_editare(client):
"""Erori cu field=None nu dispar silentios in editare — apar ca rezumat simplu top-of-form.
Bug M6: template-ul filtra erorile in `err_map` DOAR pe cele cu field,
iar cardul 3n (ascuns in editare) era singurul canal de afisare pentru field=None.
US-008: adauga un rezumat simplu (div .s-error sau similar) in ramura editabil.
"""
acct = _create_account_user("faracam@test.com")
# rar_error cu o eroare FARA camp (field=None) — ex. eroare globala de la RAR
rar_error = json.dumps([
{"problema": "Date incomplete la nivel de prezentare", "cauza": "", "fix": "", "field": None}
])
sid = _insert(acct, status="needs_data", rar_error=rar_error)
_login(client, "faracam@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Mesajul erorii globale trebuie sa fie prezent (nu silentios disparut)
assert "Date incomplete la nivel de prezentare" in html, (
"Eroarea fara camp (field=None) trebuie sa apara in contextul editabil. "
"US-008 (M6): adauga rezumat simplu top-of-form in ramura '{% if editabil %}'."
)
# Dar NU ca card 3-niveluri
assert 'class="eroare-3n"' not in html, (
"Cardul eroare-3n NU trebuie sa apara in contextul editabil."
)
def test_readonly_pastreaza_card_3niveluri(client):
"""In contextul read-only (error/sent), cardul erori_3n se pastreaza neschimbat."""
acct = _create_account_user("readonly3n@test.com")
rar_error = json.dumps([
{"problema": "Eroare RAR server", "cauza": "ORA-12899", "fix": "Reverifica datele", "field": None}
])
sid = _insert(acct, status="error", rar_error=rar_error)
_login(client, "readonly3n@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# In read-only: cardul eroare-3n TREBUIE sa apara
assert 'class="eroare-3n"' in html, (
"Cardul erori_3n (eroare-3n) trebuie pastrat in contextul read-only (error)."
)

View File

@@ -0,0 +1,191 @@
"""Teste US-007 (PRD 5.10): afisare operatie de service in detaliu.
Cand payload-ul contine `cod_op_service` (codul intern al service-ului), detaliul
trebuie sa afiseze „Operatie service" (cod + denumire), distinct de „Operatie RAR".
Cand lipseste (a venit direct cu `cod_prestatie`), randul nu apare deloc — conventie
"" (string gol) stabilita de US-002 in payload_view.py.
Apare atat in contextul editabil (needs_data/needs_mapping) cat si read-only (sent/error).
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
def _insert(acct: int, *, status: str, payload: dict) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", acct, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _payload_cu_op_service(vin: str = "WVWZZZ1JZXW000001") -> dict:
"""Payload cu cod_op_service + denumire (vine prin API cu cod intern)."""
return {
"vin": vin,
"nr_inmatriculare": "B100TST",
"data_prestatie": "2026-06-20",
"odometru_final": "50000",
"prestatii": [
{
"cod_op_service": "OP-FRANE-77",
"denumire": "Verificare si reglaj frane",
"cod_prestatie": "OE-1", # mapat deja
}
],
}
def _payload_fara_op_service(vin: str = "WVWZZZ1JZXW000002") -> dict:
"""Payload cu cod_prestatie direct (fara cod_op_service)."""
return {
"vin": vin,
"nr_inmatriculare": "B200TST",
"data_prestatie": "2026-06-20",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "op_service_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_detaliu_arata_operatie_service_read_only(client):
"""In context read-only (sent), operatia de service (cod intern + denumire) apare distinct.
Randul „Operatie service" trebuie sa fie vizibil si sa contina codul intern
si denumirea venita prin API, separat de „Operatie RAR".
"""
acct = _create_account_user("op_srv_ro@test.com")
sid = _insert(acct, status="sent", payload=_payload_cu_op_service())
_login(client, "op_srv_ro@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Codul intern de service trebuie sa apara
assert "OP-FRANE-77" in html, (
"Codul intern al operatiei de service (op_service_cod) trebuie afisat in detaliu read-only."
)
# Denumirea trebuie sa apara
assert "Verificare si reglaj frane" in html, (
"Denumirea operatiei de service (op_service_denumire) trebuie afisata in detaliu read-only."
)
# Eticheta „Operatie service" trebuie sa apara
assert "Operatie service" in html, (
"Eticheta 'Operatie service' trebuie sa apara in detaliu read-only."
)
def test_detaliu_arata_operatie_service_editabil(client):
"""In context editabil (needs_data), operatia de service apare de asemenea.
Campul este read-only in forma (nu e editabil de operator), dar trebuie afisat
ca referinta pentru ce a cerut service-ul.
"""
acct = _create_account_user("op_srv_edit@test.com")
sid = _insert(acct, status="needs_data", payload=_payload_cu_op_service())
_login(client, "op_srv_edit@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Codul intern trebuie sa apara si in editare
assert "OP-FRANE-77" in html, (
"Codul intern al operatiei de service trebuie afisat si in contextul editabil (needs_data)."
)
# Eticheta trebuie sa apara
assert "Operatie service" in html, (
"Eticheta 'Operatie service' trebuie sa apara si in contextul editabil."
)
def test_detaliu_omite_cand_lipseste_read_only(client):
"""Cand payload-ul nu are cod_op_service (vine direct cu cod_prestatie), randul nu apare.
Conventie US-002: op_service_cod = "" (nu ""). Randul lipseste complet
(nu apare „Operatie service: —" sau rand gol).
"""
acct = _create_account_user("op_srv_absent_ro@test.com")
sid = _insert(acct, status="sent", payload=_payload_fara_op_service())
_login(client, "op_srv_absent_ro@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Eticheta „Operatie service" nu trebuie sa apara cand lipseste
assert "Operatie service" not in html, (
"Randul 'Operatie service' nu trebuie sa apara cand payload-ul nu contine cod_op_service. "
"Conventie US-002: op_service_cod='' → rand absent complet."
)
def test_detaliu_omite_cand_lipseste_editabil(client):
"""Cand payload-ul nu are cod_op_service, randul nu apare nici in editare."""
acct = _create_account_user("op_srv_absent_edit@test.com")
sid = _insert(acct, status="needs_data", payload=_payload_fara_op_service())
_login(client, "op_srv_absent_edit@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
assert "Operatie service" not in html, (
"Randul 'Operatie service' nu trebuie sa apara in editare cand lipseste cod_op_service."
)

View File

@@ -0,0 +1,359 @@
"""Teste US-006 (PRD 5.10): editare operatie RAR (cod_prestatie) din formularul de detaliu.
Stari editabile: needs_data, needs_mapping (stari cu formular de corectie activ).
Read-only: sent/sending/queued/error (fara select cod_prestatie).
Cazuri:
- test_editabil_arata_select_cod_rar: detaliu needs_data → HTML are <select name="cod_prestatie">
- test_salvare_schimba_cod_si_repune_in_coada: POST cu cod_prestatie=OE-2 → payload actualizat + status queued
- test_idempotency_key_se_schimba: schimbarea codului → cheie idempotency noua
- test_cod_invalid_respins: cod necunoscut in nomenclator → respins (status neschimbat)
- test_sent_nu_arata_select: detaliu sent → fara <select name="cod_prestatie">
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# VIN valid: 17 caractere, fara I/O/Q
VIN_US006 = "WVWZZZ1JZXW0E6001"
# Payload complet valid (trece validate_prezentare)
PAYLOAD_VALID = {
"vin": VIN_US006,
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "55000",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _ins(acct: int, *, status: str, payload: dict | None = None) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(f"k-us006-{os.urandom(6).hex()}", acct, status, json.dumps(payload or PAYLOAD_VALID)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _ins_nomenclator(*codes: str) -> None:
"""Insereaza coduri RAR in nomenclator_rar (tabelul e gol in DB-ul de test)."""
from app.db import get_connection
conn = get_connection()
try:
for cod in codes:
conn.execute(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, f"Operatie test {cod}"),
)
conn.commit()
finally:
conn.close()
def _row(sid: int):
from app.db import get_connection
conn = get_connection()
try:
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
finally:
conn.close()
def _csrf(client) -> str:
"""CSRF token din pagina principala (sesiune activa necesara)."""
resp = client.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, f"CSRF token negasit in pagina principala: {resp.text[:500]}"
return m.group(1)
def _detaliu(client, sid: int) -> str:
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
return resp.text
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "editare_rar.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_editabil_arata_select_cod_rar(client):
"""needs_data cu nomenclator populat → formularul de detaliu afiseaza <select name='cod_prestatie'>."""
acct = _create_account_user("sel1@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="needs_data")
_login(client, "sel1@test.com")
html = _detaliu(client, sid)
assert 'name="cod_prestatie"' in html, (
"Formularul de detaliu needs_data trebuie sa contina un select cu name='cod_prestatie'"
)
assert "<select" in html, (
"Elementul <select> trebuie sa apara in detaliu pentru starea needs_data"
)
# Optiunile din select contin codurile din nomenclator
assert "OE-1" in html, "Codul OE-1 trebuie sa apara in optiunile select-ului"
assert "OE-2" in html, "Codul OE-2 trebuie sa apara in optiunile select-ului"
def test_salvare_schimba_cod_si_repune_in_coada(client):
"""POST /corecteaza cu cod_prestatie=OE-2 → payload actualizat + status=queued."""
acct = _create_account_user("sav2@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="needs_data")
_login(client, "sav2@test.com")
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
)
assert resp.status_code == 200
row = _row(sid)
assert row["status"] == "queued", (
f"Dupa salvarea cu OE-2, randul trebuie sa fie queued, nu '{row['status']}'"
)
payload = json.loads(row["payload_json"])
prestatii = payload.get("prestatii") or []
assert prestatii, "Payload-ul trebuie sa contina cel putin o prestatie dupa corectie"
cod_nou = (prestatii[0].get("cod_prestatie") or "").strip().upper()
assert cod_nou == "OE-2", (
f"cod_prestatie in payload trebuie sa fie OE-2, nu '{cod_nou}'"
)
def test_idempotency_key_se_schimba(client):
"""Schimbarea cod_prestatie (OE-1 → OE-2) recalculeaza cheia de idempotency.
Verificare stricta: cheia calculata dupa POST cu OE-2 difera de cheia CANONICALA
cu OE-1. Daca cod_prestatie nu e injectat inainte de build_key, cheia ramane cea
cu OE-1 si testul pica. Este RED inainte de implementarea US-006.
"""
from app.idempotency import build_key, canonicalize_row
acct = _create_account_user("idem3@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="needs_data")
_login(client, "idem3@test.com")
csrf = _csrf(client)
# Cheia canonicala cu OE-1 = ce ar produce un POST fara cod_prestatie (sau cu OE-1)
canon_oe1 = canonicalize_row({**PAYLOAD_VALID, "prestatii": [{"cod_prestatie": "OE-1"}]})
key_oe1 = build_key(acct, canon_oe1)
# POST cu cod_prestatie=OE-2: implementarea US-006 trebuie sa injecteze OE-2 in payload
client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
)
cheie_noua = _row(sid)["idempotency_key"]
assert cheie_noua != key_oe1, (
"Cheia calculata dupa POST cu OE-2 trebuie sa difere de cheia cu OE-1. "
"Daca sunt egale, cod_prestatie nu a fost injectat inainte de build_key (US-006 H3)."
)
def test_cod_invalid_respins(client):
"""Cod RAR necunoscut in nomenclator → randul ramane needs_data (nu se re-cueaza)."""
acct = _create_account_user("inv4@test.com")
_ins_nomenclator("OE-1") # "ZZ-INVALID" nu exista
sid = _ins(acct, status="needs_data")
_login(client, "inv4@test.com")
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "ZZ-INVALID"},
)
# Raspunsul e 200 (eroare in pagina, nu redirect)
assert resp.status_code == 200
row = _row(sid)
assert row["status"] == "needs_data", (
f"Un cod invalid trebuie sa lase randul in needs_data, nu '{row['status']}'"
)
# Mesajul de eroare trebuie sa apara in raspuns
assert "ZZ-INVALID" in resp.text or "necunoscut" in resp.text.lower(), (
"Raspunsul trebuie sa indice ca codul este necunoscut in nomenclator"
)
def test_sent_nu_arata_select(client):
"""Trimitere cu status=sent → fara <select name='cod_prestatie'> in detaliu (read-only)."""
acct = _create_account_user("ro5@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="sent")
_login(client, "ro5@test.com")
html = _detaliu(client, sid)
assert 'name="cod_prestatie"' not in html, (
"Starea sent trebuie sa fie read-only (fara select cod_prestatie)"
)
# ================================================================
# US-006b: extindere la starea error
# ================================================================
def test_error_arata_select_cod_rar(client):
"""needs_data/needs_mapping primeau select (US-006); error trebuie sa primeasca si el
un select cod_prestatie in formularul 'Re-pune in coada' (US-006b)."""
acct = _create_account_user("err1@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="error")
_login(client, "err1@test.com")
html = _detaliu(client, sid)
assert 'name="cod_prestatie"' in html, (
"Starea error trebuie sa afiseze un select cod_prestatie (US-006b)"
)
assert "<select" in html, "Elementul <select> trebuie sa apara in detaliu pentru error"
# Codurile din nomenclator trebuie sa fie in optiuni
assert "OE-1" in html and "OE-2" in html, (
"Codurile din nomenclator trebuie sa apara in select-ul pentru error"
)
# NU trebuie sa afiseze formularul complet de corectie (fara /corecteaza)
assert f"/trimitere/{sid}/corecteaza" not in html, (
"Starea error NU trebuie sa aiba formular /corecteaza (US-006b foloseste /repune)"
)
# Butonul principal ramane 'Re-pune in coada' (nu 'Salveaza si retrimite')
assert "Re-pune in coada" in html
assert "Salveaza si retrimite" not in html
def test_error_salvare_schimba_cod_si_repune_in_coada(client):
"""POST /repune cu cod_prestatie=OE-2 pe un rand error → payload actualizat + status=queued."""
acct = _create_account_user("err2@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="error")
_login(client, "err2@test.com")
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/repune",
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
)
assert resp.status_code == 200
row = _row(sid)
assert row["status"] == "queued", (
f"Dupa repune cu OE-2, randul trebuie sa fie queued, nu '{row['status']}'"
)
payload = json.loads(row["payload_json"])
prestatii = payload.get("prestatii") or []
assert prestatii, "Payload-ul trebuie sa contina cel putin o prestatie"
cod_nou = (prestatii[0].get("cod_prestatie") or "").strip().upper()
assert cod_nou == "OE-2", (
f"cod_prestatie in payload trebuie sa fie OE-2, nu '{cod_nou}'"
)
def test_error_idempotency_key_se_schimba(client):
"""Schimbarea cod_prestatie (OE-1 → OE-2) la repune recalculeaza cheia de idempotency.
Randul e inserat CU CHEIA CANONICA pentru OE-1 (nu random), ca sa fie RED inainte
de implementare: fara injectare, repune nu schimba cheia (ramane OE-1) → test FAIL.
Dupa implementare, POST cu OE-2 → cheie noua (canonicala cu OE-2) ≠ cheie OE-1.
"""
from app.idempotency import build_key, canonicalize_row
from app.db import get_connection as _gc
acct = _create_account_user("err3@test.com")
_ins_nomenclator("OE-1", "OE-2")
# Calculeaza cheia canonicala pentru OE-1 si insereaza randul CU acea cheie.
canon_oe1 = canonicalize_row({**PAYLOAD_VALID, "prestatii": [{"cod_prestatie": "OE-1"}]})
key_oe1 = build_key(acct, canon_oe1)
# Inserare cu cheia cunoscuta (nu random), ca sa avem un baseline deterministic.
conn = _gc()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(key_oe1, acct, "error", json.dumps({**PAYLOAD_VALID, "prestatii": [{"cod_prestatie": "OE-1"}]})),
)
conn.commit()
sid = int(cur.lastrowid)
finally:
conn.close()
_login(client, "err3@test.com")
csrf = _csrf(client)
# POST /repune cu OE-2 → implementarea trebuie sa recalculeze cheia
client.post(
f"/trimitere/{sid}/repune",
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
)
cheie_noua = _row(sid)["idempotency_key"]
assert cheie_noua != key_oe1, (
"Cheia idempotency trebuie sa difere dupa schimbarea cod_prestatie la repune (US-006b). "
"Daca sunt egale, cod_prestatie nu a fost injectat inainte de build_key."
)
def test_queued_nu_arata_select(client):
"""Trimitere queued → fara select cod_prestatie (read-only; doar error/needs_* primesc select)."""
acct = _create_account_user("ro6@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="queued")
_login(client, "ro6@test.com")
html = _detaliu(client, sid)
assert 'name="cod_prestatie"' not in html, (
"Starea queued trebuie sa fie read-only (fara select cod_prestatie)"
)

View File

@@ -0,0 +1,148 @@
"""Teste US-001 (PRD 5.10): fix filtrare pe interval de data in fragment_submissions.
Cazuri:
- timestamp-uri cu ora (ex. "2026-06-20 14:35:07") trebuie incluse la filtrare pe ziua respectiva
- interval inclusiv la ambele capete
- valori ne-ISO raman excluse
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
def _ins(acct: int, *, status: str = "queued", vin: str, nr: str, data: str) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(
f"k-{os.urandom(5).hex()}", acct, status,
json.dumps({
"vin": vin,
"nr_inmatriculare": nr,
"data_prestatie": data,
"odometru_final": "100",
"prestatii": [{"cod_prestatie": "R-X"}],
}),
),
)
conn.commit()
return cur.lastrowid # type: ignore[return-value]
finally:
conn.close()
def _row(sid: int) -> str:
"""Selector HTML pentru randul cu ID-ul dat."""
return f'id="trimitere-row-{sid}"'
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "filtre_data.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_filtru_data_include_timestamp_cu_ora(client):
"""Un rand cu data_prestatie = '2026-06-20 14:35:07' trebuie sa apara
cand filtrul e data_de=2026-06-20 si data_pana=2026-06-20.
Bug actual: _is_iso_date verifica len==10 → exclude timestamp → randul dispare."""
acct = _create_account_user("ts_ora@test.com")
# timestamp cu ora — asta e cauza bug-ului
sid_ora = _ins(acct, vin="WVIN001TS001ORA0001", nr="B01TS", data="2026-06-20 14:35:07")
# rand fara ora, in afara intervalului (nu trebuie sa apara)
sid_alt = _ins(acct, vin="WVIN001TS001ALT0002", nr="B02TS", data="2026-06-21")
_login(client, "ts_ora@test.com")
resp = client.get("/_fragments/submissions?data_de=2026-06-20&data_pana=2026-06-20")
assert resp.status_code == 200
assert _row(sid_ora) in resp.text, (
"Timestamp-ul cu ora trebuie inclus la filtrare pe ziua respectiva"
)
assert _row(sid_alt) not in resp.text, (
"Randul din 2026-06-21 nu trebuie sa apara in intervalul 2026-06-20 to 2026-06-20"
)
def test_filtru_data_interval_inclusiv_capete(client):
"""Intervalul data_de..data_pana este inclusiv la ambele capete.
data_de=2026-06-10, data_pana=2026-06-12 → randurile din 10, 11, 12 apar;
cel din 09 si cel din 13 nu apar.
Testat si cu timestamp-uri (ISO cu ora) pentru a combina ambele cerinte."""
acct = _create_account_user("interval@test.com")
sid_09 = _ins(acct, vin="WVIN_INTERVAL_09000", nr="B09", data="2026-06-09")
sid_10 = _ins(acct, vin="WVIN_INTERVAL_10000", nr="B10", data="2026-06-10") # capat stang inclusiv
sid_11 = _ins(acct, vin="WVIN_INTERVAL_11000", nr="B11", data="2026-06-11 08:00:00") # mijloc cu ora
sid_12 = _ins(acct, vin="WVIN_INTERVAL_12000", nr="B12", data="2026-06-12T23:59:59") # capat drept cu T
sid_13 = _ins(acct, vin="WVIN_INTERVAL_13000", nr="B13", data="2026-06-13")
_login(client, "interval@test.com")
resp = client.get("/_fragments/submissions?data_de=2026-06-10&data_pana=2026-06-12")
assert resp.status_code == 200
body = resp.text
assert _row(sid_09) not in body, "2026-06-09 e inainte de data_de → nu trebuie sa apara"
assert _row(sid_10) in body, "2026-06-10 = data_de → capatul stang inclusiv"
assert _row(sid_11) in body, "2026-06-11 cu ora trebuie inclus"
assert _row(sid_12) in body, "2026-06-12 cu T trebuie inclus (capatul drept inclusiv)"
assert _row(sid_13) not in body, "2026-06-13 e dupa data_pana → nu trebuie sa apara"
def test_filtru_data_ignora_valori_ne_data(client):
"""Valorile care nu incep cu o data ISO valida sunt excluse din rezultate
cand filtrul de data e activ — comportamentul actual pastrat."""
acct = _create_account_user("nedata@test.com")
sid_dd = _ins(acct, vin="WVIN_NEDATA_DD000001", nr="BND1", data="20.06.2026") # format DD.MM.YYYY — ne-ISO
sid_en = _ins(acct, vin="WVIN_NEDATA_EN000002", nr="BND2", data="Jun 20 2026") # format englezesc — ne-ISO
sid_bun = _ins(acct, vin="WVIN_NEDATA_BUNA0003", nr="BGD", data="2026-06-20") # format corect ISO
_login(client, "nedata@test.com")
resp = client.get("/_fragments/submissions?data_de=2026-06-20&data_pana=2026-06-20")
assert resp.status_code == 200
body = resp.text
assert _row(sid_dd) not in body, "Format DD.MM.YYYY trebuie exclus (ne-ISO)"
assert _row(sid_en) not in body, "Format englezesc trebuie exclus (ne-ISO)"
assert _row(sid_bun) in body, "Format ISO corect trebuie inclus"

View File

@@ -0,0 +1,175 @@
"""Teste US-012 / US-012b (PRD 5.10): Header logo ROMFAST + titlu centrat.
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
US-012b (decizie user): logo-ul PNG real (/static/romfast_logo.png) in loc de wordmark text.
Testeaza:
- test_header_contine_by_romfast: <img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
- test_titlu_centrat: titlul e in structura centrata (grila 3 coloane), controale la dreapta
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "branding.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _get_header(html: str) -> str:
"""Extrage continutul elementului <header>."""
m = re.search(r"<header>(.*?)</header>", html, re.DOTALL | re.IGNORECASE)
assert m, "<header> negasit in HTML"
return m.group(1)
def _get_style(html: str) -> str:
"""Extrage continutul primului <style>."""
m = re.search(r"<style>(.*?)</style>", html, re.DOTALL)
assert m, "<style> negasit in HTML"
return m.group(1)
# ── test_header_contine_by_romfast ────────────────────────────────────────────
def test_header_contine_by_romfast(client):
"""Header contine logo-ul ROMFAST ca <img> (US-012b: decizie user — PNG real).
Verifica:
- <img src="/static/romfast_logo.png"> prezent in header
- Atribut alt non-gol (ex. alt="ROMFAST") pentru accesibilitate
- Imaginea are clasa brand-logo (pentru stilizare CSS)
- NU mai exista spanurile text .romfast-rom / .romfast-fast (wordmark text inlocuit)
"""
resp = client.get("/login")
assert resp.status_code == 200
header = _get_header(resp.text)
# Gaseste toate tag-urile <img> din header si cauta logo-ul
img_tags = re.findall(r'<img[^>]+>', header, re.IGNORECASE)
logo_tag = next(
(t for t in img_tags if "romfast_logo.png" in t),
None,
)
# 1. <img> cu src="/static/romfast_logo.png" prezent in header
assert logo_tag is not None, (
"<img> cu 'romfast_logo.png' negasit in header. "
"Decizie user (US-012b): logo-ul PNG real trebuie sa apara in header. "
f"Header: {header[:500]}"
)
# 2. Atribut alt non-gol pe imaginea logo-ului (accesibilitate)
alt_match = re.search(r'alt=["\']([^"\']+)["\']', logo_tag, re.IGNORECASE)
assert alt_match and alt_match.group(1).strip(), (
"Imaginea logo lipseste atributul alt (sau e gol). "
f"Tag gasit: {logo_tag}"
)
# 3. Clasa brand-logo aplicata (pentru controlul inaltimii CSS)
assert "brand-logo" in logo_tag, (
"class='brand-logo' lipseste de pe <img> logo. "
f"Tag gasit: {logo_tag}"
)
# 4. Spanurile text (wordmark vechi .romfast-rom / .romfast-fast) NU mai exista
assert "romfast-rom" not in header and "romfast-fast" not in header, (
"Clasele .romfast-rom / .romfast-fast (wordmark text) inca prezente in header. "
"Trebuie inlocuite complet de <img> logo. "
f"Header snippet: {header[:500]}"
)
# ── test_titlu_centrat ────────────────────────────────────────────────────────
def test_titlu_centrat(client):
"""Titlul 'Gateway RAR AUTOPASS' e in structura centrata in header (grila 3 coloane).
Verifica:
- CSS contine grid-template-columns cu 3 coloane pe header (1fr auto 1fr sau similar)
- Header contine un element cu clasa 'header-center' (sau similar) care contine h1
- Controalele (button tema-toggle) sunt la dreapta (in header-right sau margin-left:auto)
- Badge-ul env e in grila (header-left sau similar), nu flotant
"""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
header = _get_header(html)
style = _get_style(html)
# 1. CSS header foloseste grid cu 3 coloane
header_css = re.search(r"header\s*\{([^}]+)\}", style, re.DOTALL)
assert header_css, "Regula CSS 'header { ... }' negasita in <style>"
header_block = header_css.group(1)
assert "grid" in header_block.lower(), (
f"header CSS nu foloseste grid (display:grid). Block: {header_block.strip()}"
)
assert "grid-template-columns" in style, (
"grid-template-columns lipseste din <style> (necesar pentru 3 coloane)"
)
# 2. Element centrat in header contine h1
center_div = re.search(
r'<div[^>]+class=["\'][^"\']*header-center[^"\']*["\'][^>]*>(.*?)</div>',
header,
re.DOTALL,
)
assert center_div, (
"Element cu clasa 'header-center' negasit in <header>. "
"Titlul trebuie sa fie intr-o celula centrata a grilei. "
f"Header snippet: {header[:600]}"
)
center_content = center_div.group(1)
assert "<h1" in center_content, (
"h1 nu e in interiorul elementului .header-center. "
f"Continut .header-center: {center_content[:300]}"
)
# 3. Controalele (butonul de tema) sunt in header-right
right_div = re.search(
r'<div[^>]+class=["\'][^"\']*header-right[^"\']*["\'][^>]*>(.*?)</div>',
header,
re.DOTALL,
)
assert right_div, (
"Element cu clasa 'header-right' negasit in <header>. "
"Controalele (tema, versiune, meniu) trebuie sa fie la dreapta. "
f"Header snippet: {header[:600]}"
)
right_content = right_div.group(1)
assert "tema-toggle" in right_content, (
"Butonul tema-toggle nu e in .header-right. "
f"Continut .header-right: {right_content[:300]}"
)
# 4. Badge-ul env e in header-left (nu mai e aruncat dupa h1)
left_div = re.search(
r'<div[^>]+class=["\'][^"\']*header-left[^"\']*["\'][^>]*>(.*?)</div>',
header,
re.DOTALL,
)
assert left_div, (
"Element cu clasa 'header-left' negasit in <header>. "
"Badge-ul env trebuie sa fie in celula stanga a grilei (echilibru optic). "
f"Header snippet: {header[:600]}"
)
left_content = left_div.group(1)
assert 'class="env"' in left_content or "class='env'" in left_content, (
"Badge-ul .env nu e in .header-left. "
f"Continut .header-left: {left_content[:200]}"
)

View File

@@ -0,0 +1,141 @@
"""Teste US-011 (PRD 5.10): butoane icon salvare/stergere + dirty state pe Mapari.
Cerinte:
- Butoane .icon-btn mereu vizibile pe rand (nu ascunse in kebab)
- Meniu kebab (<details class="kebab">) eliminat
- aria-label descriptiv pe fiecare buton icon
- data-dirty-form pe butonul de salvare (permite JS dirty-state)
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
def _seed_saved_mapping(acct_id: int) -> None:
"""Insereaza o mapare salvata in operations_mapping."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, ?, ?, ?)",
(acct_id, "OP-TEST-77", "OE-1", 1),
)
conn.commit()
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_actiuni.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_butoane_icon_vizibile_pe_rand_salvate(client):
"""Butoanele de salvare/stergere in 'Mapari salvate' au clasa icon-btn (mereu vizibile)."""
acct = _create_account_user("actiuni_icon@test.com")
_seed_saved_mapping(acct)
_login(client, "actiuni_icon@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
assert 'class="icon-btn' in html, (
"Butoanele de actiune din 'Mapari salvate' trebuie sa aiba clasa 'icon-btn' "
"(mereu vizibile pe rand, nu ascunse in kebab)."
)
def test_fara_kebab_meniu(client):
"""Meniul kebab (details.kebab / kebab-menu) e eliminat din 'Mapari salvate'."""
acct = _create_account_user("actiuni_kebab@test.com")
_seed_saved_mapping(acct)
_login(client, "actiuni_kebab@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
assert 'class="kebab"' not in html, (
"Meniul kebab (details.kebab) trebuie eliminat din 'Mapari salvate' (US-011)."
)
assert '"kebab-menu"' not in html, (
"Clasa 'kebab-menu' trebuie eliminata complet din 'Mapari salvate' (US-011)."
)
def test_butoane_cu_aria_label(client):
"""Butoanele icon-btn au aria-label descriptiv."""
acct = _create_account_user("actiuni_aria@test.com")
_seed_saved_mapping(acct)
_login(client, "actiuni_aria@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
icon_btns = re.findall(r'<button[^>]+class="icon-btn[^"]*"[^>]*>', html)
assert icon_btns, "Trebuie sa existe butoane cu clasa icon-btn in 'Mapari salvate'."
assert any('aria-label' in btn for btn in icon_btns), (
"Cel putin un buton icon-btn trebuie sa aiba atributul aria-label descriptiv."
)
def test_dirty_state_data_attr(client):
"""Butonul de salvare are data-dirty-form pentru dirty-state JS."""
acct = _create_account_user("actiuni_dirty@test.com")
_seed_saved_mapping(acct)
_login(client, "actiuni_dirty@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
assert 'data-dirty-form=' in html, (
"Butonul de salvare trebuie sa aiba atributul data-dirty-form pentru JS dirty-state. "
"Cand utilizatorul schimba selectul, JS adauga clasa 'dirty' pe buton (fundal --accent)."
)

View File

@@ -0,0 +1,135 @@
"""Teste US-010 (PRD 5.10): restructurare pagina Mapari intr-o singura pagina consolidata.
Modificari cerute:
- Ordinea sectiunilor: (1) De rezolvat, (2) Mapari salvate, (3) Reguli automate, (4) Formate coloane.
- Sectiunea de ajutor (<details class="ajutor-mapari">) eliminata.
- Textul empty-state „Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR..." eliminat.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_layout.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_de_rezolvat_prima(client):
"""Sectiunea „De rezolvat" apare prima — inaintea „Mapari operatii salvate"."""
_create_account_user("mapari_ord@test.com")
_login(client, "mapari_ord@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
pos_de_rezolvat = html.find("De rezolvat")
pos_salvate = html.find("Mapari operatii salvate")
assert pos_de_rezolvat != -1, "Sectiunea 'De rezolvat' trebuie sa existe in pagina."
assert pos_salvate != -1, "Sectiunea 'Mapari operatii salvate' trebuie sa existe in pagina."
assert pos_de_rezolvat < pos_salvate, (
f"'De rezolvat' (pozitia {pos_de_rezolvat}) trebuie sa apara INAINTE de "
f"'Mapari operatii salvate' (pozitia {pos_salvate})."
)
def test_fara_ajutor_si_empty_text(client):
"""Sectiunea de ajutor (ajutor-mapari) si empty-text-ul specific sunt eliminate."""
_create_account_user("mapari_fara@test.com")
_login(client, "mapari_fara@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
# Sectiunea de ajutor eliminata
assert 'ajutor-mapari' not in html, (
"Clasa 'ajutor-mapari' (details de ajutor) trebuie eliminata din pagina Mapari (US-010)."
)
assert 'class="ajutor-mapari"' not in html, (
"<details class=\"ajutor-mapari\"> trebuie eliminat."
)
# Empty-text specific „Nicio operatie nemapata" eliminat
assert "Nicio operatie nemapata" not in html, (
"Textul empty-state 'Nicio operatie nemapata — tot ce a venit...' trebuie eliminat (US-010)."
)
assert "tot ce a venit s-a tradus in coduri RAR" not in html, (
"Textul empty-state extins trebuie eliminat complet."
)
def test_ordine_sectiuni(client):
"""Ordinea corecta a sectiunilor: De rezolvat → Mapari salvate → Reguli automate → Formate."""
_create_account_user("mapari_ord2@test.com")
_login(client, "mapari_ord2@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
pos1 = html.find("De rezolvat")
pos2 = html.find("Mapari operatii salvate")
pos3 = html.find("Reguli automate")
pos4 = html.find("Formate de coloane")
assert pos1 != -1, "Sectiunea 'De rezolvat' trebuie sa existe."
assert pos2 != -1, "Sectiunea 'Mapari operatii salvate' trebuie sa existe."
assert pos3 != -1, "Sectiunea 'Reguli automate' trebuie sa existe."
assert pos4 != -1, "Sectiunea 'Formate de coloane' trebuie sa existe."
assert pos1 < pos2, "De rezolvat trebuie sa fie inaintea Mapari salvate."
assert pos2 < pos3, (
f"'Mapari operatii salvate' (poz {pos2}) trebuie sa fie inaintea "
f"'Reguli automate' (poz {pos3}). Acum Reguli automate e ultima sectiune — "
"muta-o pe pozitia 3 (inaintea Formate de coloane)."
)
assert pos3 < pos4, (
f"'Reguli automate' (poz {pos3}) trebuie sa fie inaintea "
f"'Formate de coloane' (poz {pos4})."
)

View File

@@ -0,0 +1,150 @@
"""Teste US-009 (PRD 5.10): Mapari in meniu hamburger + scoatere tab-uri.
TDD: testele se scriu INAINTE de implementare.
Acceptance criteria testate:
- test_meniu_contine_mapari: meniul #cont-menu are o intrare Mapari
(href=/?tab=mapari); badge vizibil cand exista needs_mapping.
- test_pagina_principala_fara_tabbar_mapari: pagina / nu mai are role="tablist"
(tab-bar-ul Acasa/Mapari a fost eliminat).
- test_ruta_mapari_randeaza_sectiunea: GET /?tab=mapari → 200, sectiunea mapari
randata (id="mapari-section"), fara role="tablist" rezidual.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service Test Meniu", active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit pe /login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _ins_needs_mapping(acct: int) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, 'needs_mapping', ?)",
(f"k-us009-{os.urandom(4).hex()}", acct, json.dumps({"prestatii": [{"cod_op_service": "X"}]})),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "meniu_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_meniu_contine_mapari(client):
"""Meniul hamburger (#cont-menu) contine o intrare cu href=/?tab=mapari."""
_create_account_user("menu1@test.com")
_login(client, "menu1@test.com")
html = client.get("/").text
# Intrarea Mapari trebuie sa existe in meniu
assert 'href="/?tab=mapari"' in html, (
"Meniul hamburger trebuie sa contina o intrare cu href='/?tab=mapari'"
)
# Textul "Mapari" trebuie sa apara in meniu (in apropierea link-ului)
idx = html.find('href="/?tab=mapari"')
assert idx != -1
# Cauta "Mapari" in fereastra contextului link-ului
window = html[max(0, idx - 50):idx + 100]
assert "Mapari" in window, (
f"Textul 'Mapari' trebuie sa apara langa href=/?tab=mapari: ...{window}..."
)
def test_meniu_badge_needs_mapping(client):
"""Badge vizibil in meniu cand exista submissions needs_mapping."""
acct = _create_account_user("menu2@test.com")
_ins_needs_mapping(acct)
_login(client, "menu2@test.com")
html = client.get("/").text
# Badgeul trebuie sa apara in apropierea intrarii Mapari
idx = html.find('href="/?tab=mapari"')
assert idx != -1, "Intrarea Mapari lipseste din meniu"
# Cauta tab-badge in contextul intrarii Mapari (in tag-ul/blocul imediat urmator)
window = html[idx:idx + 300]
assert "tab-badge" in window, (
"Badgeul (tab-badge) trebuie sa apara in intrarea Mapari cand exista needs_mapping"
)
def test_pagina_principala_fara_tabbar_mapari(client):
"""Pagina principala / nu mai are role=tablist (tab-bar-ul eliminat in US-009)."""
_create_account_user("menu3@test.com")
_login(client, "menu3@test.com")
html = client.get("/").text
assert 'role="tablist"' not in html, (
"Tab-bar-ul (role=tablist) trebuie eliminat din pagina principala (US-009)"
)
# Nici rolul de tab individual nu trebuie sa existe in tab-bar
# (role=tab poate exista in alte contexte, dar tab-bar-ul tablist+tab nu)
assert 'class="tab-bar"' not in html, (
"Clasa CSS tab-bar trebuie eliminata din pagina principala (US-009)"
)
def test_ruta_mapari_randeaza_sectiunea(client):
"""GET /?tab=mapari → 200, sectiunea mapari randata, fara tablist rezidual."""
_create_account_user("menu4@test.com")
_login(client, "menu4@test.com")
resp = client.get("/?tab=mapari")
assert resp.status_code == 200, (
f"/?tab=mapari trebuie sa returneze 200, nu {resp.status_code}"
)
html = resp.text
assert 'id="mapari-section"' in html, (
"Sectiunea mapari (id='mapari-section') trebuie randata la /?tab=mapari"
)
assert 'role="tablist"' not in html, (
"Tab-bar-ul (role=tablist) nu trebuie sa apara nici la /?tab=mapari"
)

View File

@@ -0,0 +1,182 @@
"""Teste US-004 (PRD 5.10): paginare numerotata pe tabelul de trimiteri.
Cazuri:
- pagina_implicita_25: 30 trimiteri → pagina 1 afiseaza max 25
- pagina_2_offset: 30 trimiteri, page=2 → 5 randuri
- total_si_numar_pagini: raspunsul contine totalul + aria-current pe pagina curenta
- paginarea_pastreaza_filtrele: linkurile de paginare includ filtrul status activ
- pagina_peste_total_revine_la_ultima: page=99 cu 30 trimiteri → clamped la pagina 2
- poll_pastreaza_pagina: raspunsul include id='f-page' value='2' (OOB) pentru poll
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _ins_n(acct: int, n: int, status: str = "sent") -> None:
"""Insereaza n submissions pentru contul dat."""
from app.db import get_connection
conn = get_connection()
try:
for i in range(n):
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(
f"k-pg-{os.urandom(6).hex()}", acct, status,
json.dumps({
"vin": f"WVIN_PG_{i:04d}_DUMMY",
"nr_inmatriculare": f"B{i:03d}PG",
"data_prestatie": "2026-06-20",
"odometru_final": "100",
"prestatii": [{"cod_prestatie": "R-X"}],
}),
),
)
conn.commit()
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "paginare.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_pagina_implicita_25(client):
"""Cu 30 trimiteri, pagina 1 (implicita) returneaza exact 25 randuri."""
acct = _create_account_user("pg1@test.com")
_ins_n(acct, 30)
_login(client, "pg1@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
body = resp.text
row_count = body.count('id="trimitere-row-')
assert row_count == 25, (
f"Pagina 1 cu 30 trimiteri trebuie sa arate exact 25 randuri, nu {row_count}"
)
def test_pagina_2_offset(client):
"""Cu 30 trimiteri, page=2 returneaza restul de 5 randuri (offset 25)."""
acct = _create_account_user("pg2@test.com")
_ins_n(acct, 30)
_login(client, "pg2@test.com")
resp = client.get("/_fragments/submissions?page=2")
assert resp.status_code == 200
body = resp.text
row_count = body.count('id="trimitere-row-')
assert row_count == 5, (
f"Pagina 2 cu 30 total trebuie sa arate 5 randuri, nu {row_count}"
)
def test_total_si_numar_pagini(client):
"""Raspunsul contine totalul (30) si marcheaza pagina curenta cu aria-current='page'."""
acct = _create_account_user("pg3@test.com")
_ins_n(acct, 30)
_login(client, "pg3@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
body = resp.text
# Totalul trebuie afisat undeva (ex. "30 din 30" sau "afiseaza 1-25 din 30")
assert "30" in body, "Totalul 30 trebuie sa apara in raspuns"
# Pagina curenta e marcata semantic
assert 'aria-current="page"' in body, (
"Pagina curenta trebuie marcata cu aria-current='page'"
)
def test_paginarea_pastreaza_filtrele(client):
"""Linkurile de paginare pastreaza filtrul status activ in URL."""
acct = _create_account_user("pg4@test.com")
_ins_n(acct, 30, status="needs_data")
_login(client, "pg4@test.com")
resp = client.get("/_fragments/submissions?status=needs_data&page=1")
assert resp.status_code == 200
body = resp.text
# Pager-ul exista si linkurile contin status=needs_data
assert "status=needs_data" in body, (
"Linkurile de paginare trebuie sa pastreze filtrul status=needs_data"
)
def test_pagina_peste_total_revine_la_ultima(client):
"""page=99 cu 30 trimiteri se clampeaza la ultima pagina (page 2 → 5 randuri)."""
acct = _create_account_user("pg5@test.com")
_ins_n(acct, 30)
_login(client, "pg5@test.com")
resp = client.get("/_fragments/submissions?page=99")
assert resp.status_code == 200
body = resp.text
row_count = body.count('id="trimitere-row-')
assert row_count == 5, (
f"page=99 cu 30 trimiteri trebuie clamped la ultima pagina (5 randuri), nu {row_count}"
)
def test_poll_pastreaza_pagina(client):
"""Raspunsul de la page=2 include id='f-page' value='2' (OOB swap) pentru poll.
Mecanismul: _submissions.html include un element cu id='f-page' si hx-swap-oob='true'
care actualizeaza inputul ascuns din #filtre-trimiteri. Poll-ul de 15s (hx-include=
'#filtre-trimiteri') include astfel pagina curenta la urmatoarea iteratie (L2 PRD).
"""
acct = _create_account_user("pg6@test.com")
_ins_n(acct, 30)
_login(client, "pg6@test.com")
resp = client.get("/_fragments/submissions?page=2")
assert resp.status_code == 200
body = resp.text
# Elementul OOB trebuie sa fie in raspuns cu valoarea corecta
assert 'id="f-page"' in body, (
"Raspunsul trebuie sa includa id='f-page' (OOB swap) pentru ca poll-ul sa pastreze pagina"
)
assert 'value="2"' in body, (
"Elementul f-page trebuie sa aiba value='2' cand page=2"
)

View File

@@ -0,0 +1,172 @@
"""Teste US-003 (PRD 5.10): pill-uri de filtrare per categorie in bara de status.
Cazuri:
- pill_per_categorie_cu_numar: pill-uri <button> cu numarul corect per categorie
- pill_click_seteaza_status: pill-urile au atributele HTMX corecte (status=X, aria-pressed)
- fara_lista_id_uri: lista de ID-uri/VIN-uri nu mai apare in bara de status
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _ins(acct: int, *, status: str, vin: str = "WVIN000000000001", nr: str = "B001") -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(
f"k-{os.urandom(5).hex()}", acct, status,
json.dumps({"vin": vin, "nr_inmatriculare": nr, "data_prestatie": "2026-06-20",
"odometru_final": "100", "prestatii": [{"cod_prestatie": "R-X"}]}),
),
)
conn.commit()
return cur.lastrowid # type: ignore[return-value]
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "pill.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_pill_per_categorie_cu_numar(client):
"""Bara de status afiseaza pill-uri <button> cu numarul corect per categorie blocata."""
acct = _create_account_user("pill1@test.com")
# 2x needs_data, 1x needs_mapping, 1x error, 1x sent (fara pill)
_ins(acct, status="needs_data", vin="WVIN_ND1_001", nr="BND1")
_ins(acct, status="needs_data", vin="WVIN_ND1_002", nr="BND2")
_ins(acct, status="needs_mapping", vin="WVIN_NM1_001", nr="BNM1")
_ins(acct, status="error", vin="WVIN_ER1_001", nr="BER1")
_ins(acct, status="sent", vin="WVIN_SE1_001", nr="BSE1")
_login(client, "pill1@test.com")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
body = resp.text
# Pill-urile sunt elemente <button> (nu <span onclick>)
assert "<button" in body, "Pill-urile trebuie sa fie elemente <button>"
# Fiecare categorie problemativa apare ca pill
assert "needs_data" in body, "Pill needs_data trebuie sa apara"
assert "needs_mapping" in body, "Pill needs_mapping trebuie sa apara"
assert "error" in body, "Pill error trebuie sa apara (hx-get sau text)"
# Contoarele sunt afisate in pill-uri
assert ">2<" in body or "2<" in body, "Contorul 2 pt needs_data trebuie vizibil in pill"
# Starea 'sent' nu produce pill (nu e categorie de problema)
# (nu exista un pill cu status=sent in bara de status)
pill_sent_count = body.count("status=sent")
assert pill_sent_count == 0, "Nu trebuie pill pentru sent in bara de status"
def test_pill_click_seteaza_status(client):
"""Pill-urile au atributele HTMX corecte: hx-get cu status=X si aria-pressed."""
acct = _create_account_user("pill2@test.com")
_ins(acct, status="needs_data", vin="WVIN_ND2_001", nr="BND_P2a")
_ins(acct, status="needs_mapping", vin="WVIN_NM2_001", nr="BNM_P2a")
_ins(acct, status="error", vin="WVIN_ER2_001", nr="BER_P2a")
_login(client, "pill2@test.com")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
body = resp.text
# Fiecare pill are atribut hx-get cu parametrul status corespunzator
assert "status=needs_data" in body, "Pill needs_data trebuie sa aiba ?status=needs_data in hx-get"
assert "status=needs_mapping" in body, "Pill needs_mapping trebuie sa aiba ?status=needs_mapping in hx-get"
assert "status=error" in body, "Pill error trebuie sa aiba ?status=error in hx-get"
# Pill-urile au aria-pressed pentru accesibilitate (WCAG)
assert "aria-pressed" in body, "Pill-urile trebuie sa aiba atribut aria-pressed"
# Target-ul este tabelul de trimiteri
assert "submissions-wrap" in body or "_fragments/submissions" in body, (
"Pill-urile trebuie sa targeteze #submissions-wrap sau sa apeleze /_fragments/submissions"
)
def test_fara_lista_id_uri(client):
"""Lista de ID-uri/VIN-uri (ex. #1 WVIN... / B...) nu mai apare in bara de status."""
acct = _create_account_user("pill3@test.com")
sid = _ins(acct, status="needs_data", vin="WVIN_ND3_UNIC001", nr="BND_P3")
_login(client, "pill3@test.com")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
body = resp.text
# Structura <li class="muted"> cu randuri blocate a disparut
assert '<li class="muted"' not in body, (
"Structura <li class='muted'> cu randuri blocate trebuie eliminata din bara de status"
)
# VIN-ul complet nu mai apare in bara de status (era partial, dar nici partial nu mai vrem)
assert "WVIN_ND3_UNIC001" not in body, (
"VIN-ul (sau partial) nu trebuie sa mai apara in bara de status"
)
def test_pill_needs_mapping_culoare_warn(client):
"""Pill-ul 'Lipsa cod' (needs_mapping) foloseste --warn (chihlimbar), nu --err (DESIGN.md).
DESIGN.md §Componente: 'Lipsa cod = --warn'. Celelalte categorii (needs_data, error) = --err.
"""
acct = _create_account_user("warn@test.com")
# Inseram DOAR needs_mapping — pentru a izola culoarea si a nu confunda cu --err
_ins(acct, status="needs_mapping", vin="WVIN_NM_WARN0001", nr="BNMW1")
_login(client, "warn@test.com")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
body = resp.text
# Pill-ul needs_mapping trebuie sa aiba culoarea --warn (nu --err)
assert "var(--warn)" in body, (
"Pill needs_mapping trebuie sa foloseasca var(--warn) conform DESIGN.md §Componente"
)
assert "status=needs_mapping" in body, "Pill needs_mapping trebuie sa fie prezent in bara"

View File

@@ -0,0 +1,163 @@
"""Teste US-014 (PRD 5.10): Selector de tema ciclic Light/Dark/Petrol/Auto.
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
Testeaza:
- test_petrol_theme_definit: [data-theme="petrol"] definit cu valorile din DESIGN.md
- test_buton_cicleaza_temele: buton ciclic + anti-FOUC extins + aria-live
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "selector.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _get_style(html: str) -> str:
m = re.search(r"<style>(.*?)</style>", html, re.DOTALL)
assert m, "<style> negasit in HTML"
return m.group(1)
def _get_head(html: str) -> str:
m = re.search(r"<head>(.*?)</head>", html, re.DOTALL | re.IGNORECASE)
assert m, "<head> negasit in HTML"
return m.group(1)
# ── test_petrol_theme_definit ─────────────────────────────────────────────────
def test_petrol_theme_definit(client):
"""[data-theme="petrol"] definit in CSS cu valorile din DESIGN.md:
--bg:#0e1416, --card:#161e20, --ink:#e6e9ef, --muted:#8b93a7,
--line:#232c2e, --accent:#0E7C7B, --ok:#2FBF8F, --warn:#E0A93B, --err:#E05D5D
"""
resp = client.get("/login")
assert resp.status_code == 200
style = _get_style(resp.text)
# Bloc CSS [data-theme="petrol"] prezent
petrol_m = re.search(
r'\[data-theme=["\']petrol["\']\]\s*\{([^}]+)\}',
style,
re.DOTALL,
)
assert petrol_m, (
'[data-theme="petrol"] { ... } negasit in <style>. '
"Tema Petrol trebuie definita conform DESIGN.md."
)
petrol_block = petrol_m.group(1)
petrol_vars = {
"--accent": "#0E7C7B",
"--bg": "#0e1416",
"--card": "#161e20",
"--ink": "#e6e9ef",
"--line": "#232c2e",
"--ok": "#2FBF8F",
"--warn": "#E0A93B",
"--err": "#E05D5D",
}
for var, val in petrol_vars.items():
assert val.lower() in petrol_block.lower(), (
f"Variabila {var}:{val} lipseste din [data-theme=\"petrol\"]. "
f"Block petrol: {petrol_block.strip()}"
)
# ── test_buton_cicleaza_temele ────────────────────────────────────────────────
def test_buton_cicleaza_temele(client):
"""Butonul de tema cicleaza Light->Dark->Petrol->Auto si are accesibilitate completa.
Verifica:
1. Anti-FOUC extins: cunoaste 'petrol' si 'auto'; fallback definit pt. valori legacy
2. JS-ul ciclului contine toate cele 4 teme in ordinea corecta
3. aria-label pe buton include 'Tema:' + tema curenta + urmatoarea
4. Regiune aria-live="polite" prezenta pentru anuntarea schimbarii
"""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
head = _get_head(html)
# 1. Anti-FOUC cunoaste 'petrol' si 'auto' (script in <head>, inainte de <style>)
style_pos = head.find('<style>')
assert style_pos >= 0, "<style> negasit in <head>"
head_before_style = head[:style_pos]
assert 'petrol' in head_before_style, (
"Scriptul anti-FOUC nu cunoaste tema 'petrol'. "
"Trebuie extins sa enumere toate cele 4 teme (light/dark/petrol/auto)."
)
assert 'auto' in head_before_style, (
"Scriptul anti-FOUC nu cunoaste tema 'auto'. "
"Trebuie sa rezolve 'auto' la light/dark inainte de primul paint."
)
# 2. Anti-FOUC are fallback pentru valori legacy/necunoscute (un set de valide)
# Acceptam: un obiect/array VALID, sau un if care verifica valorile cunoscute
has_valid_guard = (
'VALID' in head_before_style
or re.search(r'light.*dark.*petrol.*auto', head_before_style, re.DOTALL)
or 'indexOf' in head_before_style
)
assert has_valid_guard, (
"Anti-FOUC lipseste de un guard pentru valori legacy/necunoscute. "
"O valoare 'localStorage.theme' necunoscuta trebuie sa cada pe 'auto' sau 'dark'."
)
# 3. JS-ul din <body> contine ciclul complet Light->Dark->Petrol->Auto
# Cautam in tot HTML-ul (nu doar head) prezenta tuturor celor 4 teme in JS
# Acceptam: array explicit ['light','dark','petrol','auto'] sau logic echivalent
cycle_match = re.search(
r"['\"]light['\"].*['\"]dark['\"].*['\"]petrol['\"].*['\"]auto['\"]",
html,
re.DOTALL,
)
assert cycle_match, (
"Ciclul Light->Dark->Petrol->Auto negasit in JS. "
"Asteptat: array sau secventa cu toate cele 4 teme in ordine."
)
# 4. aria-label pe butonul tema-toggle include 'Tema:' (format 'Tema: X, apasa pentru Y')
tema_btn = re.search(
r'<button[^>]+id=["\']tema-toggle["\'][^>]*>',
html,
re.IGNORECASE,
)
assert tema_btn, "Butonul #tema-toggle negasit in HTML"
btn_tag = tema_btn.group(0)
# Acceptam aria-label setat initial in HTML SAU prin JS (aria-label din tag poate fi
# placeholder; testam ca JS-ul contine formatul corect)
has_tema_label = (
'Tema:' in html
or 'tema:' in html.lower()
or re.search(r'aria-label[^>]*[Tt]ema', html)
)
assert has_tema_label, (
"aria-label cu formatul 'Tema: ...' negasit in HTML/JS. "
"Butonul trebuie sa anunte tema curenta + urmatoarea."
)
# 5. Regiune aria-live="polite" prezenta (pentru anuntarea schimbarii de tema)
assert 'aria-live="polite"' in html or "aria-live='polite'" in html, (
'Regiune aria-live="polite" negasita in HTML. '
"Necesara pentru a anunta schimbarea temei catre screen-readers."
)

View File

@@ -137,24 +137,23 @@ def test_status_blocate_defalcare(client):
assert resp.status_code == 200
html = resp.text
# Trebuie sa arate titlul grupului de blocate
assert "Necesita atentia ta" in html, (
f"Fragmentul nu contine 'Necesita atentia ta'. HTML: {html[:800]}"
# US-003 (PRD 5.10): Blocatele apar ca pill-uri (nu ca lista cu ID-uri)
assert "Necesita atentie" in html, (
f"Fragmentul nu contine sectiunea 'Necesita atentie'. HTML: {html[:800]}"
)
# Trebuie sa arate etichetele umane pe motiv (din STARI_SUBMISSION in labels.py)
assert "Lipseste codul prestatiei" in html, (
"Fragmentul nu arata eticheta pentru needs_mapping"
# Pill-urile au etichetele scurte per categorie (nu etichetele lungi din eticheta_stare)
assert "Lipsa cod" in html, (
"Fragmentul nu arata pill-ul pentru needs_mapping"
)
assert "Date incomplete" in html, (
"Fragmentul nu arata eticheta pentru needs_data"
"Fragmentul nu arata pill-ul pentru needs_data"
)
assert "Eroare la trimitere" in html, (
"Fragmentul nu arata eticheta pentru error"
assert "Eroare" in html, (
"Fragmentul nu arata pill-ul pentru error"
)
# Trebuie sa arate numere concrete (2 needs_mapping, 1 needs_data, 1 error)
# Verificam ca exista cel putin un numar > 0 langa fiecare eticheta
# (nu strict format, ci prezenta datelor)
assert "2" in html or "1" in html, "Fragmentul nu arata numarul de submissions blocate"
# Pill-urile arata numarul total per categorie (2 needs_mapping, 1 needs_data, 1 error)
assert "2" in html, "Pill-ul needs_mapping trebuie sa arate numarul 2"
assert "<button" in html, "Pill-urile trebuie sa fie elemente <button>"
# ============================================================
@@ -208,26 +207,33 @@ def _insert_submission_vehicul(status, account_id, vin, nr):
def test_categorie_blocata_linkeaza_la_trimiteri_filtrate(client):
"""US-003 (PRD 5.10): pill-ul error are hx-get cu ?status=error.
Deep-link-ul tab=acasa&status=error a fost eliminat (pill inlocuieste link-ul vechi)."""
acct_id, _ = _create_account_user("link@test.com", "parolasecreta10")
_login(client, "link@test.com", "parolasecreta10")
_insert_submission("error", acct_id)
html = client.get("/_fragments/status").text
# Link HTMX catre lista filtrata pe error + deep-link server-side
# Pill-ul are hx-get cu status=error (filtrare directa submissions)
assert "/_fragments/submissions?status=error" in html
assert "tab=acasa&status=error" in html
# Deep-link-ul tab=acasa&status=error nu mai exista — pill-uri inlocuiesc link-urile
assert "tab=acasa&status=error" not in html
def test_status_arata_identificator_rand_blocat(client):
def test_status_nu_arata_identificator_rand_blocat(client):
"""US-003 (PRD 5.10): VIN/nr inmatriculare nu mai apar in bara de status.
Lista de ID-uri a fost inlocuita cu pill-uri cu numar total (fara PII nominal)."""
acct_id, _ = _create_account_user("ident@test.com", "parolasecreta10")
_login(client, "ident@test.com", "parolasecreta10")
_insert_submission_vehicul("error", acct_id, "WVWZZZ1KZAW000123", "B123ABC")
html = client.get("/_fragments/status").text
# VIN partial (ultimele 4) + nr inmatriculare + #id
assert "0123" in html, "lipseste VIN partial"
assert "B123ABC" in html, "lipseste nr inmatriculare"
# Bara de status arata pill cu count, nu lista cu VIN/nr per rand
assert "B123ABC" not in html, "Nr inmatriculare nu trebuie sa mai apara in bara de status"
assert "WVWZZZ1KZAW000123" not in html, "VIN integral nu trebuie expus"
assert "0123" not in html, "VIN partial nu trebuie sa mai apara in bara de status"
# Pill-ul cu count 1 apare in locul listei
assert "status=error" in html, "Pill error trebuie sa aiba hx-get cu status=error"
def test_scoped_pe_cont(client):

View File

@@ -0,0 +1,151 @@
"""Teste US-005 (PRD 5.10): VIN pe rand separat sub numarul de inmatriculare.
VIN-ul era randat ca <span> inline in aceeasi celula cu nr. Story-ul cere un
element block-level (div/small/p cu display:block) sub nr, in stil muted.
Testul asserteaza tipul elementului (block), nu doar prezenta textului.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
)
assert resp.status_code == 303
def _ins(acct: int, *, vin: str = "", nr: str = "B01TST", status: str = "queued") -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(
f"k-{os.urandom(5).hex()}", acct, status,
json.dumps({
"vin": vin,
"nr_inmatriculare": nr,
"data_prestatie": "2026-06-20",
"odometru_final": "100",
"prestatii": [{"cod_prestatie": "R-X"}],
}),
),
)
conn.commit()
return cur.lastrowid # type: ignore[return-value]
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "layout_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_vin_pe_rand_separat_sub_nr(client):
"""VIN-ul apare intr-un element block-level (div/p/small cu display:block) sub nr.
Inainte: <span class="muted">...VIN...</span> inline.
Dupa: <div class="muted">...VIN...</div> (block, rand separat).
Testul asserteaza prezenta unui element block, nu doar textul.
"""
acct = _create_account_user("vin_layout@test.com")
sid = _ins(acct, vin="WVWZZZ1JZXW000001", nr="B123XYZ")
_login(client, "vin_layout@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# VIN trunchiat trebuie sa apara in HTML
assert "000001" in html, "VIN-ul trunchiat trebuie sa apara in tabel"
# Elementul ce contine VIN-ul trebuie sa fie block-level (div, p, small etc.)
# NU un simplu <span> inline.
# Pattern: <div ... >...000001...</div> sau <p ... >...000001...</p>
# Acceptam orice block-level tag (div/p/small) care contine fragmentul VIN.
block_tags = ["div", "p", "small"]
vin_fragment = "000001"
found_block = any(
re.search(
rf"<{tag}[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</{tag}>",
html,
)
for tag in block_tags
)
assert found_block, (
f"VIN '{vin_fragment}' trebuie sa fie intr-un element block-level "
f"(div/p/small), nu intr-un <span> inline. HTML gasit: "
+ html[max(0, html.find(vin_fragment) - 80):html.find(vin_fragment) + 80]
)
# Elementul block trebuie sa aiba clasa 'muted' (stil discret)
muted_block = any(
re.search(
rf'<{tag}[^>]*class="[^"]*muted[^"]*"[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</{tag}>',
html,
)
for tag in block_tags
)
assert muted_block, (
f"Elementul block cu VIN trebuie sa aiba clasa 'muted'"
)
def test_vin_lipsa_nu_genereaza_rand_gol(client):
"""Cand VIN-ul lipseste (sau e EMPTY=''), nu apare un element gol in celula Vehicul."""
acct = _create_account_user("vin_gol@test.com")
sid = _ins(acct, vin="", nr="B999TST") # VIN gol -> EMPTY="—"
_login(client, "vin_gol@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Randul trebuie sa existe
assert f'id="trimitere-row-{sid}"' in html
# In coloana vehicul nu trebuie sa apara un element block gol cu "—"
# (garda != '—' exista deja, verifica ca e respectata)
assert 'class="muted"' not in html.split('col-vehicul')[1].split('col-operatie')[0] or \
'' not in (html.split('col-vehicul')[1].split('col-operatie')[0]), \
"Elementul muted din coloana Vehicul nu trebuie sa contina '' (rand gol VIN)"

View File

@@ -73,7 +73,7 @@ def client(monkeypatch):
# ============================================================
def test_dashboard_are_tabbar(client):
"""US-007 (5.5): tab-bar redus la Acasa + Mapari; Cont/Integrare/Nomenclator in meniul ☰."""
"""US-009 (5.10): tab-bar-ul eliminat; Mapari mutat in meniul ☰; rutele raman valide."""
_create_account_user("tabbar@test.com", "parolasecreta10")
_login(client, "tabbar@test.com", "parolasecreta10")
@@ -81,16 +81,15 @@ def test_dashboard_are_tabbar(client):
assert resp.status_code == 200
html = resp.text
assert 'role="tablist"' in html, "Lipseste role=tablist"
# Doar Acasa + Mapari sunt tab-uri (role="tab")
assert re.search(r'role="tab"[^>]*>\s*Acasa', html), "Lipseste tab-ul Acasa"
assert re.search(r'role="tab"[^>]*>\s*Mapari', html), "Lipseste tab-ul Mapari"
# Cont/Integrare/Nomenclator NU mai sunt tab-uri
# US-009: tab-bar-ul (role="tablist") a fost eliminat
assert 'role="tablist"' not in html, "Tab-bar-ul (role=tablist) trebuie eliminat (US-009)"
# Cont/Integrare/Nomenclator raman in meniu, nu ca tab-uri
for label in ("Cont", "Integrare", "Nomenclator", "Import"):
assert not re.search(rf'role="tab"[^>]*>\s*{label}\s*<', html), \
f"'{label}' nu ar mai trebui sa fie un tab separat (mutat in meniu)"
# ...dar traiesc in meniul de cont
f"'{label}' nu ar mai trebui sa fie un tab separat"
# Mapari e acum in meniu (nu tab), cu link valid
assert 'href="/?tab=mapari"' in html, "Lipseste link Mapari din meniu"
# Cont/Nomenclator raman in meniu
assert 'href="/?tab=cont"' in html and 'href="/?tab=nomenclator"' in html
@@ -99,7 +98,7 @@ def test_dashboard_are_tabbar(client):
# ============================================================
def test_tab_implicit_acasa(client):
"""Fara ?tab=, tab-ul Acasa are aria-selected=true."""
"""US-009: fara ?tab=, pagina principala randeaza continutul Acasa (upload + sectiuni)."""
_create_account_user("implicit@test.com", "parolasecreta10")
_login(client, "implicit@test.com", "parolasecreta10")
@@ -107,13 +106,11 @@ def test_tab_implicit_acasa(client):
assert resp.status_code == 200
html = resp.text
# Tab-ul activ trebuie sa aiba aria-selected="true"
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
# Verificam ca Acasa e cel cu aria-selected=true
# Cautam un fragment care contine atat Acasa cat si aria-selected="true" in proximitate
assert re.search(r'aria-selected="true"[^>]*>.*?Acasa|Acasa.*?aria-selected="true"', html, re.DOTALL), \
"Tab-ul Acasa nu are aria-selected=true"
# US-009: tab-bar eliminat, deci nu mai exista aria-selected pe tab-uri
assert 'role="tablist"' not in html, "Tab-bar-ul trebuie eliminat (US-009)"
# Continutul Acasa (status-bar + tab-panel cu continut Acasa) e randat direct
assert 'id="status-bar"' in html, "Status-bar-ul trebuie sa fie prezent"
assert 'id="tab-panel"' in html, "Panoul de continut (tab-panel) trebuie sa fie prezent"
# ============================================================
@@ -140,23 +137,22 @@ def test_deeplink_tab_import(client):
# ============================================================
def test_tab_activ_randat_server_side(client):
"""Panoul activ e in HTML-ul initial, nu doar cerut prin HTMX dupa load."""
"""Panoul activ e in HTML-ul initial, randat server-side (nu doar HTMX dupa load)."""
_create_account_user("serverside@test.com", "parolasecreta10")
_login(client, "serverside@test.com", "parolasecreta10")
# Tab-ul implicit (Acasa) trebuie sa fie randat server-side
# Acasa e randat server-side
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# Panoul trebuie sa aiba role="tabpanel"
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel in HTML initial"
# US-009: role="tabpanel" eliminat; continutul e in div#tab-panel fara rol ARIA de tabpanel
assert 'id="tab-panel"' in html, "Containerul de continut tab-panel trebuie sa existe"
assert 'role="tabpanel"' not in html, "role=tabpanel trebuie eliminat (US-009)"
# Import tab server-side
# Import tab server-side: ?tab=import randeaza direct continutul Import
resp2 = client.get("/?tab=import")
assert resp2.status_code == 200
html2 = resp2.text
# Continutul Import trebuie sa fie randat direct, nu prin hx-trigger=load pe panoul inactiv
assert 'id="import-section"' in html2, "Panoul Import nu e randat server-side la ?tab=import"
@@ -205,7 +201,7 @@ def test_fragmentele_inactive_lazy(client):
# ============================================================
def test_tabbar_aria(client):
"""Prezenta atributelor ARIA: role=tablist/tab/tabpanel, aria-selected."""
"""US-009: schela ARIA orfana (role=tablist/tab/tabpanel/aria-selected) a fost eliminata."""
_create_account_user("aria@test.com", "parolasecreta10")
_login(client, "aria@test.com", "parolasecreta10")
@@ -213,11 +209,14 @@ def test_tabbar_aria(client):
assert resp.status_code == 200
html = resp.text
assert 'role="tablist"' in html, "Lipseste role=tablist"
assert 'role="tab"' in html, "Lipseste role=tab"
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel"
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
assert 'aria-selected="false"' in html, "Lipseste aria-selected=false pe tab-urile inactive"
# US-009: un role="tablist" cu un singur tab e violare ARIA → eliminat
assert 'role="tablist"' not in html, "role=tablist trebuie eliminat (US-009)"
assert 'role="tab"' not in html, "role=tab trebuie eliminat (tab-bar eliminat)"
assert 'role="tabpanel"' not in html, "role=tabpanel trebuie eliminat (tab-bar eliminat)"
assert 'aria-selected=' not in html, "aria-selected trebuie eliminat (fara tab-uri)"
# Meniu cont (role="menu") si item-urile sale (role="menuitem") raman valide
assert 'role="menu"' in html, "Meniul hamburger (role=menu) trebuie pastrat"
assert 'role="menuitem"' in html, "Intrarile meniului (role=menuitem) trebuie pastrate"
# ============================================================

View File

@@ -0,0 +1,256 @@
"""Teste US-013 (PRD 5.10): Tema de culori ROMFAST (accent azur) + tipografie IBM Plex.
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
Testeaza:
- test_paleta_accent_azur_definita: accentul azur ROMFAST definit corect in :root si [data-theme="light"]
- test_font_ibm_plex_aplicat: IBM Plex Sans + Mono declarate in font-family si @font-face
- test_contrast_aa_pe_text_principal: contrast text principal >= 4.5:1 in dark si light
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "culori.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _get_style_block(html: str) -> str:
"""Extrage continutul primului <style> din HTML."""
m = re.search(r"<style>(.*?)</style>", html, re.DOTALL)
assert m, "<style> negasit in HTML"
return m.group(1)
def _hex_to_srgb(hex_color: str) -> tuple[float, float, float]:
"""Converteste hex (#rrggbb) la tuple (r, g, b) in [0,1]."""
h = hex_color.lstrip("#")
assert len(h) == 6, f"Hex invalid: {hex_color}"
r = int(h[0:2], 16) / 255.0
g = int(h[2:4], 16) / 255.0
b = int(h[4:6], 16) / 255.0
return r, g, b
def _linearize(c: float) -> float:
"""Liniarizeaza o componenta sRGB pentru calcul luminanta WCAG."""
if c <= 0.04045:
return c / 12.92
return ((c + 0.055) / 1.055) ** 2.4
def _luminance(hex_color: str) -> float:
"""Calculeaza luminanta relativa WCAG 2.1 pentru o culoare hex."""
r, g, b = _hex_to_srgb(hex_color)
rl = _linearize(r)
gl = _linearize(g)
bl = _linearize(b)
return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl
def _contrast_ratio(c1: str, c2: str) -> float:
"""Calculeaza raportul de contrast WCAG 2.1 intre doua culori hex."""
l1 = _luminance(c1)
l2 = _luminance(c2)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
# ── test_paleta_accent_azur_definita ─────────────────────────────────────────
def test_paleta_accent_azur_definita(client):
"""Accentul azur ROMFAST definit corect si neutrele actualizate conform DESIGN.md.
:root (dark default):
--accent:#2E74D6
--bg:#0f1218 --card:#181c24 --ink:#e6e9ef --muted:#8b93a7 --line:#262b36
--ok:#2FBF8F --warn:#E0A93B --err:#E05D5D
[data-theme="light"]:
--accent:#1F66C9
--bg:#f5f7fa --card:#ffffff --ink:#1a1d24 --muted:#5c6473 --line:#e2e5ea
--ok:#15803d --warn:#b45309 --err:#dc2626
"""
resp = client.get("/login")
assert resp.status_code == 200
style = _get_style_block(resp.text)
# Paleta dark (:root)
dark_vars = {
"--accent": "#2E74D6",
"--bg": "#0f1218",
"--card": "#181c24",
"--ink": "#e6e9ef",
"--muted": "#8b93a7",
"--line": "#262b36",
"--ok": "#2FBF8F",
"--warn": "#E0A93B",
"--err": "#E05D5D",
}
# Extrage blocul :root
root_m = re.search(r":root\s*\{([^}]+)\}", style, re.DOTALL)
assert root_m, "Blocul :root negasit in <style>"
root_block = root_m.group(1)
for var, val in dark_vars.items():
assert val.lower() in root_block.lower(), (
f"Variabila {var}:{val} lipseste sau are valoare gresita in :root (dark). "
f"Continut :root: {root_block.strip()}"
)
# Paleta light ([data-theme="light"])
light_vars = {
"--accent": "#1F66C9",
"--bg": "#f5f7fa",
"--card": "#ffffff",
"--ink": "#1a1d24",
"--muted": "#5c6473",
"--line": "#e2e5ea",
"--ok": "#15803d",
"--warn": "#b45309",
"--err": "#dc2626",
}
light_m = re.search(r'\[data-theme=["\']light["\']\]\s*\{([^}]+)\}', style, re.DOTALL)
assert light_m, 'Blocul [data-theme="light"] negasit in <style>'
light_block = light_m.group(1)
for var, val in light_vars.items():
assert val.lower() in light_block.lower(), (
f"Variabila {var}:{val} lipseste sau are valoare gresita in [data-theme=\"light\"]. "
f"Continut light: {light_block.strip()}"
)
# ── test_font_ibm_plex_aplicat ────────────────────────────────────────────────
def test_font_ibm_plex_aplicat(client):
"""IBM Plex Sans si IBM Plex Mono declarate in font-family si @font-face cu font-display:swap.
Verifica:
- body font-family contine 'IBM Plex Sans' (sau alias ibm-plex-sans)
- exista cel putin un @font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono'
- @font-face include font-display:swap
- @font-face pointeaza spre /static/fonts/
"""
resp = client.get("/login")
assert resp.status_code == 200
style = _get_style_block(resp.text)
# 1. body font-family contine IBM Plex Sans
body_m = re.search(r"body\s*\{([^}]+)\}", style, re.DOTALL)
assert body_m, "Regula 'body { ... }' negasita in <style>"
body_block = body_m.group(1)
assert "IBM Plex Sans" in body_block or "ibm-plex-sans" in body_block.lower(), (
f"'IBM Plex Sans' lipseste din font-family al body. body block: {body_block.strip()}"
)
# 2. Exista cel putin un @font-face cu IBM Plex
font_face_blocks = re.findall(r"@font-face\s*\{([^}]+)\}", style, re.DOTALL)
assert font_face_blocks, "@font-face negasit in <style>"
ibm_face = [b for b in font_face_blocks if "IBM Plex" in b or "ibm-plex" in b.lower()]
assert ibm_face, (
"@font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono' negasit. "
f"Blocuri @font-face gasite: {font_face_blocks}"
)
# 3. font-display:swap prezent in cel putin un bloc IBM Plex @font-face
swap_present = any("swap" in b.lower() for b in ibm_face)
assert swap_present, (
"font-display:swap lipseste din @font-face IBM Plex. "
f"Blocuri @font-face IBM Plex: {ibm_face}"
)
# 4. @font-face pointeaza spre /static/fonts/
fonts_src = any("/static/fonts/" in b for b in ibm_face)
assert fonts_src, (
"@font-face IBM Plex nu pointeaza spre /static/fonts/. "
f"Blocuri: {ibm_face}"
)
# 5. IBM Plex Mono pentru monospace: exista un context monospace cu IBM Plex Mono
# (fie @font-face, fie o regula font-family cu monospace)
has_mono = any("IBM Plex Mono" in b or "ibm-plex-mono" in b.lower() for b in font_face_blocks)
if not has_mono:
# Acceptam si daca e in o regula CSS (nu neaparat @font-face)
has_mono = "IBM Plex Mono" in style
assert has_mono, (
"'IBM Plex Mono' lipseste din <style> (trebuie pentru coduri RAR/VIN/nr)."
)
# ── test_contrast_aa_pe_text_principal ───────────────────────────────────────
def test_contrast_aa_pe_text_principal(client):
"""Contrastul text principal este >= 4.5:1 in dark si light (WCAG 2.1 AA).
Dark: --ink:#e6e9ef pe --bg:#0f1218
Light: --ink:#1a1d24 pe --bg:#f5f7fa
Accent ca text pe alb: #1F66C9 pe #ffffff (WCAG AA pentru text normal)
"""
resp = client.get("/login")
assert resp.status_code == 200
style = _get_style_block(resp.text)
# Extrage valorile de culoare din :root si [data-theme="light"]
def _extract_var(block: str, var_name: str) -> str | None:
m = re.search(
re.escape(var_name) + r"\s*:\s*(#[0-9a-fA-F]{6})",
block,
re.IGNORECASE,
)
return m.group(1) if m else None
root_m = re.search(r":root\s*\{([^}]+)\}", style, re.DOTALL)
assert root_m, "Blocul :root negasit"
root_block = root_m.group(1)
light_m = re.search(r'\[data-theme=["\']light["\']\]\s*\{([^}]+)\}', style, re.DOTALL)
assert light_m, 'Blocul [data-theme="light"] negasit'
light_block = light_m.group(1)
# --- Dark: ink pe bg ---
dark_ink = _extract_var(root_block, "--ink")
dark_bg = _extract_var(root_block, "--bg")
assert dark_ink and dark_bg, (
f"Nu am putut extrage --ink/{dark_ink} sau --bg/{dark_bg} din :root"
)
cr_dark = _contrast_ratio(dark_ink, dark_bg)
assert cr_dark >= 4.5, (
f"Contrast dark insuficient: {dark_ink} pe {dark_bg} = {cr_dark:.2f}:1 (minim 4.5:1 AA)"
)
# --- Light: ink pe bg ---
light_ink = _extract_var(light_block, "--ink")
light_bg = _extract_var(light_block, "--bg")
assert light_ink and light_bg, (
f"Nu am putut extrage --ink/{light_ink} sau --bg/{light_bg} din [data-theme=light]"
)
cr_light = _contrast_ratio(light_ink, light_bg)
assert cr_light >= 4.5, (
f"Contrast light insuficient: {light_ink} pe {light_bg} = {cr_light:.2f}:1 (minim 4.5:1 AA)"
)
# --- Accent ca text pe alb (tema light) ---
light_accent = _extract_var(light_block, "--accent")
assert light_accent, f"--accent negasit in [data-theme=light]: {light_block.strip()}"
cr_accent_white = _contrast_ratio(light_accent, "#ffffff")
assert cr_accent_white >= 4.5, (
f"Accent light ({light_accent}) pe alb: contrast {cr_accent_white:.2f}:1 < 4.5:1 AA. "
f"Foloseste o varianta mai inchisa (ex. #1F66C9)."
)

View File

@@ -153,9 +153,8 @@ def test_mapari_ajutor_disclosure_si_fara_proza_inline(client):
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
# panou Ajutor (<details>) prezent
assert "ajutor-mapari" in html
assert "<details" in html and ">Ajutor<" in html
# US-010: sectiunea de ajutor (<details class="ajutor-mapari">) eliminata
assert "ajutor-mapari" not in html
# antet de coloana compact
assert ">In coada<" in html
# proza inline veche eliminata de pe sectiuni