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