feat(5.15+5.14): CLOSE — fix-uri code-review + embeddings functional

5.15 (propagare design + dashboard editare) si 5.14 (mapare LLM distilata)
inchise dupa /code-review high. 8 buguri reparate TDD:

- HIGH modal nu se deschidea pe randul slim (base.html: trimitere-slim)
- HIGH /repune trunchia prestatii (declaratie incompleta la RAR) -> iterare
  peste existing, codes pozitional
- HIGH embeddings incarca model ~230MB degeaba pe corpus gol -> poarta has_corpus()
- HIGH picker chips gol pe re-render eroare -> conn/account_id pe toate ramurile
- MED obs re-derivat dupa stergere explicita -> _merge_override pastreaza obs=''
- MED mapare salvata fara denumire poluă GOLD -> _record_gold_validation guard
- MED typo nome_prestatie -> nume_prestatie in select /repune
- MED bucketare timp +3h gresita iarna -> SQLite localtime + TZ=Europe/Bucharest

Embeddings WIRE-uit functional (PRD #15, decizie user): ensure_embeddings_corpus
construieste corpus din nomenclator, gated pe AUTOPASS_EMBEDDINGS_ENABLED (default
off). Marime model corectata ~50MB->~230MB (estimare PRD gresita).

Cleanup: hoist load_* din bucla bulk-fix; import re la top.
Regresie: 1256 passed, 1 deselected (live), 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-28 20:48:34 +00:00
parent 9e42e7ed6f
commit 3fc53534e2
53 changed files with 9684 additions and 384 deletions

View File

@@ -14,10 +14,34 @@ variabila exportata explicit in shell. Testele care chiar verifica enforcement-u
import hashlib
import os
import pytest
os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false")
os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
@pytest.fixture(autouse=True)
def _reset_embeddings_singleton():
"""Reseteaza singleton-ul global de embeddings intre teste (izolare de ordine).
`enrich_suggestions` foloseste `embeddings.has_corpus()` ca poarta; un test care
indexeaza corpusul pe singleton-ul global (ex. test_module_level_index_corpus)
altfel l-ar lasa populat -> teste ulterioare care cheama pending_unmapped ar primi
sugestii embedding spurioase. Resetam la None inainte si dupa fiecare test.
"""
try:
import app.embeddings as _emb
_emb._engine = None
except Exception:
pass
yield
try:
import app.embeddings as _emb
_emb._engine = None
except Exception:
pass
def make_test_cui(seed: str = "") -> str:
"""Factory centralizat (D#14, PRD 5.12 US-001): genereaza un CUI de test unic din seed.

226
tests/test_api_scope.py Normal file
View File

@@ -0,0 +1,226 @@
"""US-011 (PRD 5.15): account-scope pe GET-urile de listare API (securitate).
Verifica:
- GET /v1/prezentari: un cont nu vede submissions ale altui cont
- GET /v1/prezentari/{id}: 404-before-leak pe id strain
- Unauthenticated access cu require_api_key=True -> 401
- Filtre si paginare nu sparg scope-ul
Legatura cu implementare: resolve_account_id + account_scope_clause (mecanisme
existente, reutilizate). Testele LOCK DOWN comportamentul deja implementat
si verifica alinierea cu AUTOPASS_REQUIRE_API_KEY (dev vs prod).
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_with_key(name: str):
"""Creeaza cont + cheie API. Intoarce (account_id, plaintext_key)."""
from app.accounts import create_account
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
key = create_api_key(conn, acct_id)
conn.commit()
return acct_id, key
finally:
conn.close()
def _insert_submission(acct: int, vin: str = "WVWZZZ1JZXW000777") -> int:
from app.db import get_connection
conn = get_connection()
try:
p = {
"vin": vin,
"nr_inmatriculare": "B777TST",
"data_prestatie": "2026-06-18",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}],
}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"api-scope-{acct}-{os.urandom(4).hex()}", acct, "sent", json.dumps(p)),
)
conn.commit()
rid = cur.lastrowid
assert rid is not None
return int(rid)
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
"""Client cu DB izolata, REQUIRE_API_KEY=false (dev default)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "api-scope.db"))
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
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()
@pytest.fixture()
def client_prod(monkeypatch):
"""Client cu require_api_key=True (comportament productie)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "api-scope-prod.db"))
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true")
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()
# ---------------------------------------------------------------------------
# Test 1: cross-account isolation pe listare submissions (dev mode)
# ---------------------------------------------------------------------------
def test_get_listare_scoped_cont(client):
"""Un cont NU vede submissions (VIN/PII) ale altui cont in GET /v1/prezentari.
Contul A are un submission cu VIN_A; contul B cu cheie proprie nu trebuie
sa vada VIN_A in listarea sa.
"""
VIN_A = "WVWZZZ1JZXWAPI1AA"
VIN_B = "WVWZZZ1JZXWAPI2BB"
acct_a, key_a = _create_account_with_key("ApiContA")
acct_b, key_b = _create_account_with_key("ApiContB")
_insert_submission(acct_a, vin=VIN_A)
_insert_submission(acct_b, vin=VIN_B)
# Cont B listeaza cu propria cheie
resp = client.get("/v1/prezentari", headers={"X-API-Key": key_b})
assert resp.status_code == 200
data = resp.json()
vins = [s["prezentare"]["vin"] for s in data["submissions"] if s.get("prezentare")]
assert VIN_B in vins, "Contul B ar trebui sa vada propriul submission"
assert VIN_A not in vins, (
"Scurgere cross-account: VIN-ul contului A vizibil contului B prin API"
)
# ---------------------------------------------------------------------------
# Test 2: unauthenticated 401 in prod mode
# ---------------------------------------------------------------------------
def test_get_listare_neautentificat_401(client_prod):
"""Fara cheie API cu require_api_key=True, GET /v1/prezentari -> 401."""
resp = client_prod.get("/v1/prezentari")
assert resp.status_code == 401, (
f"Asteptat 401 fara cheie API in mod prod, primit {resp.status_code}."
)
def test_get_listare_cheie_invalida_401(client_prod):
"""Cheie API invalida (prezenta dar gresita) -> 401, indiferent de flag."""
resp = client_prod.get("/v1/prezentari", headers={"X-API-Key": "rfak_invalida_xxx"})
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Test 3: 404-before-leak pe detaliu id strain
# ---------------------------------------------------------------------------
def test_get_detaliu_scoped_404(client):
"""GET /v1/prezentari/{id} pe un id al altui cont -> 404 (fara leak).
Acelasi 404 pentru id inexistent = nu confirmam existenta.
"""
acct_a, key_a = _create_account_with_key("DetApiA")
acct_b, key_b = _create_account_with_key("DetApiB")
sid_a = _insert_submission(acct_a, vin="WVWZZZ1JZXWDET100")
# Cont B incearca sa acceseze submission-ul contului A
resp = client.get(f"/v1/prezentari/{sid_a}", headers={"X-API-Key": key_b})
assert resp.status_code == 404, (
f"Asteptat 404, primit {resp.status_code}. "
"Nu trebuie confirmata existenta unui submission al altui cont."
)
# Id inexistent -> acelasi 404
resp2 = client.get("/v1/prezentari/999999", headers={"X-API-Key": key_b})
assert resp2.status_code == 404
def test_get_detaliu_neautentificat_401(client_prod):
"""GET /v1/prezentari/{id} fara cheie API in prod -> 401."""
resp = client_prod.get("/v1/prezentari/1")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Test 4: contul implicit id=1 in dev (nu trebuie spart de scope)
# ---------------------------------------------------------------------------
def test_get_listare_cont_implicit_dev(client):
"""In dev (require_api_key=False), fara cheie -> cont implicit id=1.
Contul 1 vede propriile submissions (NULL account_id = cont 1 prin
account_scope_clause). NU trebuie sa vada submissions cu alt account_id.
"""
# Inserare submission pt cont id=1 (account_id NULL = legacy cont 1)
from app.db import get_connection
conn = get_connection()
try:
p = json.dumps({
"vin": "WVWZZZ1JZXWDEV001",
"nr_inmatriculare": "B001DEV",
"data_prestatie": "2026-06-18",
"odometru_final": "10000",
"prestatii": [{"cod_prestatie": "OE-1"}],
})
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, NULL, 'sent', ?)",
("dev-null-key-001", p),
)
conn.commit()
finally:
conn.close()
# Fara cheie in dev -> cont implicit 1, vede submission-ul cu account_id NULL
resp = client.get("/v1/prezentari")
assert resp.status_code == 200
vins = [s["prezentare"]["vin"] for s in resp.json()["submissions"] if s.get("prezentare")]
assert "WVWZZZ1JZXWDEV001" in vins, "Contul implicit 1 trebuie sa vada submissions NULL"
# ---------------------------------------------------------------------------
# Test 5: izolare status filter nu sparge scope-ul
# ---------------------------------------------------------------------------
def test_get_listare_filtru_status_nu_sparge_scope(client):
"""Filtrul ?status= nu poate scoate randuri din alt cont."""
VIN_A = "WVWZZZ1JZXWSTA1AA"
acct_a, key_a = _create_account_with_key("StatApiA")
acct_b, key_b = _create_account_with_key("StatApiB")
_insert_submission(acct_a, vin=VIN_A)
# Cont B filtreaza dupa status 'sent' - nu trebuie sa vada VIN_A
resp = client.get("/v1/prezentari?status=sent", headers={"X-API-Key": key_b})
assert resp.status_code == 200
vins = [s["prezentare"]["vin"] for s in resp.json()["submissions"] if s.get("prezentare")]
assert VIN_A not in vins, (
"Filtrul status a scos date din alt cont (scurgere cross-account prin filtru)."
)

View File

@@ -49,11 +49,13 @@ def test_dashboard_renders_with_rar_state(client):
assert r.status_code == 200
# Dupa US-003 bara de status e incarcata via HTMX (hx-trigger=load, every 15s)
assert "/_fragments/status" in r.text, "Dashboard-ul trebuie sa referenceze fragmentul de status"
# Fragmentul de status contine starea worker (eticheta umana, nu "worker oprit" brut)
# Fragmentul de status contine starea de sanatate (text uman, nu brut tehnic)
rs = client.get("/_fragments/status")
assert rs.status_code == 200
# eticheta_worker(False) => "Trimitere automata: oprita" → fragmentul afiseaza "oprita"
assert "oprita" in rs.text or "Trimitere automata" in rs.text
# US-003 D6: strip sanatate unificat — "declaratiile" apare in orice stare (curg/blocat)
assert "declaratiile" in rs.text.lower(), (
f"Strip sanatate lipseste din fragment. HTML: {rs.text[:500]}"
)
# Tab-ul Nomenclator e accesat via /_fragments/nomenclator
rn = client.get("/_fragments/nomenclator")
assert rn.status_code == 200

152
tests/test_device_mix.py Normal file
View File

@@ -0,0 +1,152 @@
"""Teste US-012 (PRD 5.15): Analytics device-mix — validare premisa mobil, fara PII.
TDD: RED inainte de implementare.
Semnal: la acces dashboard -> eveniment 'device_mix' in app_events cu cod 'desktop'/'mobil'.
Zero PII: nu se stocheaza UA brut, IP sau VIN.
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixture #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "device_mix.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
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 _events_device_mix():
from app.db import get_connection
conn = get_connection()
try:
return conn.execute(
"SELECT * FROM app_events WHERE tip='device_mix' ORDER BY id"
).fetchall()
finally:
conn.close()
# --------------------------------------------------------------------------- #
# test_device_mix_inregistrat #
# --------------------------------------------------------------------------- #
UA_DESKTOP = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0 Safari/537.36"
)
UA_MOBIL_ANDROID = (
"Mozilla/5.0 (Linux; Android 13; Pixel 7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0 Mobile Safari/537.36"
)
UA_MOBIL_IPHONE = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) "
"Version/17.0 Mobile/15E148 Safari/604.1"
)
def test_device_mix_inregistrat_desktop(client):
"""Acces dashboard cu UA desktop -> eveniment device_mix cod='desktop'."""
r = client.get("/", headers={"User-Agent": UA_DESKTOP})
assert r.status_code == 200
events = _events_device_mix()
assert len(events) >= 1, "Trebuie cel putin un eveniment device_mix dupa acces dashboard"
# ultimul eveniment clasificat ca desktop
ev = events[-1]
assert ev["tip"] == "device_mix"
assert ev["cod"] == "desktop", f"Clasificare gresita: {ev['cod']!r}"
def test_device_mix_inregistrat_mobil_android(client):
"""Acces dashboard cu UA Android Mobile -> eveniment device_mix cod='mobil'."""
r = client.get("/", headers={"User-Agent": UA_MOBIL_ANDROID})
assert r.status_code == 200
events = _events_device_mix()
assert len(events) >= 1
ev = events[-1]
assert ev["tip"] == "device_mix"
assert ev["cod"] == "mobil", f"Clasificare gresita Android: {ev['cod']!r}"
def test_device_mix_inregistrat_mobil_iphone(client):
"""Acces dashboard cu UA iPhone -> eveniment device_mix cod='mobil'."""
r = client.get("/", headers={"User-Agent": UA_MOBIL_IPHONE})
assert r.status_code == 200
events = _events_device_mix()
assert len(events) >= 1
ev = events[-1]
assert ev["tip"] == "device_mix"
assert ev["cod"] == "mobil", f"Clasificare gresita iPhone: {ev['cod']!r}"
# --------------------------------------------------------------------------- #
# test_device_mix_fara_pii #
# --------------------------------------------------------------------------- #
def test_device_mix_fara_pii(client):
"""Evenimentul device_mix nu contine UA brut, IP sau alte PII."""
r = client.get("/", headers={"User-Agent": UA_MOBIL_ANDROID})
assert r.status_code == 200
events = _events_device_mix()
assert len(events) >= 1
ev = events[-1]
# Campul mesaj: doar eticheta grosiera, nu UA brut
mesaj = ev["mesaj"] or ""
assert UA_MOBIL_ANDROID not in mesaj, "UA brut nu trebuie stocat in mesaj"
assert "Android" not in mesaj, "Fragment UA nu trebuie stocat in mesaj"
assert "Mozilla" not in mesaj, "Fragment UA nu trebuie stocat in mesaj"
# context_json: daca exista, nu contine UA brut / IP
ctx_raw = ev["context_json"]
if ctx_raw:
ctx = json.loads(ctx_raw)
ctx_str = json.dumps(ctx)
assert UA_MOBIL_ANDROID not in ctx_str, "UA brut nu trebuie in context_json"
assert "Mozilla" not in ctx_str, "Fragment UA nu trebuie in context_json"
# IP-uri tipice nu apar (testclient trimite 127.0.0.1/testclient)
for ip_fragment in ["127.0.0.1", "testclient", "192.168."]:
assert ip_fragment not in ctx_str, f"IP {ip_fragment!r} nu trebuie in context_json"
# codul este doar eticheta grosiera
assert ev["cod"] in ("desktop", "mobil"), f"Cod neasteptat: {ev['cod']!r}"
def test_device_mix_fara_pii_desktop(client):
"""Evenimentul device_mix pentru desktop nu contine UA brut."""
r = client.get("/", headers={"User-Agent": UA_DESKTOP})
assert r.status_code == 200
events = _events_device_mix()
assert len(events) >= 1
ev = events[-1]
mesaj = ev["mesaj"] or ""
assert UA_DESKTOP not in mesaj, "UA brut desktop nu trebuie in mesaj"
assert "Windows NT" not in mesaj, "Fragment UA nu trebuie in mesaj"
assert ev["cod"] == "desktop"

233
tests/test_embeddings.py Normal file
View File

@@ -0,0 +1,233 @@
"""
Teste pentru app/embeddings.py -- modul embedding in-proces (L14-S4).
Structura:
(a) backend MOCK (vectori deterministi) -- index + suggest_nearest
(b) degradare gratioasa: backend None/broken -> is_available()=False,
suggest_nearest()=[] fara exceptie
(c) test real fastembed, skip daca nu e instalat (marker slow)
"""
import math
import pytest
from app import embeddings as emb
from app.embeddings import EmbeddingEngine
# --------------------------------------------------------------------------- #
# Helpers #
# --------------------------------------------------------------------------- #
def _vec(text: str, dim: int = 8) -> list:
"""Vector determinist bazat pe hash-ul textului (mock pur, fara retea)."""
h = abs(hash(text))
components = [(h >> (i * 5)) & 0x1F for i in range(dim)]
norm = math.sqrt(sum(c * c for c in components)) or 1.0
return [c / norm for c in components]
class MockBackend:
"""Backend embedding determinist pentru teste."""
def embed(self, texts: list) -> list:
return [_vec(t) for t in texts]
# --------------------------------------------------------------------------- #
# (a) Mock backend -- index + suggest_nearest #
# --------------------------------------------------------------------------- #
def test_index_and_suggest_nearest_mock():
"""Cel mai apropiat vecin al unui text identic == el insusi."""
corpus = [
{"denumire": "SCHIMB ULEI", "cod": "OE-3"},
{"denumire": "REPARATIE MOTOR", "cod": "OE-1"},
{"denumire": "VERIFICARE DIRECTIE", "cod": "OE-4"},
]
engine = EmbeddingEngine(backend=MockBackend())
engine.index_corpus(corpus)
results = engine.suggest_nearest("SCHIMB ULEI", top_k=1)
assert results, "Trebuie sa returneze cel putin un rezultat"
assert results[0]["cod"] == "OE-3"
assert 0.0 <= results[0]["similaritate"] <= 1.0 + 1e-9
def test_suggest_nearest_top_k_respects_limit():
"""suggest_nearest(top_k=2) nu returneaza mai mult de 2 rezultate."""
corpus = [
{"denumire": "SCHIMB ULEI MOTOR", "cod": "OE-3"},
{"denumire": "REVIZIE COMPLETA", "cod": "OE-3"},
{"denumire": "REPARATIE MOTOR", "cod": "OE-1"},
{"denumire": "INLOCUIT FRANA", "cod": "OE-2"},
]
engine = EmbeddingEngine(backend=MockBackend())
engine.index_corpus(corpus)
results = engine.suggest_nearest("ULEI MOTOR", top_k=2)
assert len(results) <= 2
def test_suggest_nearest_sorted_descending():
"""Rezultatele sunt sortate descrescator dupa similaritate."""
corpus = [
{"denumire": "SCHIMB ULEI", "cod": "OE-3"},
{"denumire": "REPARATIE MOTOR", "cod": "OE-1"},
{"denumire": "VERIFICARE FRANURI", "cod": "OE-2"},
]
engine = EmbeddingEngine(backend=MockBackend())
engine.index_corpus(corpus)
results = engine.suggest_nearest("SCHIMB ULEI", top_k=3)
scores = [r["similaritate"] for r in results]
assert scores == sorted(scores, reverse=True)
def test_suggest_nearest_returns_dict_with_required_keys():
"""Fiecare rezultat contine 'cod' si 'similaritate'."""
corpus = [{"denumire": "SCHIMB ULEI", "cod": "OE-3"}]
engine = EmbeddingEngine(backend=MockBackend())
engine.index_corpus(corpus)
results = engine.suggest_nearest("SCHIMB ULEI", top_k=1)
assert results
assert "cod" in results[0]
assert "similaritate" in results[0]
def test_index_empty_corpus():
"""suggest_nearest pe corpus gol returneaza []."""
engine = EmbeddingEngine(backend=MockBackend())
engine.index_corpus([])
assert engine.suggest_nearest("CEVA", top_k=3) == []
def test_suggest_nearest_before_index():
"""suggest_nearest fara index_corpus returneaza []."""
engine = EmbeddingEngine(backend=MockBackend())
assert engine.suggest_nearest("CEVA", top_k=3) == []
def test_engine_is_available_with_backend():
"""is_available() = True cand backend-ul e furnizat."""
engine = EmbeddingEngine(backend=MockBackend())
assert engine.is_available() is True
# --------------------------------------------------------------------------- #
# (b) Degradare gratioasa -- backend None / arunca #
# --------------------------------------------------------------------------- #
def test_is_available_false_when_backend_none():
"""is_available() = False cand backend = None."""
engine = EmbeddingEngine(backend=None)
assert engine.is_available() is False
def test_suggest_nearest_returns_empty_when_backend_none():
"""suggest_nearest = [] fara exceptie cand backend = None."""
engine = EmbeddingEngine(backend=None)
result = engine.suggest_nearest("CEVA", top_k=3)
assert result == []
def test_index_corpus_no_exception_when_backend_none():
"""index_corpus nu arunca exceptie cand backend = None."""
engine = EmbeddingEngine(backend=None)
engine.index_corpus([{"denumire": "CEVA", "cod": "OE-1"}]) # nu arunca
def test_suggest_nearest_no_exception_on_backend_error():
"""suggest_nearest prinde exceptia din backend si returneaza []."""
class BrokenBackend:
def embed(self, texts):
raise RuntimeError("backend broke")
corpus = [{"denumire": "SCHIMB ULEI", "cod": "OE-3"}]
engine = EmbeddingEngine(backend=BrokenBackend())
engine.index_corpus(corpus) # index poate esua silentios
# suggest_nearest nu trebuie sa arunce exceptie
result = engine.suggest_nearest("SCHIMB ULEI", top_k=1)
assert result == []
def test_index_corpus_no_exception_on_backend_error():
"""index_corpus nu arunca exceptie cand backend-ul arunca la embed."""
class BrokenBackend:
def embed(self, texts):
raise ValueError("embed error")
engine = EmbeddingEngine(backend=BrokenBackend())
engine.index_corpus([{"denumire": "CEVA", "cod": "OE-1"}])
# corpus ramane gol, suggest_nearest returneaza []
assert engine.suggest_nearest("CEVA") == []
# --------------------------------------------------------------------------- #
# API la nivel de modul (singleton global) #
# --------------------------------------------------------------------------- #
def test_module_level_is_available_no_exception():
"""Apelul global is_available() nu arunca exceptie."""
result = emb.is_available()
assert isinstance(result, bool)
def test_module_level_suggest_nearest_no_exception():
"""Apelul global suggest_nearest() nu arunca exceptie."""
result = emb.suggest_nearest("SCHIMB ULEI MOTOR", top_k=3)
assert isinstance(result, list)
def test_module_level_index_corpus_no_exception():
"""Apelul global index_corpus() nu arunca exceptie."""
corpus = [{"denumire": "REPARATIE", "cod": "OE-1"}]
emb.index_corpus(corpus) # nu trebuie sa arunce
# --------------------------------------------------------------------------- #
# (c) Test real fastembed -- skip daca modelul nu e descarcat #
# --------------------------------------------------------------------------- #
try:
import fastembed as _fe
_FASTEMBED_AVAILABLE = True
except ImportError:
_FASTEMBED_AVAILABLE = False
@pytest.mark.skipif(not _FASTEMBED_AVAILABLE, reason="fastembed nu e instalat")
def test_fastembed_backend_is_available_type():
"""is_available() returneaza bool (indiferent daca modelul e descarcat sau nu)."""
result = emb.is_available()
assert isinstance(result, bool)
@pytest.mark.slow
@pytest.mark.skipif(not _FASTEMBED_AVAILABLE, reason="fastembed nu e instalat")
def test_fastembed_real_embedding_similarity():
"""Test real end-to-end: denumiri similare au similaritate mai mare decat cele diferite.
Necesita download model la prima rulare (~220MB). Skip cu: pytest -m 'not slow'.
"""
from app.embeddings import EmbeddingEngine, FastEmbedBackend
backend = FastEmbedBackend()
engine = EmbeddingEngine(backend=backend)
corpus = [
{"denumire": "schimb ulei motor", "cod": "OE-3"},
{"denumire": "reparatie motor cutie viteze", "cod": "OE-1"},
{"denumire": "verificare directie volan", "cod": "OE-4"},
]
engine.index_corpus(corpus)
results = engine.suggest_nearest("schimb ulei", top_k=3)
assert results, "Trebuie sa returneze cel putin un rezultat"
# 'schimb ulei' trebuie sa fie mai aproape de 'schimb ulei motor' (OE-3)
assert results[0]["cod"] == "OE-3", (
f"Asteptat OE-3 ca primul rezultat, primit: {results}"
)

527
tests/test_heldout_eval.py Normal file
View File

@@ -0,0 +1,527 @@
"""Teste TDD pentru tools/mapare-llm/heldout_eval.py (L14-S5).
Fixture sintetic cu predictii+ground_truth cunoscute. Verifica:
- precizie globala
- precizie per-cod (TP/FP/FN per eticheta)
- rata cod-gresit (critic: cod gresit = FINALIZATA ireversibil)
- esantionare stratificata determinista (acelasi seed = aceleasi rezultate)
- kill-criterion (pass/fail pe praguri definite)
Rulare: python3 -m pytest tests/test_heldout_eval.py -v
"""
from __future__ import annotations
import os
import sys
import csv
# Adaugam tools/mapare-llm/ la sys.path (pattern din test_holdout.py)
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS_DIR = os.path.abspath(os.path.join(HERE, "..", "tools", "mapare-llm"))
if TOOLS_DIR not in sys.path:
sys.path.insert(0, TOOLS_DIR)
import pytest
import heldout_eval as he
# ---------------------------------------------------------------------------
# Fixture sintetic pentru eval_predictions
# ---------------------------------------------------------------------------
# 6 intrari; 3 corecte, 1 cod-gresit (critic), 1 NUL fals-negativ, 1 nerezolvat
PREDS = [
{"denumire": "REVIZIE PERIODICA", "cod_pred": "OE-3"}, # corect
{"denumire": "SCHIMB ULEI MOTOR", "cod_pred": "OE-1"}, # GRESIT: gold=OE-3 (cod gresit!)
{"denumire": "DISCOUNT 10%", "cod_pred": "NUL"}, # corect
{"denumire": "VOPSIRE BARA FATA", "cod_pred": "OE-1"}, # corect
{"denumire": "DIAGNOSTICARE OBD", "cod_pred": "?"}, # nerezolvat
{"denumire": "D/R BARA FATA", "cod_pred": "OE-2"}, # GRESIT: gold=OE-1 (cod gresit!)
]
GOLD = [
{"denumire": "REVIZIE PERIODICA", "cod_gold": "OE-3"},
{"denumire": "SCHIMB ULEI MOTOR", "cod_gold": "OE-3"}, # pred=OE-1, gold=OE-3 -> COD GRESIT
{"denumire": "DISCOUNT 10%", "cod_gold": "NUL"},
{"denumire": "VOPSIRE BARA FATA", "cod_gold": "OE-1"},
{"denumire": "DIAGNOSTICARE OBD", "cod_gold": "OE-4"},
{"denumire": "D/R BARA FATA", "cod_gold": "OE-1"}, # pred=OE-2, gold=OE-1 -> COD GRESIT
]
# total=6, correct=3 (REVIZIE, DISCOUNT, VOPSIRE)
# wrong_code=2 (SCHIMB ULEI: OE-1 vs OE-3; D/R BARA: OE-2 vs OE-1)
# coverage_count=5 (pred!="?"), coverage_rate=5/6
# global_precision=3/6=0.50
# wrong_code_rate=2/6
# ---------------------------------------------------------------------------
# Sectiunea 1: eval_predictions — precizie globala
# ---------------------------------------------------------------------------
class TestEvalPrecizie:
"""Verifica metricile globale returnate de eval_predictions."""
def test_total_items(self):
"""total = numarul de intrari din ground_truth."""
m = he.eval_predictions(PREDS, GOLD)
assert m["total"] == 6
def test_correct_count(self):
"""3 predictii corecte din 6."""
m = he.eval_predictions(PREDS, GOLD)
assert m["correct"] == 3
def test_global_precision(self):
"""global_precision = correct / total = 3/6 = 0.50."""
m = he.eval_predictions(PREDS, GOLD)
assert abs(m["global_precision"] - 0.50) < 1e-9
def test_campuri_obligatorii(self):
"""Rezultatul contine toate campurile definite."""
m = he.eval_predictions(PREDS, GOLD)
obligatorii = [
"total", "correct", "global_precision",
"wrong_code_count", "wrong_code_rate",
"coverage_count", "coverage_rate",
"per_cod", "confusion_matrix",
]
for camp in obligatorii:
assert camp in m, f"Camp lipsa: {camp}"
def test_empty_inputs(self):
"""Input gol -> metrics cu valori zero, fara exceptie."""
m = he.eval_predictions([], [])
assert m["total"] == 0
assert m["global_precision"] == 0.0
assert m["wrong_code_rate"] == 0.0
def test_all_correct(self):
"""Toate corecte -> precision 1.0, wrong_code_rate 0.0."""
preds = [
{"denumire": "REVIZIE", "cod_pred": "OE-3"},
{"denumire": "ITP", "cod_pred": "NUL"},
]
gold = [
{"denumire": "REVIZIE", "cod_gold": "OE-3"},
{"denumire": "ITP", "cod_gold": "NUL"},
]
m = he.eval_predictions(preds, gold)
assert m["global_precision"] == 1.0
assert m["wrong_code_rate"] == 0.0
def test_predictie_lipsa_tratata_ca_nerezolvata(self):
"""Daca o denumire din gold nu e in predictions -> pred='?' (nerezolvat)."""
preds = [
{"denumire": "REVIZIE", "cod_pred": "OE-3"},
# SCHIMB ULEI lipseste din predictii
]
gold = [
{"denumire": "REVIZIE", "cod_gold": "OE-3"},
{"denumire": "SCHIMB ULEI", "cod_gold": "OE-3"},
]
m = he.eval_predictions(preds, gold)
assert m["total"] == 2
assert m["correct"] == 1 # doar REVIZIE
assert m["coverage_count"] == 1 # SCHIMB ULEI e "?"
# ---------------------------------------------------------------------------
# Sectiunea 2: eval_predictions — rata cod-gresit (CRITIC)
# ---------------------------------------------------------------------------
class TestWrongCodeRate:
"""
'Cod gresit' = pred in VALID_RAR, gold in VALID_RAR, pred != gold.
Aceasta situatie ar produce FINALIZATA ireversibil cu cod eronat.
"""
def test_wrong_code_count(self):
"""2 cod-gresit din 6 intrari."""
m = he.eval_predictions(PREDS, GOLD)
assert m["wrong_code_count"] == 2
def test_wrong_code_rate(self):
"""wrong_code_rate = 2/6."""
m = he.eval_predictions(PREDS, GOLD)
assert abs(m["wrong_code_rate"] - 2 / 6) < 1e-9
def test_nul_gresit_nu_e_cod_gresit(self):
"""pred=NUL si gold=OE-3 NU e 'cod gresit' (item merge la needs_mapping, nu la FINALIZATA)."""
preds = [{"denumire": "REVIZIE", "cod_pred": "NUL"}]
gold = [{"denumire": "REVIZIE", "cod_gold": "OE-3"}]
m = he.eval_predictions(preds, gold)
# pred=NUL nu genereaza FINALIZATA -> wrong_code_count=0
assert m["wrong_code_count"] == 0
def test_zero_wrong_code_pe_fixture_corect(self):
"""Pe fixture 'all correct', wrong_code_count = 0."""
preds = [{"denumire": "X", "cod_pred": "OE-1"}]
gold = [{"denumire": "X", "cod_gold": "OE-1"}]
m = he.eval_predictions(preds, gold)
assert m["wrong_code_count"] == 0
assert m["wrong_code_rate"] == 0.0
# ---------------------------------------------------------------------------
# Sectiunea 3: eval_predictions — acoperire (coverage)
# ---------------------------------------------------------------------------
class TestCoverage:
"""coverage = fractia de intrari cu pred != '?' (are un raspuns, fie cod fie NUL)."""
def test_coverage_count(self):
"""5 din 6 au pred != '?'."""
m = he.eval_predictions(PREDS, GOLD)
assert m["coverage_count"] == 5
def test_coverage_rate(self):
"""coverage_rate = 5/6."""
m = he.eval_predictions(PREDS, GOLD)
assert abs(m["coverage_rate"] - 5 / 6) < 1e-9
def test_coverage_zero_pe_toate_nerezolvate(self):
"""Daca toate pred='?' -> coverage=0."""
preds = [{"denumire": "X", "cod_pred": "?"}]
gold = [{"denumire": "X", "cod_gold": "OE-3"}]
m = he.eval_predictions(preds, gold)
assert m["coverage_count"] == 0
assert m["coverage_rate"] == 0.0
# ---------------------------------------------------------------------------
# Sectiunea 4: eval_predictions — per_cod (TP/FP/FN + precision/recall)
# ---------------------------------------------------------------------------
class TestPerCod:
"""Verifica metricile per eticheta (precizie + recall per cod)."""
def test_per_cod_returnat(self):
"""per_cod e un dict cu chei = etichete prezente."""
m = he.eval_predictions(PREDS, GOLD)
assert isinstance(m["per_cod"], dict)
assert len(m["per_cod"]) > 0
def test_per_cod_campuri(self):
"""Fiecare cod are tp, fp, fn, precision, recall."""
m = he.eval_predictions(PREDS, GOLD)
for cod, stats in m["per_cod"].items():
assert "tp" in stats, f"tp lipsa pentru {cod}"
assert "fp" in stats, f"fp lipsa pentru {cod}"
assert "fn" in stats, f"fn lipsa pentru {cod}"
assert "precision" in stats, f"precision lipsa pentru {cod}"
assert "recall" in stats, f"recall lipsa pentru {cod}"
def test_per_cod_oe1_precision(self):
"""OE-1: pred pt [VOPSIRE(corect), D/R BARA(gresit, gold=OE-1 dar pred=OE-2)].
Wait - pred=OE-1 pt VOPSIRE(gold=OE-1 corect) si SCHIMB ULEI(gold=OE-3 gresit).
TP=1(VOPSIRE), FP=1(SCHIMB ULEI pred=OE-1 dar gold=OE-3), FN=1(D/R BARA pred=OE-2 nu OE-1).
precision_OE1 = 1/(1+1) = 0.50
recall_OE1 = 1/(1+1) = 0.50
"""
m = he.eval_predictions(PREDS, GOLD)
oe1 = m["per_cod"].get("OE-1", {})
# TP: VOPSIRE BARA FATA (pred=OE-1, gold=OE-1)
# FP: SCHIMB ULEI MOTOR (pred=OE-1, gold=OE-3)
# FN: D/R BARA FATA (gold=OE-1, pred=OE-2)
assert oe1.get("tp") == 1
assert oe1.get("fp") == 1
assert oe1.get("fn") == 1
assert abs(oe1.get("precision") - 0.50) < 1e-9
assert abs(oe1.get("recall") - 0.50) < 1e-9
def test_per_cod_oe3_precision(self):
"""OE-3: pred pt [REVIZIE(corect)]. gold=OE-3 pt [REVIZIE, SCHIMB ULEI].
TP=1(REVIZIE), FP=0, FN=1(SCHIMB ULEI pred=OE-1).
precision=1.0, recall=0.50
"""
m = he.eval_predictions(PREDS, GOLD)
oe3 = m["per_cod"].get("OE-3", {})
assert oe3.get("tp") == 1
assert oe3.get("fp") == 0
assert oe3.get("fn") == 1
assert abs(oe3.get("precision") - 1.0) < 1e-9
assert abs(oe3.get("recall") - 0.50) < 1e-9
def test_per_cod_precision_none_pe_necunoscut(self):
"""Daca un cod e doar in gold (niciodata prezis) -> precision=None sau 0."""
# OE-4 e gold pt DIAGNOSTICARE, dar pred='?' -> FN=1, TP=0, FP=0
m = he.eval_predictions(PREDS, GOLD)
oe4 = m["per_cod"].get("OE-4", {})
# Precision nedefinita (0/0): None sau 0.0 ambele OK
assert oe4.get("tp") == 0
assert oe4.get("fp") == 0
assert oe4.get("fn") == 1
assert oe4.get("precision") is None or oe4.get("precision") == 0.0
# ---------------------------------------------------------------------------
# Sectiunea 5: eval_predictions — matrice confuzie
# ---------------------------------------------------------------------------
class TestConfusionMatrix:
"""Matricea confuzie indexata ca 'gold->pred'."""
def test_confusion_matrix_returnat(self):
"""confusion_matrix e un dict."""
m = he.eval_predictions(PREDS, GOLD)
assert isinstance(m["confusion_matrix"], dict)
def test_confusion_matrix_cod_gresit_prezent(self):
"""Cazul 'gold=OE-3, pred=OE-1' (SCHIMB ULEI) -> cheie 'OE-3->OE-1' cu count 1."""
m = he.eval_predictions(PREDS, GOLD)
assert m["confusion_matrix"].get("OE-3->OE-1") == 1
def test_confusion_matrix_corect(self):
"""Cazul corect 'gold=OE-3, pred=OE-3' (REVIZIE) -> cheie 'OE-3->OE-3' cu count 1."""
m = he.eval_predictions(PREDS, GOLD)
assert m["confusion_matrix"].get("OE-3->OE-3") == 1
# ---------------------------------------------------------------------------
# Sectiunea 6: sample_stratified — esantionare stratificata determinista
# ---------------------------------------------------------------------------
# Fixture: 20 iteme cu frecvente Zipf-like (suficient pt 3 strate)
SAMPLE_ROWS = [(f"op_{i:02d}", max(1, 2000 - i * 100)) for i in range(20)]
# Sortat descrescator: op_00=2000, op_01=1900, ..., op_19=100
# n=20, head_end = max(1, round(20*0.20)) = 4
# mid_end = max(5, round(20*0.50)) = 10
# cap = [op_00..op_03] (4 items)
# mijloc = [op_04..op_09] (6 items)
# coada = [op_10..op_19] (10 items)
class TestSampleStratified:
"""Verifica proprietatile esantionarii stratificate."""
def test_determinist_acelasi_seed(self):
"""Acelasi seed -> acelasi rezultat (determinist)."""
r1 = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=42)
r2 = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=42)
assert r1 == r2
def test_seed_diferit_rezultat_diferit(self):
"""Seed diferit -> (de obicei) rezultat diferit."""
r1 = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=42)
r2 = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=999)
# Nu garanteaza 100% diferenta, dar pe 20 items e practic garantat
assert r1 != r2
def test_items_din_input(self):
"""Toate itemele returnate exista in inputul original."""
result = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=42)
input_set = {(d, n) for d, n in SAMPLE_ROWS}
for item in result:
assert (item["denumire"], item["nr"]) in input_set
def test_campuri_obligatorii(self):
"""Fiecare item are: denumire, nr, strat."""
result = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=42)
for item in result:
assert "denumire" in item
assert "nr" in item
assert "strat" in item
def test_strat_valid(self):
"""Valorile strat sunt exclusiv din {'cap', 'mijloc', 'coada'}."""
result = he.sample_stratified(SAMPLE_ROWS, n_sample=9, seed=42)
for item in result:
assert item["strat"] in ("cap", "mijloc", "coada")
def test_toate_stratele_reprezentate(self):
"""Cand n_sample e suficient de mare, toate 3 stratele apar in rezultat."""
# n_sample=15 dintr-un total de 20 -> toate stratele au cel putin 1 item
result = he.sample_stratified(SAMPLE_ROWS, n_sample=15, seed=42)
strate_prezente = {item["strat"] for item in result}
assert "cap" in strate_prezente
assert "mijloc" in strate_prezente
assert "coada" in strate_prezente
def test_dimensiune_aproape_de_n_sample(self):
"""Dimensiunea rezultatului e aproape de n_sample (+/- 3 datorita rotunjirii)."""
n_sample = 9
result = he.sample_stratified(SAMPLE_ROWS, n_sample=n_sample, seed=42)
assert abs(len(result) - n_sample) <= 3
def test_fara_duplicate(self):
"""Niciun item nu apare de doua ori in esantion."""
result = he.sample_stratified(SAMPLE_ROWS, n_sample=15, seed=42)
denumiri = [item["denumire"] for item in result]
assert len(denumiri) == len(set(denumiri))
def test_input_gol(self):
"""Input gol -> returneaza lista goala fara exceptie."""
result = he.sample_stratified([], n_sample=10, seed=42)
assert result == []
def test_n_sample_mai_mare_decat_corpus(self):
"""Cand n_sample > len(rows), returneaza cel mult len(rows) items."""
result = he.sample_stratified(SAMPLE_ROWS, n_sample=1000, seed=42)
assert len(result) <= len(SAMPLE_ROWS)
# ---------------------------------------------------------------------------
# Sectiunea 7: export_for_labeling — fisier CSV pt etichetare umana
# ---------------------------------------------------------------------------
class TestExportForLabeling:
"""Exportul CSV contine denumire, nr, strat si coloana cod_gold GOALA."""
def test_fisier_creat(self, tmp_path):
"""Fisierul este creat la calea specificata."""
sample = he.sample_stratified(SAMPLE_ROWS, n_sample=5, seed=42)
path = str(tmp_path / "esantion.csv")
he.export_for_labeling(sample, path)
assert os.path.exists(path)
def test_header_csv(self, tmp_path):
"""CSV-ul are header-ul corect: denumire;nr;strat;cod_gold."""
sample = he.sample_stratified(SAMPLE_ROWS, n_sample=5, seed=42)
path = str(tmp_path / "esantion.csv")
he.export_for_labeling(sample, path)
with open(path, encoding="utf-8-sig") as f:
reader = csv.DictReader(f, delimiter=";")
coloane = reader.fieldnames
assert "denumire" in coloane
assert "nr" in coloane
assert "strat" in coloane
assert "cod_gold" in coloane
def test_cod_gold_gol(self, tmp_path):
"""Coloana cod_gold e goala (de completat de operator uman)."""
sample = he.sample_stratified(SAMPLE_ROWS, n_sample=5, seed=42)
path = str(tmp_path / "esantion.csv")
he.export_for_labeling(sample, path)
with open(path, encoding="utf-8-sig") as f:
reader = csv.DictReader(f, delimiter=";")
for row in reader:
# Coloana cod_gold trebuie sa fie vida (nu etichetata de cod!)
assert row["cod_gold"] == "", (
"cod_gold nu trebuie pre-completat: ar fi 'antrenare pe test' "
"(Decision #19 PRD 5.14)"
)
def test_n_randuri_egal_cu_sample(self, tmp_path):
"""CSV-ul are exact atatea randuri cat esantionul."""
sample = he.sample_stratified(SAMPLE_ROWS, n_sample=5, seed=42)
path = str(tmp_path / "esantion.csv")
he.export_for_labeling(sample, path)
with open(path, encoding="utf-8-sig") as f:
rows = list(csv.DictReader(f, delimiter=";"))
assert len(rows) == len(sample)
# ---------------------------------------------------------------------------
# Sectiunea 8: kill_criterion — pragul de acceptanta (F-E, PRD 5.14)
# ---------------------------------------------------------------------------
class TestKillCriterion:
"""
Kill-criterion (F-E): sistemul TRECE daca
wrong_code_rate < wrong_code_threshold (default 0.5%)
SI coverage_rate > coverage_threshold (default 50%).
Justificare threshold 0.5% (0.005):
Un service cu 200 operatii/zi auto-rezolvate = 1 FINALIZATA gresita/zi.
FINALIZATA e ireversibila (cf. PRD 5.14 Premisa 3 / invariant CLAUDE.md).
Pragul poate fi RELAXAT empiric; nu INASPRIT post-hoc (sesizare-in-timp).
"""
def test_trece_cand_sub_prag(self):
"""Trece cand wrong_code_rate < threshold si coverage_rate > min_coverage."""
metrics = {
"wrong_code_rate": 0.003, # 0.3% < 0.5%
"coverage_rate": 0.70, # 70% > 50%
}
r = he.kill_criterion(metrics)
assert r["passes"] is True
def test_esueaza_cand_wrong_code_prea_mare(self):
"""Esueaza cand wrong_code_rate >= threshold."""
metrics = {
"wrong_code_rate": 0.02, # 2% > 0.5% -> FAIL
"coverage_rate": 0.70,
}
r = he.kill_criterion(metrics)
assert r["passes"] is False
assert "wrong_code" in r["reason"].lower() or "cod gresit" in r["reason"].lower()
def test_esueaza_cand_coverage_prea_mica(self):
"""Esueaza cand coverage_rate < min_coverage_threshold."""
metrics = {
"wrong_code_rate": 0.001,
"coverage_rate": 0.30, # 30% < 50% -> FAIL
}
r = he.kill_criterion(metrics)
assert r["passes"] is False
assert "acoperire" in r["reason"].lower() or "coverage" in r["reason"].lower()
def test_esueaza_pe_ambele_conditii(self):
"""Esueaza cand ambele conditii sunt incalcate."""
metrics = {
"wrong_code_rate": 0.05,
"coverage_rate": 0.10,
}
r = he.kill_criterion(metrics)
assert r["passes"] is False
def test_campuri_obligatorii_in_rezultat(self):
"""Rezultatul are: passes, reason, wrong_code_rate, coverage_rate, thresholds."""
metrics = {"wrong_code_rate": 0.001, "coverage_rate": 0.80}
r = he.kill_criterion(metrics)
for camp in ("passes", "reason", "wrong_code_rate", "coverage_rate", "thresholds"):
assert camp in r, f"Camp lipsa: {camp}"
def test_threshold_customizabil(self):
"""Pragurile pot fi suprascrise."""
metrics = {"wrong_code_rate": 0.05, "coverage_rate": 0.80}
# Cu threshold mai lax, trece
r = he.kill_criterion(metrics, wrong_code_threshold=0.10)
assert r["passes"] is True
def test_exact_pe_prag_nu_trece(self):
"""Pe prag exact (egalitate), nu trece (< e strict)."""
threshold = he.DEFAULT_WRONG_CODE_THRESHOLD
metrics = {"wrong_code_rate": threshold, "coverage_rate": 0.80}
r = he.kill_criterion(metrics)
# wrong_code_rate = threshold -> NU < threshold -> FAIL
assert r["passes"] is False
def test_reason_descrie_starea(self):
"""reason e un string non-gol care descrie de ce trece/esueaza."""
metrics = {"wrong_code_rate": 0.001, "coverage_rate": 0.80}
r = he.kill_criterion(metrics)
assert isinstance(r["reason"], str)
assert len(r["reason"]) > 0
# ---------------------------------------------------------------------------
# Sectiunea 9: constante si metadate modul
# ---------------------------------------------------------------------------
class TestModulMetadata:
"""Verifica existenta constantelor documentate."""
def test_valid_rar_definit(self):
"""VALID_RAR e un set non-gol de coduri RAR."""
assert hasattr(he, "VALID_RAR")
assert isinstance(he.VALID_RAR, frozenset)
assert len(he.VALID_RAR) >= 18
def test_nul_in_all_labels_nu_in_valid_rar(self):
"""NUL e eticheta speciala (supresie), NU e cod RAR valid."""
assert "NUL" not in he.VALID_RAR
# NUL trebuie sa fie accesibil totusi
assert hasattr(he, "NUL")
assert he.NUL == "NUL"
def test_default_seed(self):
"""DEFAULT_SEED exista si e intreg."""
assert hasattr(he, "DEFAULT_SEED")
assert isinstance(he.DEFAULT_SEED, int)
def test_default_thresholds_in_range(self):
"""Pragurile default sunt in (0, 1)."""
assert 0 < he.DEFAULT_WRONG_CODE_THRESHOLD < 1
assert 0 < he.DEFAULT_COVERAGE_THRESHOLD < 1

286
tests/test_holdout.py Normal file
View File

@@ -0,0 +1,286 @@
"""Teste TDD pentru tools/mapare-llm/holdout.py.
Verifica logica de split + calcul hit-rate pe un fixture SINTETIC (nu pe date reale).
Fixture-ul nu testeaza numerele efective pe CSV-uri, ci CORECTITUDINEA functiilor.
"""
from __future__ import annotations
import sys
import os
# Adaugam tools/mapare-llm/ in path pentru import direct al holdout.py
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools', 'mapare-llm'))
import pytest
# Fixture sintetic: 5 denumiri cu frecvente diferite
# Total volum = 100 + 80 + 50 + 30 + 10 + 1 + 1 = 272
FIXTURE = [
("Revizie motor", 100),
("Schimb ulei", 80),
("Reglat frane", 50),
("Diagnosticare", 30),
("Curatenie interior", 10),
("Altceva rar A", 1),
("Altceva rar B", 1),
]
FIXTURE_TOTAL_VOL = sum(n for _, n in FIXTURE) # 272
FIXTURE_DISTINCT = len(FIXTURE) # 7
# ---------------------------------------------------------------------------
# compute_volume_coverage
# ---------------------------------------------------------------------------
def test_compute_volume_coverage_sorted_descrescator():
"""Primul element trebuie sa fie cel cu NR cel mai mare."""
from holdout import compute_volume_coverage
rows = [("A", 10), ("B", 90), ("C", 0)]
result = compute_volume_coverage([r for r in rows if r[1] > 0])
assert result[0]["denumire"] == "B"
assert result[0]["nr"] == 90
def test_compute_volume_coverage_cumul():
"""Acoperirea cumulativa e corecta."""
from holdout import compute_volume_coverage
rows = [("A", 90), ("B", 9), ("C", 1)] # total=100
result = compute_volume_coverage(rows)
# Ordine: A(90), B(9), C(1) dupa sortare desc
assert result[0]["denumire"] == "A"
assert abs(result[0]["cumulative_volume_frac"] - 0.90) < 1e-9
assert result[0]["cumulative_count"] == 1
assert result[1]["denumire"] == "B"
assert abs(result[1]["cumulative_volume_frac"] - 0.99) < 1e-9
assert result[1]["cumulative_count"] == 2
assert result[2]["denumire"] == "C"
assert abs(result[2]["cumulative_volume_frac"] - 1.0) < 1e-9
assert result[2]["cumulative_count"] == 3
def test_compute_volume_coverage_gol():
"""Lista goala -> lista goala (fara exceptii)."""
from holdout import compute_volume_coverage
assert compute_volume_coverage([]) == []
# ---------------------------------------------------------------------------
# corpus_size_for_threshold
# ---------------------------------------------------------------------------
def test_corpus_size_for_90pct():
"""Gaseste corect numarul de etichete pentru 90% acoperire."""
from holdout import corpus_size_for_threshold
rows = [("A", 90), ("B", 9), ("C", 1)] # total=100
# A singur = 90% -> 1 eticheta suficienta
assert corpus_size_for_threshold(rows, threshold=0.90) == 1
def test_corpus_size_for_99pct():
"""Prag 99%: necesita 2 etichete (A+B = 99/100)."""
from holdout import corpus_size_for_threshold
rows = [("A", 90), ("B", 9), ("C", 1)]
assert corpus_size_for_threshold(rows, threshold=0.99) == 2
def test_corpus_size_for_100pct():
"""Prag 100%: necesita toate etichetele."""
from holdout import corpus_size_for_threshold
rows = [("A", 90), ("B", 9), ("C", 1)]
assert corpus_size_for_threshold(rows, threshold=1.0) == 3
# ---------------------------------------------------------------------------
# compute_hit_rate_at_k
# ---------------------------------------------------------------------------
def test_compute_hit_rate_at_k_1():
"""Top-1 eticheta (A=90): hit-rate = 90/100 = 0.90."""
from holdout import compute_hit_rate_at_k
rows = [("A", 90), ("B", 9), ("C", 1)]
assert abs(compute_hit_rate_at_k(rows, k=1) - 0.90) < 1e-9
def test_compute_hit_rate_at_k_2():
"""Top-2 etichete (A+B=99): hit-rate = 0.99."""
from holdout import compute_hit_rate_at_k
rows = [("A", 90), ("B", 9), ("C", 1)]
assert abs(compute_hit_rate_at_k(rows, k=2) - 0.99) < 1e-9
def test_compute_hit_rate_at_k_depasit():
"""k mai mare decat numarul de randuri: hit-rate = 1.0."""
from holdout import compute_hit_rate_at_k
rows = [("A", 90), ("B", 10)]
assert abs(compute_hit_rate_at_k(rows, k=100) - 1.0) < 1e-9
def test_compute_hit_rate_at_k_gol():
"""Lista goala: hit-rate = 0.0 (fara ZeroDivisionError)."""
from holdout import compute_hit_rate_at_k
assert compute_hit_rate_at_k([], k=10) == 0.0
# ---------------------------------------------------------------------------
# leave_one_out_hit_rate
# ---------------------------------------------------------------------------
def test_leave_one_out_hit_rate_formula():
"""Hit-rate leave-first-out: (total_vol - total_distinct) / total_vol.
Interpretare: pe oricare aparitie, dupa prima, e un hit (deja in corpus).
Singletonii (NR=1) contribuie 0 hit-uri.
"""
from holdout import leave_one_out_hit_rate
rows = [("A", 10), ("B", 5), ("C", 1)] # total=16, distinct=3
# formula: (16 - 3) / 16 = 0.8125
assert abs(leave_one_out_hit_rate(rows) - 13 / 16) < 1e-9
def test_leave_one_out_hit_rate_toate_singletons():
"""Toti singletons: hit-rate = 0 (fiecare aparitie e prima)."""
from holdout import leave_one_out_hit_rate
rows = [("A", 1), ("B", 1), ("C", 1)]
assert leave_one_out_hit_rate(rows) == 0.0
def test_leave_one_out_hit_rate_gol():
"""Lista goala: returneaza 0.0 fara exceptie."""
from holdout import leave_one_out_hit_rate
assert leave_one_out_hit_rate([]) == 0.0
# ---------------------------------------------------------------------------
# singleton_stats
# ---------------------------------------------------------------------------
def test_singleton_stats_calcul():
"""Statistici singletons corecte."""
from holdout import singleton_stats
rows = [("A", 100), ("B", 1), ("C", 1)] # total=102, 2 singletons
stats = singleton_stats(rows)
assert stats["singleton_count"] == 2
assert stats["total_distinct"] == 3
assert abs(stats["singleton_volume_frac"] - 2 / 102) < 1e-9
assert abs(stats["singleton_distinct_frac"] - 2 / 3) < 1e-9
def test_singleton_stats_fara_singletons():
"""Fara singletons: toate fractiile singleton = 0."""
from holdout import singleton_stats
rows = [("A", 5), ("B", 10)]
stats = singleton_stats(rows)
assert stats["singleton_count"] == 0
assert stats["singleton_volume_frac"] == 0.0
# ---------------------------------------------------------------------------
# normalize_for_match: cheia de potrivire refolosita din app/mapping.py
# ---------------------------------------------------------------------------
def test_normalize_for_match_diacritice():
"""normalize_for_match trateaza diacriticele identic (din app/mapping.py)."""
from holdout import normalize_key
# Variante cu si fara diacritice -> aceeasi cheie normalizata
assert normalize_key("Reparație motor") == normalize_key("Reparatie motor")
assert normalize_key("REPARATIE MOTOR") == normalize_key("Reparatie motor")
def test_normalize_for_match_spatii():
"""Spatiile multiple se colapseza."""
from holdout import normalize_key
assert normalize_key("revizie periodica") == normalize_key("REVIZIE PERIODICA")
# ---------------------------------------------------------------------------
# run_holdout: structura si verdict
# ---------------------------------------------------------------------------
def test_run_holdout_campuri_obligatorii():
"""run_holdout returneaza toate campurile asteptate."""
from holdout import run_holdout
result = run_holdout(FIXTURE, client_name="test_client")
campuri = [
"client", "total_distinct", "total_volume",
"coverage_at_100", "coverage_at_500", "coverage_at_1000",
"labels_for_90pct", "frac_for_90pct",
"leave_one_out_hit_rate",
"singleton_count", "singleton_distinct_frac", "singleton_volume_frac",
"verdict", "nota",
]
for camp in campuri:
assert camp in result, f"Camp lipsa: {camp}"
def test_run_holdout_client_name():
"""client_name se pastreaza corect in rezultat."""
from holdout import run_holdout
result = run_holdout(FIXTURE, client_name="test_client")
assert result["client"] == "test_client"
def test_run_holdout_verdict_valid():
"""Verdict e unul din valorile definite."""
from holdout import run_holdout
result = run_holdout(FIXTURE, client_name="test_client")
assert result["verdict"] in ("SUSTINUTA", "SLABA", "NEVALIDABILA")
def test_run_holdout_total_volum():
"""total_volume = suma NR din fixture."""
from holdout import run_holdout
result = run_holdout(FIXTURE, client_name="test_client")
assert result["total_volume"] == FIXTURE_TOTAL_VOL
def test_run_holdout_distinct():
"""total_distinct = numarul de randuri din fixture."""
from holdout import run_holdout
result = run_holdout(FIXTURE, client_name="test_client")
assert result["total_distinct"] == FIXTURE_DISTINCT
def test_run_holdout_verdict_sustinuta_pe_zipf_puternic():
"""Pe distributie Zipf puternica (o denumire = 95% din volum), verdict SUSTINUTA."""
from holdout import run_holdout
rows = [("REVIZIE", 9500)] + [(f"altceva_{i}", 1) for i in range(500)]
result = run_holdout(rows, client_name="zipf")
assert result["verdict"] == "SUSTINUTA"
def test_run_holdout_verdict_slaba_pe_distributie_plata():
"""Pe distributie uniforma (50 denumiri cu aceeasi frecventa), poate fi SLABA/NEVALIDABILA."""
from holdout import run_holdout
rows = [(f"op_{i}", 100) for i in range(100)] # 100 denumiri cu NR egal
result = run_holdout(rows, client_name="uniform")
# 90% din 100*100=10000 = 9000; necesita 90 din 100 denumiri = 90% -> NEVALIDABILA
assert result["verdict"] in ("SLABA", "NEVALIDABILA")

View File

@@ -0,0 +1,578 @@
"""TDD L14-S6 — Integrare Layer 2/3 in editor (suggestion-only, DUPA 5.15).
Scenarii acoperite:
- F1-regression CRITIC: SILVER/shared GOLD NU auto-trimit (resolve_prestatii neschimbat)
- pending_unmapped include sugestie GOLD partajat > SILVER > embeddings (precedenta Eng-F2)
- record_human_validation apelat la confirmare umana (POST /mapari -> shared_mappings)
- Degradare gratioasa cand embeddings indisponibil (mock is_available=False)
- Separare structurala #13: resolve_prestatii/load_mapping NU citesc tabelele de sugestii
"""
from __future__ import annotations
import os
import tempfile
import pytest
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def env(monkeypatch):
"""DB temporara cu schema initiata, auth dezactivata (mod dev)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "l14_s6_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield monkeypatch
get_settings.cache_clear()
@pytest.fixture()
def conn(env):
from app.db import get_connection
c = get_connection()
# Seed nomenclator (OE-1, OE-2, OE-3, OE-4 suficient pentru teste)
c.executemany(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
[
("OE-1", "REPARATIE MOTOR"),
("OE-2", "INTRETINERE"),
("OE-3", "REVIZIE PERIODICA"),
("OE-4", "REGLARE"),
],
)
c.commit()
yield c
c.close()
@pytest.fixture()
def client(env):
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
# --------------------------------------------------------------------------- #
# F1-regression CRITIC: SILVER/shared GOLD NU auto-trimit #
# --------------------------------------------------------------------------- #
def test_f1_silver_nu_auto_trimite(conn):
"""CRITICAL F1: un cod in SILVER (mapping_suggestions) NU produce auto-trimitere.
resolve_prestatii cu mapping gol + SILVER existent -> operatie ramane nemapata.
Submissionul ar ramane needs_mapping, NU queued.
"""
from app.shared_store import seed_suggestions
from app.mapping import resolve_prestatii
seed_suggestions(conn, [
{"denumire": "Revizie periodica", "cod_prestatie": "OE-3", "source": "llm", "confidence": 0.95},
])
conn.commit()
# resolve_prestatii cu mapping gol -> SILVER nu se vede
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP-REV", "denumire": "Revizie periodica"}],
{}, # operations_mapping gol
)
# Operatia ramane nemapata (SILVER nu e in resolve, #13)
assert resolved[0]["cod_prestatie"] is None
assert len(unmapped) == 1
def test_f1_shared_gold_nu_auto_trimite(conn):
"""CRITICAL F1: un cod in shared_mappings (GOLD partajat) NU produce auto-trimitere.
resolve_prestatii cu mapping gol + shared GOLD existent -> operatie ramane nemapata.
"""
from app.shared_store import record_human_validation
from app.mapping import resolve_prestatii
record_human_validation(conn, "Schimb ulei motor", "OE-3")
conn.commit()
# resolve_prestatii cu mapping gol -> GOLD partajat nu se vede
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP-ULEI", "denumire": "Schimb ulei motor"}],
{}, # operations_mapping gol
)
# Operatia ramane nemapata (GOLD partajat nu e in resolve, #13)
assert resolved[0]["cod_prestatie"] is None
assert len(unmapped) == 1
def test_f1_load_mapping_nu_citeste_shared_gold(conn):
"""Separare #13: load_mapping NU returneaza coduri din shared_mappings."""
from app.shared_store import record_human_validation
from app.mapping import load_mapping
record_human_validation(conn, "Revizie anuala", "OE-3")
conn.commit()
mapping = load_mapping(conn, account_id=1)
# GOLD partajat nu trebuie sa apara in load_mapping (citit de resolve_prestatii)
assert "Revizie anuala" not in mapping
# Maparea propriu-zisa (operations_mapping) ramane goala
assert len(mapping) == 0
# --------------------------------------------------------------------------- #
# enrich_suggestions: GOLD partajat > SILVER > embeddings #
# --------------------------------------------------------------------------- #
def test_enrich_fara_surse_returneaza_none(conn):
"""Fara GOLD/SILVER/embedding -> sugestie_principala = None."""
from app.mapping import enrich_suggestions
result = enrich_suggestions(conn, "Operatie inexistenta")
assert result["sugestie_principala"] is None
assert result["surse"]["gold_partajat"] is None
assert result["surse"]["silver"] is None
assert result["surse"]["embedding"] is None
def test_enrich_include_gold_partajat(conn):
"""enrich_suggestions returneaza sugestie GOLD partajat cand shared_mappings are match."""
from app.shared_store import record_human_validation
from app.mapping import enrich_suggestions
record_human_validation(conn, "Schimb ulei", "OE-3")
conn.commit()
result = enrich_suggestions(conn, "Schimb ulei")
assert result["sugestie_principala"] is not None
assert result["sugestie_principala"]["cod_prestatie"] == "OE-3"
assert result["sugestie_principala"]["sursa"] == "gold_partajat"
assert result["surse"]["gold_partajat"] == "OE-3"
def test_enrich_include_silver(conn):
"""enrich_suggestions returneaza sugestie SILVER cand mapping_suggestions are match."""
from app.shared_store import seed_suggestions
from app.mapping import enrich_suggestions
seed_suggestions(conn, [
{"denumire": "Reparatie motor", "cod_prestatie": "OE-1", "source": "llm", "confidence": 0.9},
])
conn.commit()
result = enrich_suggestions(conn, "Reparatie motor")
assert result["sugestie_principala"] is not None
assert result["sugestie_principala"]["cod_prestatie"] == "OE-1"
assert result["sugestie_principala"]["sursa"] == "silver"
assert result["surse"]["silver"] == "OE-1"
def test_enrich_precedenta_gold_peste_silver(conn):
"""Precedenta Eng-F2: GOLD partajat castiga fata de SILVER cand ambele exista."""
from app.shared_store import seed_suggestions, record_human_validation
from app.mapping import enrich_suggestions
# SILVER spune OE-1, GOLD spune OE-3
seed_suggestions(conn, [
{"denumire": "Verificare tehnica", "cod_prestatie": "OE-1", "source": "llm", "confidence": 0.8},
])
record_human_validation(conn, "Verificare tehnica", "OE-3")
conn.commit()
result = enrich_suggestions(conn, "Verificare tehnica")
assert result["sugestie_principala"] is not None
assert result["sugestie_principala"]["cod_prestatie"] == "OE-3"
assert result["sugestie_principala"]["sursa"] == "gold_partajat"
# SILVER prezent dar nu castiga
assert result["surse"]["silver"] == "OE-1"
assert result["surse"]["gold_partajat"] == "OE-3"
def test_enrich_degradare_embeddings_indisponibil(conn, monkeypatch):
"""Degradare gratioasa (#16b): cand embeddings nu e disponibil, nu eroare."""
import app.embeddings as emb_mod
monkeypatch.setattr(emb_mod, "is_available", lambda: False)
from app.mapping import enrich_suggestions
# Fara surse -> sugestie_principala = None, fara exceptie
result = enrich_suggestions(conn, "Operatie demo", include_embeddings=True)
assert result["sugestie_principala"] is None
assert result["surse"]["embedding"] is None
def test_enrich_corpus_gol_nu_incarca_modelul(conn, monkeypatch):
"""Bug fix (code-review): enrich_suggestions NU lazy-load-eaza modelul de 220MB
cand corpus-ul embeddings e gol.
Implementarea veche apela `is_available()` neconditionat -> `_get_engine()` ->
`_load_engine()` -> `FastEmbedBackend()` (incarcare sincrona 30-120s) chiar daca
`index_corpus` nu a fost apelat niciodata in productie -> corpus gol ->
`suggest_nearest` ar fi returnat [] oricum (zero beneficiu, cost mare).
Fix: poarta `has_corpus()` (ieftina, nu construieste engine-ul cand `_engine is None`).
"""
import app.embeddings as emb_mod
# Engine ne-initializat -> corpus gol prin definitie.
monkeypatch.setattr(emb_mod, "_engine", None, raising=False)
incarcari = {"n": 0}
orig_load = emb_mod._load_engine
def _spy_load():
incarcari["n"] += 1
return orig_load()
monkeypatch.setattr(emb_mod, "_load_engine", _spy_load)
from app.mapping import enrich_suggestions
result = enrich_suggestions(conn, "Operatie oarecare", include_embeddings=True)
assert result["surse"]["embedding"] is None
assert incarcari["n"] == 0, (
"Modelul de embeddings NU trebuie incarcat cand corpus-ul e gol "
f"(index_corpus nu e wired). _load_engine apelat de {incarcari['n']} ori."
)
class _FakeEmbedBackend:
"""Backend embedding determinist (3 dimensiuni keyword) — fara model real 230MB."""
def embed(self, texts):
out = []
for t in texts:
tl = str(t).lower()
out.append([
1.0 if "ulei" in tl else 0.0,
1.0 if "motor" in tl else 0.0,
1.0 if "frana" in tl else 0.0,
])
return out
def test_embeddings_functional_cand_flag_activ(conn, monkeypatch):
"""PRD #15: cu AUTOPASS_EMBEDDINGS_ENABLED=true, embeddings produce efectiv o sugestie.
Wire-uieste ensure_embeddings_corpus (corpus din nomenclator) + enrich_suggestions.
Backend injectat (determinist) -> nu incarca modelul real de 230MB.
"""
import app.embeddings as emb_mod
from app.embeddings import EmbeddingEngine
from app.config import get_settings
# Activeaza flagul + injecteaza backend fals in singleton-ul global.
monkeypatch.setenv("AUTOPASS_EMBEDDINGS_ENABLED", "true")
get_settings.cache_clear()
monkeypatch.setattr(emb_mod, "_engine", EmbeddingEngine(backend=_FakeEmbedBackend()))
# Nomenclatorul (din fixtura conn) are OE-1..OE-4; adaug coduri cu denumiri keyword.
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
("UL-1", "Schimb ulei"),
)
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
("FR-1", "Placute frana"),
)
conn.commit()
from app.mapping import ensure_embeddings_corpus, enrich_suggestions
ensure_embeddings_corpus(conn)
assert emb_mod.has_corpus(), "corpusul trebuie indexat cand flagul e activ"
# "schimbat uleiul motor" -> vector [1,1,0] -> cel mai apropiat = UL-1 (Schimb ulei).
result = enrich_suggestions(conn, "schimbat uleiul motor", include_embeddings=True)
assert result["surse"]["embedding"] == "UL-1", (
f"embeddings trebuie sa sugereze UL-1, got {result['surse']}"
)
get_settings.cache_clear()
def test_embeddings_flag_off_ramane_noop(conn, monkeypatch):
"""Cu flagul off (default), ensure_embeddings_corpus e no-op total (nu indexeaza)."""
import app.embeddings as emb_mod
from app.embeddings import EmbeddingEngine
from app.config import get_settings
monkeypatch.setenv("AUTOPASS_EMBEDDINGS_ENABLED", "false")
get_settings.cache_clear()
# Engine cu backend disponibil, dar flagul off -> NU se indexeaza nimic.
monkeypatch.setattr(emb_mod, "_engine", EmbeddingEngine(backend=_FakeEmbedBackend()))
from app.mapping import ensure_embeddings_corpus
ensure_embeddings_corpus(conn)
assert not emb_mod.has_corpus(), "flag off -> corpusul NU trebuie indexat"
get_settings.cache_clear()
def test_enrich_silver_nul_ignorat(conn):
"""SILVER cu is_nul=1 (non-operatie) NU apare ca sugestie."""
from app.shared_store import seed_suggestions
from app.mapping import enrich_suggestions
seed_suggestions(conn, [
{"denumire": "ITP CT 12 ABC", "is_nul": True, "source": "llm", "confidence": 0.99},
])
conn.commit()
result = enrich_suggestions(conn, "ITP CT 12 ABC")
assert result["sugestie_principala"] is None
assert result["surse"]["silver"] is None
# --------------------------------------------------------------------------- #
# pending_unmapped: include sugestie_principala #
# --------------------------------------------------------------------------- #
def test_pending_unmapped_include_sugestie_principala(conn):
"""pending_unmapped returneaza entries cu sugestie_principala din GOLD/SILVER."""
from app.shared_store import record_human_validation
from app.mapping import pending_unmapped
import json
record_human_validation(conn, "Schimb ulei motor", "OE-3")
conn.commit()
# Creeaza un submission needs_mapping cu "Schimb ulei motor"
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key) "
"VALUES (1, 'needs_mapping', ?, 'key-test-001')",
(json.dumps({
"vin": "WVWZZZ1KZAW001111",
"prestatii": [{"cod_op_service": "OP-ULEI", "denumire": "Schimb ulei motor"}],
}),),
)
conn.commit()
pending = pending_unmapped(conn, account_id=1)
assert len(pending) == 1
entry = pending[0]
# sugestie_principala adaugat de enrich_suggestions (L14-S6)
assert "sugestie_principala" in entry
sp = entry["sugestie_principala"]
assert sp is not None
assert sp["cod_prestatie"] == "OE-3"
assert sp["sursa"] == "gold_partajat"
def test_pending_unmapped_fara_surse_sugestie_principala_none(conn, monkeypatch):
"""pending_unmapped -> sugestie_principala = None cand nu exista nicio sursa.
Dezactiveaza embeddings prin poarta reala `has_corpus`=False (gate-ul folosit de
enrich_suggestions dupa wiring), independent de starea singleton-ului global lasata
de alte teste (izolare de ordine).
"""
import app.embeddings as emb_mod
monkeypatch.setattr(emb_mod, "has_corpus", lambda: False)
monkeypatch.setattr(emb_mod, "is_available", lambda: False)
from app.mapping import pending_unmapped
import json
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key) "
"VALUES (1, 'needs_mapping', ?, 'key-test-002')",
(json.dumps({
"vin": "WVWZZZ1KZAW002222",
"prestatii": [{"cod_op_service": "OP-FARA-SURSA", "denumire": "Operatie de nisa"}],
}),),
)
conn.commit()
pending = pending_unmapped(conn, account_id=1)
assert len(pending) == 1
entry = pending[0]
assert "sugestie_principala" in entry
assert entry["sugestie_principala"] is None
# --------------------------------------------------------------------------- #
# record_human_validation apelat la confirmare umana #
# --------------------------------------------------------------------------- #
def test_record_human_validation_la_post_mapari(env, client):
"""POST /mapari (tab Mapari) -> record_human_validation scrie in shared_mappings.
Testul verifica ca GOLD partajat se populeaza automat la confirmarea umana
din interfata de mapari.
"""
from app.db import get_connection
import json
# Creeaza un submission needs_mapping
conn_setup = get_connection()
try:
conn_setup.executemany(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
[("OE-3", "REVIZIE PERIODICA"), ("OE-1", "REPARATIE")],
)
conn_setup.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key) "
"VALUES (1, 'needs_mapping', ?, 'key-hv-001')",
(json.dumps({
"vin": "WVWZZZ1KZAW003333",
"prestatii": [{"cod_op_service": "OP-REV", "denumire": "Revizie anuala"}],
}),),
)
conn_setup.commit()
finally:
conn_setup.close()
# POST /mapari cu denumire (L14-S6: form include denumire hidden)
resp = client.post(
"/mapari",
data={
"cod_op_service": "OP-REV",
"cod_prestatie": "OE-3",
"denumire": "Revizie anuala",
"csrf_token": "",
},
)
assert resp.status_code == 200, resp.text
# Verifica ca shared_mappings contine intrarea
conn_check = get_connection()
try:
from app.shared_store import lookup_shared_gold
row = lookup_shared_gold(conn_check, "Revizie anuala")
assert row is not None, "record_human_validation nu a scris in shared_mappings"
assert row["cod_prestatie"] == "OE-3"
finally:
conn_check.close()
def test_record_human_validation_la_mapeaza_inline(env, client):
"""POST /trimitere/{id}/mapeaza -> record_human_validation scrie in shared_mappings.
Testul verifica ca GOLD partajat se populeaza la maparea inline din panoul de detaliu.
"""
from app.db import get_connection
import json
# Setup submission needs_mapping
conn_setup = get_connection()
try:
conn_setup.executemany(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
[("OE-1", "REPARATIE"), ("OE-3", "REVIZIE")],
)
conn_setup.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key) "
"VALUES (1, 'needs_mapping', ?, 'key-inline-001')",
(json.dumps({
"vin": "WVWZZZ1KZAW004444",
"data_prestatie": "2026-06-15",
"odometru_final": 100000,
"prestatii": [{"cod_op_service": "OP-REP", "denumire": "Reparatie chiulasa"}],
}),),
)
conn_setup.commit()
# Preia ID-ul submission-ului
sid = conn_setup.execute("SELECT id FROM submissions WHERE idempotency_key='key-inline-001'").fetchone()["id"]
finally:
conn_setup.close()
resp = client.post(
f"/trimitere/{sid}/mapeaza",
data={
"cod_op_service": "OP-REP",
"cod_prestatie": "OE-1",
"csrf_token": "",
},
)
assert resp.status_code == 200, resp.text
# Verifica shared_mappings
conn_check = get_connection()
try:
from app.shared_store import lookup_shared_gold
row = lookup_shared_gold(conn_check, "Reparatie chiulasa")
assert row is not None, "record_human_validation nu a scris in shared_mappings pentru mapeaza inline"
assert row["cod_prestatie"] == "OE-1"
finally:
conn_check.close()
def test_mapare_salvata_fara_denumire_nu_polueaza_gold(env, client):
"""Bug fix (code-review 5.15): editarea unei mapari salvate FARA denumire NU scrie
o intrare bogus in GOLD partajat (cheiata pe cod_op_service in loc de denumire umana).
Formularul din _mapari.html nu trimite denumire; vechiul fallback `denumire or
cod_op_service` scria shared_mappings cheiat pe cod_op_service -> lookup_shared_gold
(pe denumirea umana) nu il potrivea niciodata -> poluare. Fix: _record_gold_validation
sare scrierea cand denumire lipseste sau == cod_op_service.
"""
from app.db import get_connection
conn_setup = get_connection()
try:
conn_setup.execute(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
("OE-1", "REPARATIE"),
)
conn_setup.commit()
finally:
conn_setup.close()
# Editare mapare salvata FARA denumire (ca formularul real din _mapari.html).
resp = client.post(
"/mapari/salvate",
data={
"cod_op_service": "OP-SALV",
"cod_prestatie": "OE-1",
"csrf_token": "",
},
)
assert resp.status_code == 200, resp.text
conn_check = get_connection()
try:
from app.shared_store import lookup_shared_gold
# NICIO intrare bogus cheiata pe cod_op_service.
assert lookup_shared_gold(conn_check, "OP-SALV") is None, (
"GOLD partajat poluat cu cod_op_service ca si cheie (denumire lipsa)"
)
finally:
conn_check.close()
# --------------------------------------------------------------------------- #
# Separare structurala #13 (redundant cu test_shared_store dar explicit L14) #
# --------------------------------------------------------------------------- #
def test_separare_silver_din_resolve_prestatii():
"""#13: resolve_prestatii nu citeste mapping_suggestions (SILVER)."""
from app.mapping import resolve_prestatii
# Apelam fara conn (pur) — SILVER nu e parametru si nu e accesat
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP-TEST", "denumire": "Test silver"}],
{}, # mapping gol
)
assert resolved[0]["cod_prestatie"] is None
assert len(unmapped) == 1
def test_separare_shared_gold_din_resolve_prestatii():
"""#13: resolve_prestatii nu citeste shared_mappings (GOLD partajat)."""
from app.mapping import resolve_prestatii
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP-TEST2", "denumire": "Test gold partajat"}],
{}, # mapping gol
)
assert resolved[0]["cod_prestatie"] is None
assert len(unmapped) == 1

491
tests/test_or_label.py Normal file
View File

@@ -0,0 +1,491 @@
"""Teste pentru or_label.py — etichetator batch offline OpenRouter (L14-S1).
TDD: aceste teste TREBUIE sa fie RED inainte de implementare, GREEN dupa.
Fara apeluri LLM reale — or_common.call() este MOCK-at in toate testele
care ating API-ul. Testeaza: grupare+propagare, vot ensemble, scrub PII,
resumabilitate, format output.
Rulare: python3 -m pytest tests/test_or_label.py -v
"""
import sys
import os
import json
# Setam cheia inainte de import (or_common.py o citeste la nivel de modul).
# Valoarea nu conteaza in teste (call() e mock-at).
os.environ.setdefault("OPENROUTER_KEY", "test-key-mock")
# Adaugam calea tools/mapare-llm/ la sys.path ca sa putem importa or_label
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS_DIR = os.path.abspath(os.path.join(HERE, "..", "tools", "mapare-llm"))
if TOOLS_DIR not in sys.path:
sys.path.insert(0, TOOLS_DIR)
import or_label # subject under test
import or_common as oc # pentru VALID, CODURI, scrub
# ---------------------------------------------------------------------------
# Grupare pe similaritate + propagare cod
# ---------------------------------------------------------------------------
class TestGroupBySimilarity:
"""Verifica logica de grupare greedy pe fuzz.token_sort_ratio."""
def test_similar_strings_grouped_in_one(self):
"""Denumiri aproape identice -> un singur reprezentant, ceilalti membri."""
# Scoruri masurate: token_sort_ratio("REGLAT DIRECTIE","REGLAT DIRECTIA")=93
# token_sort_ratio("REGLAT DIRECTIE","REGLARE DIRECTIE")=90
corpus = [
("REGLAT DIRECTIE", 100), # reprezentant (frecventa maxima)
("REGLAT DIRECTIA", 80), # similar: 93 >= 85
("REGLARE DIRECTIE", 60), # similar: 90 >= 85
]
groups = or_label.group_by_similarity(corpus, threshold=85)
assert len(groups) == 1
g = groups[0]
assert g["rep"] == "REGLAT DIRECTIE"
assert len(g["members"]) == 2
member_names = [m[0] for m in g["members"]]
assert "REGLAT DIRECTIA" in member_names
assert "REGLARE DIRECTIE" in member_names
def test_distinct_strings_separate_groups(self):
"""Denumiri foarte diferite -> grupuri separate."""
corpus = [
("REVIZIE", 100),
("D/R BARA FATA", 80),
("SCHIMB ULEI MOTOR", 60),
]
groups = or_label.group_by_similarity(corpus, threshold=85)
assert len(groups) == 3
def test_representative_is_highest_frequency(self):
"""Reprezentantul = cel cu frecventa maxima (primul in sorted desc)."""
corpus = [
("INLOCUIT FILTRU AER", 300), # frecventa maxima
("INLOCUIRE FILTRU AER", 100), # similar: 92 >= 85
]
groups = or_label.group_by_similarity(corpus, threshold=85)
assert len(groups) == 1
assert groups[0]["rep"] == "INLOCUIT FILTRU AER"
assert groups[0]["freq"] == 300
def test_singleton_group(self):
"""O denumire fara vecini -> grup cu 0 membri."""
corpus = [("REVIZIE", 100)]
groups = or_label.group_by_similarity(corpus, threshold=85)
assert len(groups) == 1
assert groups[0]["rep"] == "REVIZIE"
assert groups[0]["members"] == []
def test_below_threshold_not_grouped(self):
"""Similaritate sub threshold -> grupuri separate."""
# D/R BARA FATA vs D/R BARA SPATE = 81 < 85
corpus = [
("D/R BARA FATA", 200),
("D/R BARA SPATE", 180),
]
groups = or_label.group_by_similarity(corpus, threshold=85)
assert len(groups) == 2
# ---------------------------------------------------------------------------
# Vot ensemble (acord/dezacord) — fara apeluri LLM
# ---------------------------------------------------------------------------
class TestEnsembleVote:
"""Verifica logica de vot pe coduri (nu self-confidence)."""
def test_unanim_cod_rar(self):
"""Ambele modele de acord pe cod RAR -> confidence high, sursa unanim."""
votes = {
"nvidia/nemotron-3-super-120b-a12b:free": "OE-3",
"nvidia/nemotron-nano-9b-v2:free": "OE-3",
}
cod, confidence, sursa = or_label.ensemble_vote(votes)
assert cod == "OE-3"
assert confidence == "high"
assert "unanim" in sursa
def test_unanim_nul_marcat_separat(self):
"""Ambele spun NUL -> NUL confidence high, NUL nu e promovat la cod RAR."""
votes = {
"nvidia/nemotron-3-super-120b-a12b:free": "NUL",
"nvidia/nemotron-nano-9b-v2:free": "NUL",
}
cod, confidence, sursa = or_label.ensemble_vote(votes)
assert cod == "NUL"
assert confidence == "high"
# NUL nu este in codurile OE-* (nu e promovat)
rar_codes = {c.split("=")[0] for c in oc.CODURI.replace(", ", ",").split(",")} - {"NUL"}
assert cod not in rar_codes
assert "nul" in sursa.lower()
def test_dezacord_total(self):
"""Modele nu se inteleg -> needs_mapping."""
votes = {
"nvidia/nemotron-3-super-120b-a12b:free": "OE-2",
"nvidia/nemotron-nano-9b-v2:free": "OE-4",
}
cod, confidence, sursa = or_label.ensemble_vote(votes)
assert confidence == "needs_mapping"
assert "dezacord" in sursa
def test_parse_fail_partial(self):
"""Un model intoarce '?' (parse-fail), altul cod valid -> dezacord (conservator)."""
votes = {
"nvidia/nemotron-3-super-120b-a12b:free": "OE-1",
"nvidia/nemotron-nano-9b-v2:free": "?",
}
cod, confidence, sursa = or_label.ensemble_vote(votes)
# Conservator: fara unanimitate -> needs_mapping
assert confidence == "needs_mapping"
def test_toate_parse_fail(self):
"""Ambele modele intorc '?' -> needs_mapping."""
votes = {
"nvidia/nemotron-3-super-120b-a12b:free": "?",
"nvidia/nemotron-nano-9b-v2:free": "?",
}
cod, confidence, sursa = or_label.ensemble_vote(votes)
assert confidence == "needs_mapping"
def test_cod_invalid_returnat_de_llm(self):
"""LLM returneaza cod necunoscut (nu e in VALID) -> needs_mapping."""
votes = {
"nvidia/nemotron-3-super-120b-a12b:free": "OE-99",
"nvidia/nemotron-nano-9b-v2:free": "OE-99",
}
cod, confidence, sursa = or_label.ensemble_vote(votes)
assert confidence == "needs_mapping"
# ---------------------------------------------------------------------------
# Scrub PII — refoloseste or_common.scrub (F3)
# ---------------------------------------------------------------------------
class TestScrubPII:
"""Scrub-ul PII e integrat in or_common.call() si testat independent."""
def test_nr_inmatriculare_scrubbed(self):
"""Nr de inmatriculare (ex: CT 12 ABC) este scrubuit."""
s = "ITP CT 12 ABC"
assert "[NR]" in oc.scrub(s)
def test_vin_scrubbed(self):
"""VIN (17 char alfanumeric) este scrubuit."""
vin = "WVWZZZ1KZAM000001" # 17 caractere, format VIN
s = f"VERIFICAT {vin}"
assert "[VIN]" in oc.scrub(s)
def test_text_normal_nemodificat(self):
"""Text fara PII ramane neatins."""
s = "REVIZIE PERIODICA MOTOR"
assert oc.scrub(s) == s
def test_scrub_in_batch_call(self, monkeypatch):
"""or_common.call() aplica scrub intern inainte de trimitere."""
trimis = []
def mock_urlopen(req, timeout=None):
import io
body_str = req.data.decode()
trimis.append(body_str)
# Simuleaza raspuns LLM
resp = json.dumps({
"choices": [{"message": {"content": json.dumps({"rez": [{"i": 1, "cod": "NUL"}]})}}]
}).encode()
class FakeResp:
def __enter__(self): return self
def __exit__(self, *a): pass
def read(self): return resp
def __iter__(self): return iter([resp])
import urllib.request
r = FakeResp()
r.read = lambda: resp
# urllib.request.urlopen returneaza context manager
class CM:
def __enter__(self_): return self_
def __exit__(self_, *a): pass
def read(self_): return resp
import json as _json
class FakeFile:
def read(self_): return resp
# Patch-uim json.load
monkeypatch.setattr("json.load", lambda f: _json.loads(resp))
return CM()
batch = ["ITP CT 12 ABC"]
# Verificam ca scrub e aplicat in continut trimis
# (nu putem usor mock-ui urlopen, asa ca testam scrub() direct)
scrubbed = oc.scrub("ITP CT 12 ABC")
assert "[NR]" in scrubbed
# Deci batch-ul trimis nu va contine nr original
assert "CT 12 ABC" not in scrubbed
# ---------------------------------------------------------------------------
# Resumabilitate
# ---------------------------------------------------------------------------
class TestResumabil:
"""Etichetatorul reia de unde a ramas din partial.json."""
def test_skip_already_labeled(self, monkeypatch):
"""Reprezentantii deja in partial NU sunt retrimisi la LLM."""
call_reps = []
def mock_call(model, batch, **kw):
call_reps.extend(batch)
return ["OE-1"] * len(batch), {"ms": 100, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "REVIZIE", "freq": 5000, "members": []}]
# REVIZIE e deja in partial
partial = {
"REVIZIE": {
"cod": "OE-3",
"confidence": "high",
"sursa": "ensemble-unanim",
"votes": {},
}
}
result = or_label.label_groups(groups, partial, batch_size=20, pace=0)
# LLM nu trebuia apelat pentru REVIZIE
assert "REVIZIE" not in call_reps
# Codul din partial e pastrat
assert result["REVIZIE"]["cod"] == "OE-3"
def test_labels_new_reps(self, monkeypatch):
"""Reprezentantii noi (nu in partial) sunt etichetati."""
call_count = [0]
def mock_call(model, batch, **kw):
call_count[0] += 1
return ["OE-1"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "D/R BARA FATA", "freq": 3000, "members": []}]
partial = {}
result = or_label.label_groups(groups, partial, batch_size=20, pace=0)
# LLM a fost apelat (cel putin o data per model)
assert call_count[0] >= len(or_label.MODELS)
assert "D/R BARA FATA" in result
assert result["D/R BARA FATA"]["cod"] == "OE-1"
def test_partial_mixt(self, monkeypatch):
"""Partial cu unii etichetati, altii noi -> eticheteaza doar cei noi."""
labeled_batches = []
def mock_call(model, batch, **kw):
labeled_batches.extend(batch)
return ["OE-2"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [
{"rep": "REVIZIE", "freq": 5000, "members": []}, # deja in partial
{"rep": "D/R BARA FATA", "freq": 3000, "members": []}, # nou
]
partial = {
"REVIZIE": {"cod": "OE-3", "confidence": "high",
"sursa": "ensemble-unanim", "votes": {}}
}
result = or_label.label_groups(groups, partial, batch_size=20, pace=0)
# Doar D/R BARA FATA trebuie trimis la LLM
assert "REVIZIE" not in labeled_batches
assert "D/R BARA FATA" in labeled_batches
# Partial complet: ambele chei prezente
assert "REVIZIE" in result
assert "D/R BARA FATA" in result
# REVIZIE pastrat din partial
assert result["REVIZIE"]["cod"] == "OE-3"
def test_load_partial_fisier_gol(self, tmp_path):
"""load_partial pe fisier inexistent intoarce dict gol."""
result = or_label.load_partial(str(tmp_path / "inexistent.json"))
assert result == {}
def test_save_si_load_partial(self, tmp_path):
"""save_partial + load_partial sunt inversele una alteia."""
path = str(tmp_path / "partial.json")
data = {
"REVIZIE": {"cod": "OE-3", "confidence": "high",
"sursa": "ensemble-unanim", "votes": {}}
}
or_label.save_partial(path, data)
loaded = or_label.load_partial(path)
assert loaded == data
# ---------------------------------------------------------------------------
# Format output si propagare
# ---------------------------------------------------------------------------
class TestOutputFormat:
"""expand_to_all produce outputul cu campurile cerute si propagare corecta."""
def test_campuri_obligatorii(self, monkeypatch):
"""Fiecare intrare are: denumire, cod, sursa, confidence."""
def mock_call(model, batch, **kw):
return ["OE-3"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "REVIZIE", "freq": 5000,
"members": [("REVIZIE MICA", 100)]}]
partial = {}
partial = or_label.label_groups(groups, partial, batch_size=20, pace=0)
results = or_label.expand_to_all(groups, partial)
assert len(results) == 2 # reprezentant + 1 membru
for row in results:
assert "denumire" in row
assert "cod" in row
assert "sursa" in row
assert "confidence" in row
assert "grup_rep" in row
def test_reprezentant_cu_sursa_ensemble(self, monkeypatch):
"""Reprezentantul are sursa 'ensemble-*', nu 'propagat'."""
def mock_call(model, batch, **kw):
return ["OE-3"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "REVIZIE", "freq": 5000, "members": []}]
partial = {}
partial = or_label.label_groups(groups, partial, batch_size=20, pace=0)
results = or_label.expand_to_all(groups, partial)
row = results[0]
assert row["denumire"] == "REVIZIE"
assert row["sursa"].startswith("ensemble-")
assert row["sursa"] != "propagat"
def test_membru_primeste_sursa_propagat(self, monkeypatch):
"""Membrii grupului au sursa='propagat' si codul reprezentantului."""
def mock_call(model, batch, **kw):
return ["OE-3"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "REVIZIE", "freq": 5000,
"members": [("REVIZIE MICA", 100), ("REVIZIE AUTO", 80)]}]
partial = {}
partial = or_label.label_groups(groups, partial, batch_size=20, pace=0)
results = or_label.expand_to_all(groups, partial)
assert len(results) == 3
membri = [r for r in results if r["sursa"] == "propagat"]
assert len(membri) == 2
for m in membri:
assert m["cod"] == "OE-3" # propagat de la reprezentant
assert m["grup_rep"] == "REVIZIE"
def test_nul_propagat_ca_nul_nu_ca_cod_rar(self, monkeypatch):
"""NUL este propagat ca NUL la membri, nu convertit la cod RAR."""
def mock_call(model, batch, **kw):
return ["NUL"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "ITP", "freq": 50,
"members": [("ITP + RAR", 30)]}]
partial = {}
partial = or_label.label_groups(groups, partial, batch_size=20, pace=0)
results = or_label.expand_to_all(groups, partial)
rar_codes = {c.split("=")[0] for c in oc.CODURI.replace(", ", ",").split(",")} - {"NUL"}
for row in results:
assert row["cod"] == "NUL"
assert row["cod"] not in rar_codes
def test_dezacord_propagat_ca_needs_mapping(self, monkeypatch):
"""Dezacordul ensemble se propaga la membri ca needs_mapping."""
call_n = [0]
def mock_call(model, batch, **kw):
call_n[0] += 1
# Modelele dau coduri diferite in functie de ordinea apelului
cod = "OE-1" if call_n[0] % 2 == 1 else "OE-3"
return [cod] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "call", mock_call)
groups = [{"rep": "REGLAT DIRECTIE", "freq": 200,
"members": [("REGLAT DIRECTIA", 150)]}]
partial = {}
partial = or_label.label_groups(groups, partial, batch_size=20, pace=0)
results = or_label.expand_to_all(groups, partial)
# Ambii (rep + member) trebuie sa aiba needs_mapping
for row in results:
assert row["confidence"] == "needs_mapping"
# ---------------------------------------------------------------------------
# Integrare end-to-end (fara apeluri reale)
# ---------------------------------------------------------------------------
class TestRunIntegrare:
"""Verifica run() cu corpus mock si LLM mock."""
def test_run_produce_fisier_output(self, tmp_path, monkeypatch):
"""run() salveaza fisierul de output JSON."""
def mock_corpus():
return [("REVIZIE", 5000), ("D/R BARA FATA", 3000)]
def mock_call(model, batch, **kw):
return ["OE-3"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "corpus_by_freq", mock_corpus)
monkeypatch.setattr(or_label.oc, "call", mock_call)
out = str(tmp_path / "final.json")
partial = str(tmp_path / "partial.json")
results = or_label.run(n=2, output_path=out, partial_path=partial,
threshold=85, batch_size=20, pace=0)
assert os.path.exists(out)
loaded = json.load(open(out, encoding="utf-8"))
assert len(loaded) >= 2
# Toate intrarile au campurile cerute
for row in loaded:
assert "denumire" in row
assert "cod" in row
def test_run_resumabil(self, tmp_path, monkeypatch):
"""run() cu partial existent sare intrarile deja etichetate."""
call_count = [0]
def mock_corpus():
return [("REVIZIE", 5000), ("D/R BARA FATA", 3000)]
def mock_call(model, batch, **kw):
call_count[0] += 1
return ["OE-1"] * len(batch), {"ms": 50, "err": None}
monkeypatch.setattr(or_label.oc, "corpus_by_freq", mock_corpus)
monkeypatch.setattr(or_label.oc, "call", mock_call)
partial_path = str(tmp_path / "partial.json")
# Pre-populam partial cu REVIZIE
or_label.save_partial(partial_path, {
"REVIZIE": {"cod": "OE-3", "confidence": "high",
"sursa": "ensemble-unanim", "votes": {}}
})
out = str(tmp_path / "final.json")
results = or_label.run(n=2, output_path=out, partial_path=partial_path,
threshold=85, batch_size=20, pace=0)
# LLM apelat DOAR pentru D/R BARA FATA (nu si REVIZIE)
# call_count = 2 (un apel per model, pentru un singur representant)
assert call_count[0] == len(or_label.MODELS)

290
tests/test_shared_store.py Normal file
View File

@@ -0,0 +1,290 @@
"""TDD pentru L14-S3 — shared_store: SILVER (mapping_suggestions) + GOLD partajat (shared_mappings).
Scenarii acoperite:
- seed_suggestions: idempotent (INSERT OR IGNORE, nu clobberuie randuri existente)
- seed_suggestions: NUL marcat -> cod_prestatie NULL, is_nul=1 (supresie, #4)
- lookup_suggestion: cauta pe denumire_normalizata
- lookup_shared_gold: cauta pe denumire_normalizata
- record_human_validation: insert nou + increment confirmations la al doilea apel
- provenance/source/confidence pastrate (#5)
"""
from __future__ import annotations
import os
import tempfile
import pytest
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def env(monkeypatch):
"""DB temporara cu schema initiata."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "shared_store_test.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield monkeypatch
get_settings.cache_clear()
@pytest.fixture()
def conn(env):
from app.db import get_connection
c = get_connection()
yield c
c.close()
# --------------------------------------------------------------------------- #
# seed_suggestions — strat SILVER #
# --------------------------------------------------------------------------- #
def test_seed_suggestions_inserteaza(conn):
"""seed_suggestions insereaza un rand si returneaza 1 (numarul de randuri inserate)."""
from app.shared_store import seed_suggestions, lookup_suggestion
n = seed_suggestions(conn, [
{"denumire": "Schimb ulei motor", "cod_prestatie": "OE-3", "source": "llm", "confidence": 0.9},
])
conn.commit()
assert n == 1
row = lookup_suggestion(conn, "Schimb ulei motor")
assert row is not None
assert row["cod_prestatie"] == "OE-3"
assert row["source"] == "llm"
assert abs(row["confidence"] - 0.9) < 0.001
assert row["is_nul"] == 0
def test_seed_suggestions_idempotent(conn):
"""seed_suggestions de doua ori cu acelasi item -> al doilea INSERT OR IGNORE, n=0 la re-seed."""
from app.shared_store import seed_suggestions, lookup_suggestion
n1 = seed_suggestions(conn, [
{"denumire": "Verificare faruri", "cod_prestatie": "OE-2", "source": "llm", "confidence": 0.8},
])
conn.commit()
n2 = seed_suggestions(conn, [
{"denumire": "Verificare faruri", "cod_prestatie": "OE-2", "source": "llm", "confidence": 0.8},
])
conn.commit()
assert n1 == 1
assert n2 == 0 # INSERT OR IGNORE: randul deja exista
row = lookup_suggestion(conn, "Verificare faruri")
assert row is not None
assert row["cod_prestatie"] == "OE-2"
def test_seed_suggestions_nu_clobberuie_randul_existent(conn):
"""Re-seed cu cod diferit -> INSERT OR IGNORE pastreaza valoarea veche (#2)."""
from app.shared_store import seed_suggestions, lookup_suggestion
seed_suggestions(conn, [
{"denumire": "Reparatie motor", "cod_prestatie": "OE-1", "source": "llm", "confidence": 0.85},
])
conn.commit()
# Al doilea seed cu alt cod: trebuie ignorat (nu suprascrie)
seed_suggestions(conn, [
{"denumire": "Reparatie motor", "cod_prestatie": "OE-2", "source": "llm", "confidence": 0.5},
])
conn.commit()
row = lookup_suggestion(conn, "Reparatie motor")
assert row is not None
assert row["cod_prestatie"] == "OE-1" # valoarea veche pastrata
def test_seed_suggestions_nul_marcat_fara_cod(conn):
"""is_nul=True -> cod_prestatie NULL, is_nul=1 in DB (supresie, #4)."""
from app.shared_store import seed_suggestions, lookup_suggestion
seed_suggestions(conn, [
{"denumire": "ITP CT 12 ABC", "is_nul": True, "source": "llm", "confidence": 0.95},
])
conn.commit()
row = lookup_suggestion(conn, "ITP CT 12 ABC")
assert row is not None
assert row["is_nul"] == 1
assert row["cod_prestatie"] is None # NUL nu se promoveaza la cod (#4)
def test_seed_suggestions_nul_cu_cod_explicit_tot_nul(conn):
"""Daca is_nul=True, cod_prestatie e ignorat si stocat NULL (supresie stricta, #4)."""
from app.shared_store import seed_suggestions, lookup_suggestion
seed_suggestions(conn, [
{
"denumire": "DISCOUNT MATERIALE 5%",
"cod_prestatie": "OE-1", # ignorat cand is_nul=True
"is_nul": True,
"source": "llm",
"confidence": 0.99,
},
])
conn.commit()
row = lookup_suggestion(conn, "DISCOUNT MATERIALE 5%")
assert row is not None
assert row["is_nul"] == 1
assert row["cod_prestatie"] is None # cod explicit ignorat cand is_nul
def test_seed_suggestions_normalizare_diacritice(conn):
"""Lookup pe forma cu diacritice gaseste randul seedat fara diacritice (normalize_for_match)."""
from app.shared_store import seed_suggestions, lookup_suggestion
seed_suggestions(conn, [
{"denumire": "Înlocuit filtru aer", "cod_prestatie": "OE-3", "source": "llm", "confidence": 0.7},
])
conn.commit()
# Lookup cu accentele, fara accente, uppercase — trebuie sa gaseasca acelasi rand
row1 = lookup_suggestion(conn, "Înlocuit filtru aer")
row2 = lookup_suggestion(conn, "Inlocuit filtru aer")
row3 = lookup_suggestion(conn, "INLOCUIT FILTRU AER")
assert row1 is not None and row1["cod_prestatie"] == "OE-3"
assert row2 is not None and row2["cod_prestatie"] == "OE-3"
assert row3 is not None and row3["cod_prestatie"] == "OE-3"
def test_lookup_suggestion_lipseste_returneaza_none(conn):
"""lookup_suggestion pe o denumire care nu exista -> None."""
from app.shared_store import lookup_suggestion
row = lookup_suggestion(conn, "Denumire inexistenta")
assert row is None
# --------------------------------------------------------------------------- #
# record_human_validation + lookup_shared_gold — strat GOLD partajat #
# --------------------------------------------------------------------------- #
def test_record_human_validation_insert(conn):
"""Prima confirmare umana creeaza un rand nou in shared_mappings."""
from app.shared_store import record_human_validation, lookup_shared_gold
record_human_validation(
conn,
denumire="Schimb ulei",
cod_prestatie="oe-3", # se normalizeaza la OE-3
source="human",
provenance="cont_2/user@test.com",
confidence=1.0,
)
conn.commit()
row = lookup_shared_gold(conn, "Schimb ulei")
assert row is not None
assert row["cod_prestatie"] == "OE-3" # normalizat uppercase
assert row["source"] == "human"
assert row["provenance"] == "cont_2/user@test.com"
assert abs(row["confidence"] - 1.0) < 0.001
assert row["confirmations"] == 1
def test_record_human_validation_increment_confirmations(conn):
"""A doua confirmare umana pe aceeasi denumire -> confirmations += 1."""
from app.shared_store import record_human_validation, lookup_shared_gold
record_human_validation(conn, "Revizie anuala", "OE-3")
conn.commit()
record_human_validation(conn, "Revizie anuala", "OE-3")
conn.commit()
row = lookup_shared_gold(conn, "Revizie anuala")
assert row is not None
assert row["confirmations"] == 2
def test_record_human_validation_normalizare(conn):
"""Lookup pe diacritice sau uppercase gaseste acelasi rand GOLD."""
from app.shared_store import record_human_validation, lookup_shared_gold
record_human_validation(conn, "Înlocuit garnitura chiulasa", "OE-1")
conn.commit()
row1 = lookup_shared_gold(conn, "Înlocuit garnitura chiulasa")
row2 = lookup_shared_gold(conn, "Inlocuit garnitura chiulasa")
row3 = lookup_shared_gold(conn, "INLOCUIT GARNITURA CHIULASA")
assert row1 is not None and row1["cod_prestatie"] == "OE-1"
assert row2 is not None
assert row3 is not None
def test_lookup_shared_gold_lipseste_returneaza_none(conn):
"""lookup_shared_gold pe denumire inexistenta -> None."""
from app.shared_store import lookup_shared_gold
row = lookup_shared_gold(conn, "Operatie fara GOLD")
assert row is None
def test_provenance_source_confidence_pastrate(conn):
"""source, provenance, confidence sunt stocate si returnate corect (#5)."""
from app.shared_store import seed_suggestions, lookup_suggestion
seed_suggestions(conn, [
{
"denumire": "Reglat directie",
"cod_prestatie": "OE-2",
"source": "embedding",
"confidence": 0.73,
},
])
conn.commit()
row = lookup_suggestion(conn, "Reglat directie")
assert row is not None
assert row["source"] == "embedding"
assert abs(row["confidence"] - 0.73) < 0.001
# --------------------------------------------------------------------------- #
# Separare structurala (#13): tabelele noi NU sunt citite de resolve_prestatii#
# --------------------------------------------------------------------------- #
def test_mapping_suggestions_nu_e_folosita_de_resolve_prestatii():
"""resolve_prestatii NU citeste din mapping_suggestions — separare structurala (#13).
Daca un cod e in SILVER dar nu in operations_mapping, resolve_prestatii
NU il gaseste -> submission ramane needs_mapping (om in bucla).
"""
from app.mapping import resolve_prestatii
# Apelam resolve_prestatii fara niciun mapping -> operatia e nemapata
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP_SILVER", "denumire": "Reglat faruri"}],
{}, # operations_mapping gol
)
assert resolved[0]["cod_prestatie"] is None
assert len(unmapped) == 1
# Nu exista cale prin care SILVER sa ajunga in resolve fara wiring explicit (L14-S6)
def test_shared_mappings_nu_e_folosita_de_resolve_prestatii():
"""resolve_prestatii NU citeste din shared_mappings — separare structurala (#13).
Chiar daca GOLD partajat exista, resolve_prestatii nu il vede fara wiring explicit.
"""
from app.mapping import resolve_prestatii
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP_GOLD", "denumire": "Revizie periodica"}],
{}, # operations_mapping gol
)
assert resolved[0]["cod_prestatie"] is None
assert len(unmapped) == 1

View File

@@ -186,3 +186,187 @@ def test_fragmente_fara_fundal_hardcodat():
"Fragmente cu fundal hardcodat dark (nu adapteaza la tema light):\n"
+ "\n".join(vinovate)
)
# ── US-001 (PRD 5.15): Teme aditive + tokeni --card2/--line2 ──────────────────
def test_cele_4_teme_definite(client):
"""Cele 4 teme noi (grafit/cobalt/cupru/hartie) au blocuri CSS [data-theme="..."]
cu tokenul minim: --bg/--card/--ink/--muted/--line/--ok/--err/--accent."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
for tema in ("grafit", "cobalt", "cupru", "hartie"):
blk = re.search(
r'\[data-theme=["\']' + tema + r'["\']\]\s*\{([^}]+)\}',
html, re.DOTALL,
)
assert blk, f"Bloc CSS [data-theme=\"{tema}\"] negasit in HTML"
block = blk.group(1)
for var in ("--bg", "--card", "--ink", "--muted", "--line", "--ok", "--err", "--accent"):
assert var in block, (
f"Token {var} lipseste din blocul CSS [data-theme=\"{tema}\"]"
)
def test_tokeni_card2_line2_in_toate_temele(client):
"""--card2 si --line2 sunt definiti in TOATE cele 7 teme:
dark (:root), light, petrol, grafit, cobalt, cupru, hartie."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
# dark e in :root
root_blk = re.search(r':root\s*\{([^}]+)\}', html, re.DOTALL)
assert root_blk, ":root CSS block negasit"
root_block = root_blk.group(1)
for var in ("--card2", "--line2"):
assert var in root_block, f"{var} lipseste din :root (dark)"
for tema in ("light", "petrol", "grafit", "cobalt", "cupru", "hartie"):
blk = re.search(
r'\[data-theme=["\']' + tema + r'["\']\]\s*\{([^}]+)\}',
html, re.DOTALL,
)
assert blk, f"Bloc CSS [data-theme=\"{tema}\"] negasit"
block = blk.group(1)
for var in ("--card2", "--line2"):
assert var in block, f"{var} lipseste din blocul CSS [data-theme=\"{tema}\"]"
def test_anti_fouc_7_stari(client):
"""Anti-FOUC din <head> cunoaste TOATE cele 7+1 stari valide:
light/dark/petrol/grafit/cobalt/cupru/hartie + auto.
Valoare necunoscuta -> auto, fara blink.
Fostul test test_anti_fouc_4_stari acoperea doar light/dark/petrol/auto;
acum verifica toate 8 starile."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
head_match = re.search(r'<head>(.*?)</head>', html, re.DOTALL | re.IGNORECASE)
assert head_match, "<head> negasit"
head = head_match.group(1)
style_pos = head.find('<style>')
assert style_pos >= 0, "<style> negasit in <head>"
head_before_style = head[:style_pos]
for tema in ("light", "dark", "petrol", "grafit", "cobalt", "cupru", "hartie", "auto"):
assert tema in head_before_style, (
f"Tema '{tema}' lipseste din scriptul anti-FOUC (section inainte de <style>). "
f"Utilizatorul cu localStorage.theme='{tema}' va vedea blink la prima incarcare."
)
def test_migrare_localStorage_legacy(client):
"""Valorile vechi (light/dark/petrol) din localStorage raman VALIDE dupa adaugarea
temelor noi. Fara migrare fortata; preferinta setata inainte de update e pastrata.
Valoare lipsa/necunoscuta -> auto (fallback sigur, fara blink)."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
head_match = re.search(r'<head>(.*?)</head>', html, re.DOTALL | re.IGNORECASE)
assert head_match, "<head> negasit"
head = head_match.group(1)
style_pos = head.find('<style>')
head_before_style = head[:style_pos]
# Valorile vechi trebuie sa fie recunoscute ca valide in anti-FOUC
for tema_veche in ("light", "dark", "petrol"):
assert tema_veche in head_before_style, (
f"Tema legacy '{tema_veche}' a disparut din scriptul anti-FOUC. "
f"Userii cu localStorage.theme='{tema_veche}' vor vedea blink (tratati ca necunoscut)."
)
# Fallback la 'auto' trebuie sa fie prezent
assert "auto" in head_before_style, (
"'auto' (fallback pentru valori necunoscute) lipseste din anti-FOUC"
)
def test_themes_dry_single_source(client):
"""DRY (E2): config temelor traieste intr-o singura structura sursa-de-adevar
(var THEMES). ICONS/LABELS NU sunt literali separati (ar putea diverge de THEMES).
Un test prinde o intrare ICONS/LABELS lipsa, nu doar token CSS lipsa.
Adaugarea unei teme noi = O singura intrare in THEMES."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
# Structura THEMES trebuie sa existe
assert "THEMES" in html, (
"var THEMES (sursa de adevar unica pentru config teme) negasit in HTML. "
"E2: config trebuie consolidat intr-o singura structura."
)
themes_match = re.search(r'var THEMES\s*=\s*\[(.*?)\];', html, re.DOTALL)
assert themes_match, "var THEMES = [...]; nu a fost gasit (forma asteptata: var THEMES = [...])"
themes_body = themes_match.group(1)
# Fiecare tema (inclusiv cele 4 noi) trebuie sa fie in THEMES
for tema in ("light", "dark", "petrol", "grafit", "cobalt", "cupru", "hartie", "auto"):
assert (f"'{tema}'" in themes_body or f'"{tema}"' in themes_body), (
f"Tema '{tema}' lipseste din var THEMES. "
f"DRY (E2): adaugarea temei = O singura intrare in THEMES."
)
# ICONS si LABELS NU trebuie sa fie literali separati cu cheile hardcodate
# (daca sunt literali, o tema noua in THEMES nu apare automat in ICONS/LABELS)
icons_literal = re.search(r'var ICONS\s*=\s*\{', html)
labels_literal = re.search(r'var LABELS\s*=\s*\{', html)
assert not icons_literal, (
"var ICONS = {...} e inca un literal separat (nu derivat din THEMES). "
"O tema noua in THEMES nu va aparea automat in ICONS — rupe DRY (E2)."
)
assert not labels_literal, (
"var LABELS = {...} e inca un literal separat (nu derivat din THEMES). "
"O tema noua in THEMES nu va aparea automat in LABELS — rupe DRY (E2)."
)
# ── US-008: Test parametrizat robust — token critic in fiecare tema ─────────────
@pytest.mark.parametrize("token", ["--card2", "--line2", "--accent", "--ok", "--err"])
@pytest.mark.parametrize("tema", ["light", "dark", "petrol", "grafit", "cobalt", "cupru", "hartie"])
def test_token_critic_in_tema_parametrizat(client, tema, token):
"""US-008: test parametrizat robust — fiecare token critic e definit in fiecare tema.
Ancorare pe selectorul CSS [data-theme="X"] {...} (sau :root pentru dark),
NU pe felii fixe [idx:idx+N]. Evita false-green-ul din regresia 5.13:
testele care feliau cu [idx:idx+N] nu prindeau un token lipsa dintr-o tema specifica
(offset-ul era mascat de continut din alte teme).
La esec, pytest raporteaza EXACT combinatia (tema, token) care lipseste —
debugging rapid fara cautare manuala in CSS.
Auto (tema 8): rezolvat la dark/light de anti-FOUC, fara bloc CSS propriu;
acoperit de test_anti_fouc_7_stari. Verificam cele 7 teme concrete (cu bloc CSS).
"""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
if tema == "dark":
# Dark e tema implicita — traieste in :root {}
blk = re.search(r':root\s*\{([^}]+)\}', html, re.DOTALL)
assert blk, ":root CSS block negasit — dark tema nu are paleta definita"
block = blk.group(1)
else:
blk = re.search(
r'\[data-theme=["\']' + re.escape(tema) + r'["\']\]\s*\{([^}]+)\}',
html, re.DOTALL,
)
assert blk, (
f'Bloc CSS [data-theme="{tema}"] negasit in HTML. '
f'Tema "{tema}" nu are paleta definita — adauga blocul CSS.'
)
block = blk.group(1)
assert token in block, (
f"Token '{token}' lipseste din tema '{tema}' "
f"({'\":root\"' if tema == 'dark' else f'\"[data-theme={tema}]\"'}). "
f"Componentele cu var({token}) vor arata gresit pe aceasta tema. "
f"Adauga '{token}:<valoare>;' in blocul CSS al temei '{tema}'."
)

248
tests/test_web_bulk_fix.py Normal file
View File

@@ -0,0 +1,248 @@
"""Teste US-010 (PRD 5.15): Bulk-fix din lista — selectie multipla -> actiune unica.
Acceptance criteria:
- test_bulk_remapeaza_selectie: N randuri needs_mapping + aplica cod -> toate -> queued
- test_bulk_doar_blocate: randuri sent/sending nu sunt eligibile (sarite silentios)
- test_bulk_scoped_cont: 404-before-409 — un cont nu atinge randurile altui cont
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# ---------------------------------------------------------------------------
# Helpere comune (aceeasi conventie ca test_web_submissions.py)
# ---------------------------------------------------------------------------
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, "CSRF token not found on /login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _insert_submission(acct: int, status: str = "needs_mapping",
*, payload: dict | None = None) -> int:
"""Insereaza o trimitere cu payload standard (needs_mapping cu cod_op_service)."""
from app.db import get_connection
conn = get_connection()
try:
p = payload if payload is not None else {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B123TST",
"data_prestatie": "2026-06-15",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": "INTERN1", "denumire": "Schimb ulei"}],
}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"k-{status}-{os.urandom(6).hex()}", acct, status, json.dumps(p)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _get_status(sid: int) -> str | None:
"""Citeste status-ul curent al unui rand din DB (sursa de adevar)."""
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()
return row["status"] if row else None
finally:
conn.close()
def _csrf_from_fragment(client) -> str:
"""Extrage CSRF token din /_fragments/submissions sau din dashboard (fallback).
Submissions fragment include CSRF doar cand exista randuri (form bulk).
Dashboard-ul (/) include mereu CSRF in formularul de upload.
"""
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
if m:
return m.group(1)
# Fallback: dashboard principal (contine intotdeauna un form cu CSRF dupa login)
resp2 = client.get("/")
m2 = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp2.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp2.text)
assert m2, "CSRF token not found in submissions fragment or dashboard"
return m2.group(1)
# ---------------------------------------------------------------------------
# Fixture client
# ---------------------------------------------------------------------------
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "bulk_fix.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()
# ---------------------------------------------------------------------------
# Teste US-010
# ---------------------------------------------------------------------------
def test_bulk_remapeaza_selectie(client):
"""US-010 AC principal: N randuri needs_mapping + aplica cod valid -> toate -> queued.
OE-1 face parte din nomenclatorul seed (nomenclator_seed.FALLBACK_NOMENCLATOR),
incarcat de init_db la startup; nu e nevoie de insert separat.
Payload-uri diferite (VIN diferit) ca sa nu colizioneze la recalculul idempotentei.
"""
acct = _create_account_user("bulk_fix1@test.com")
sid1 = _insert_submission(acct, "needs_mapping", payload={
"vin": "WVWZZZ1KZAW000111",
"nr_inmatriculare": "B111TST",
"data_prestatie": "2026-06-15",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": "INTERN1", "denumire": "Schimb ulei"}],
})
sid2 = _insert_submission(acct, "needs_mapping", payload={
"vin": "WVWZZZ1KZAW000222",
"nr_inmatriculare": "B222TST",
"data_prestatie": "2026-06-16",
"odometru_final": "60000",
"prestatii": [{"cod_op_service": "INTERN2", "denumire": "Verificare franare"}],
})
_login(client, "bulk_fix1@test.com")
csrf = _csrf_from_fragment(client)
resp = client.post(
"/trimiteri/bulk-fix",
data={
"csrf_token": csrf,
"submission_id": [str(sid1), str(sid2)],
"cod_prestatie": "OE-1",
},
)
assert resp.status_code == 200, f"Asteptam 200, primit {resp.status_code}"
# Ambele randuri trebuie sa fie acum queued
s1 = _get_status(sid1)
s2 = _get_status(sid2)
assert s1 == "queued", f"sid1 status={s1!r}, asteptam 'queued'"
assert s2 == "queued", f"sid2 status={s2!r}, asteptam 'queued'"
# Sumar vizibil in raspuns HTML (cel putin unul din: "reusit", "2", "queued")
html = resp.text
assert "reusit" in html.lower() or "2 " in html or "queued" in html.lower(), \
"Sumar bulk-fix lipseste din raspuns"
def test_bulk_doar_blocate(client):
"""US-010 AC eligibilitate: randuri sent/sending sarite silentios; doar blocate procesate."""
acct = _create_account_user("bulk_fix2@test.com")
# Rand sent (read-only, nu trebuie atins)
sid_sent = _insert_submission(acct, "sent", payload={
"vin": "WVWZZZ1KZAW000222",
"nr_inmatriculare": "B222TST",
"data_prestatie": "2026-06-15",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-2", "denumire": "Intretinere"}],
})
# Rand needs_mapping (gestionabil, trebuie procesat)
sid_blocked = _insert_submission(acct, "needs_mapping")
_login(client, "bulk_fix2@test.com")
csrf = _csrf_from_fragment(client)
# Trimitem ambele id-uri; doar cel blocat trebuie procesat
resp = client.post(
"/trimiteri/bulk-fix",
data={
"csrf_token": csrf,
"submission_id": [str(sid_sent), str(sid_blocked)],
"cod_prestatie": "OE-1",
},
)
assert resp.status_code == 200
# Randul sent ramane sent (read-only — INTERZIS sa fie modificat)
assert _get_status(sid_sent) == "sent", \
"Randul sent a fost modificat de bulk-fix — INTERZIS"
# Randul blocat a trecut la queued
assert _get_status(sid_blocked) == "queued", \
f"Randul needs_mapping nu a trecut la queued: {_get_status(sid_blocked)!r}"
def test_bulk_scoped_cont(client):
"""US-010 AC scope: contul A nu poate modifica randurile contului B.
Pattern 404-before-409: randurile cross-account sunt sarite silentios
(nu confirmam existenta), raspuns HTTP 200 cu sumar care reflecta 0 reusite.
"""
acct_a = _create_account_user("bulk_fix_a@test.com", name="Cont A")
acct_b = _create_account_user("bulk_fix_b@test.com", name="Cont B")
# Randul lui B (alt cont)
sid_b = _insert_submission(acct_b, "needs_mapping", payload={
"vin": "WVWZZZ1KZAW000333",
"nr_inmatriculare": "B333TST",
"data_prestatie": "2026-06-15",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": "INTERN3", "denumire": "Test extern"}],
})
# Logat ca A — incearca sa aplice cod pe randul lui B
_login(client, "bulk_fix_a@test.com")
csrf = _csrf_from_fragment(client)
resp = client.post(
"/trimiteri/bulk-fix",
data={
"csrf_token": csrf,
"submission_id": [str(sid_b)],
"cod_prestatie": "OE-1",
},
)
# Raspuns 200 (nu 404 expus HTTP — cross-account e sarit silentios ca la bulk-delete)
assert resp.status_code == 200
# Randul lui B NEATINS
assert _get_status(sid_b) == "needs_mapping", \
"Randul contului B a fost modificat de contul A — INCALCARE SCOPE!"

View File

@@ -231,8 +231,9 @@ def test_camp_apare_o_singura_data(client):
def test_nr_si_vin_pe_randuri_separate(client):
"""Nr. inmatriculare pe rand propriu, VIN dedesubt ambele inputuri latime plina,
nr. inaintea VIN-ului in markup."""
"""VIN si Nr. inmatriculare sunt ambele prezente ca inputuri separate in formular.
US-007 (PRD 5.15): VIN apare PRIMUL in markup (formular slim), nr. inmatriculare
in grila 2-col dupa VIN."""
acct = _create_account_user("u2@test.com")
sid = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0U2001", odo=""))
_login(client, "u2@test.com")
@@ -241,7 +242,7 @@ def test_nr_si_vin_pe_randuri_separate(client):
poz_nr = html.find('name="nr_inmatriculare"')
poz_vin = html.find('name="vin"')
assert poz_nr != -1 and poz_vin != -1
assert poz_nr < poz_vin # nr. apare inaintea VIN-ului (rand propriu, VIN dedesubt)
assert poz_vin < poz_nr # US-007: VIN apare primul (slim form), nr. dupa in grila 2-col
def test_un_singur_buton_primar_per_stare(client):

View File

@@ -0,0 +1,472 @@
"""Teste US-005 (PRD 5.15): obs editabil + concat operatie la import.
AC-uri:
- obs adaugat in bucla de campuri din post_corecteaza (routes.py) si in EDIT_FIELDS
(import_router.py); corecteaza si editeaza preview accepta si persista obs.
- obs optional (text liber, fara validare de continut, doar .strip()).
- obs apare in prezentare_din_payload (payload_view.py).
- obs EXCLUS din cheia de idempotenta (D8): editarea obs NU schimba cheia.
- La import fara coloana obs: denumirea operatiei se COPIAZA in obs (D7).
- Derive-on-empty idempotent: re-preview NU dubleaza obs (E3).
TDD: toate testele se scriu INAINTE de implementare (RED -> GREEN).
"""
from __future__ import annotations
import io
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
"""Client web cu autentificare activa (pentru corecteaza)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "obs.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()
@pytest.fixture()
def api_client(monkeypatch):
"""Client API fara autentificare web (pentru import preview + editeaza)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "obs_api.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
reset_cache()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
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", 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 nu a fost gasit in pagina de login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303, f"Login esuat: {resp.status_code}"
def _csrf(client) -> str:
resp = client.get("/?tab=coada")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token nu a fost gasit in dashboard"
return m.group(1)
def _insert(acct: int, *, status: str, payload: dict, key: str | None = None) -> int:
from app.db import get_connection
conn = get_connection()
try:
k = key or f"k-{os.urandom(6).hex()}"
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(k, acct, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _row_payload(sid: int) -> dict:
from app.db import get_connection
conn = get_connection()
try:
r = conn.execute("SELECT payload_json FROM submissions WHERE id=?", (sid,)).fetchone()
return json.loads(r["payload_json"])
finally:
conn.close()
def _row_status(sid: int) -> str:
from app.db import get_connection
conn = get_connection()
try:
r = conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()
return r["status"]
finally:
conn.close()
def _seed_nomenclator(cod: str = "OE-1", op_service: str = "Schimb ulei") -> None:
"""Insereaza cod in nomenclator si mapare op_service -> cod pentru contul 1."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, "Schimb ulei motor"),
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (1, ?, ?, 1)",
(op_service, cod),
)
conn.commit()
finally:
conn.close()
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
import csv as _csv
buf = io.StringIO()
writer = _csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
def _upload(client, data: bytes, filename: str = "test.csv") -> int:
r = client.post(
"/v1/import",
files={"file": (filename, io.BytesIO(data), "text/csv")},
)
assert r.status_code == 200, r.text
return int(r.json()["import_id"])
def _save_mapping(client, import_id: int, json_mapare: dict) -> None:
r = client.post(
f"/v1/import/{import_id}/column-mapping",
json={"json_mapare": json_mapare, "format_data": "YYYY-MM-DD"},
)
assert r.status_code == 200, r.text
def _preview(client, import_id: int) -> list[dict]:
r = client.get(f"/v1/import/{import_id}/preview")
assert r.status_code == 200, r.text
return r.json()["rows"]
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_obs_editabil_persistat_corecteaza(client):
"""AC: obs adaugat in bucla post_corecteaza -> persists in payload_json.
RED: 'obs' nu e inca in bucla de campuri din post_corecteaza (routes.py:1177).
"""
acct = _create_account_user("obs.corecteaza@test.com")
_login(client, "obs.corecteaza@test.com")
# Submission needs_data cu odometru gol (trigger pentru blocaj)
sid = _insert(acct, status="needs_data", payload={
"vin": "WVWZZZ1JZXW0AB001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "", # trigger needs_data
"prestatii": [{"cod_prestatie": "OE-1"}],
})
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"odometru_final": "50000", # fix odo
"obs": "Schimb ulei verificat", # obs editabil
},
)
assert resp.status_code == 200, (
f"Status neasteptat: {resp.status_code}\n{resp.text[:500]}"
)
payload = _row_payload(sid)
assert payload.get("obs") == "Schimb ulei verificat", (
f"obs nu e persistat in payload_json; payload={payload}"
)
assert _row_status(sid) == "queued", (
f"status neasteptat: {_row_status(sid)}"
)
def test_obs_persistat_preview_editeaza(api_client):
"""AC: obs in EDIT_FIELDS + RandEditIn -> editeaza preview salveaza obs -> apare in resolved.
RED: 'obs' nu e in RandEditIn (import_router.py:1188) sau in EDIT_FIELDS (:261).
"""
_seed_nomenclator()
data = _csv_bytes([{
"VIN": "WVWZZZ1JZXW0AB002",
"Nr": "B200BBB",
"Data": "2026-06-10",
"KM": "50000",
"Operatie": "Schimb ulei",
# Fara coloana Observatii: obs vine din derive
}])
iid = _upload(api_client, data)
_save_mapping(api_client, iid, {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
})
rows = _preview(api_client, iid)
assert rows[0]["resolved_status"] == "ok", f"Stare neasteptata inainte de edit: {rows[0]}"
# Editeaza obs explicit pe randul 0
r = api_client.post(
f"/v1/import/{iid}/rand/0/editeaza",
json={"obs": "Observatie test manuala"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body.get("override", {}).get("obs") == "Observatie test manuala", (
f"obs nu e in override returnat: {body}"
)
# Preview dupa editare: obs din override trebuie sa apara in resolved
rows2 = _preview(api_client, iid)
resolved_obs = rows2[0]["resolved"].get("obs")
assert resolved_obs == "Observatie test manuala", (
f"obs nu apare in resolved dupa editeaza; resolved={rows2[0]['resolved']}"
)
def test_obs_optional_gol_ok(client):
"""AC: obs optional; o trimitere fara obs trece validarea si devine queued.
RED: implicit nu esueaza, dar ne asiguram ca lipsa obs nu introduce o eroare.
"""
acct = _create_account_user("obs.gol@test.com")
_login(client, "obs.gol@test.com")
sid = _insert(acct, status="needs_data", payload={
"vin": "WVWZZZ1JZXW0AB006",
"nr_inmatriculare": "B600FFF",
"data_prestatie": "2026-06-10",
"odometru_final": "", # trigger needs_data
"prestatii": [{"cod_prestatie": "OE-1"}],
})
csrf = _csrf(client)
# Corecteaza FARA obs in form (obs absent)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"odometru_final": "50000",
# obs absent din form
},
)
assert resp.status_code == 200, resp.text
assert _row_status(sid) == "queued", (
f"Status neasteptat dupa corectie fara obs: {_row_status(sid)}"
)
def test_import_concateneaza_operatie_in_obs(api_client):
"""AC (D7): import fara coloana obs -> obs = denumire operatie in preview.
RED: obs nu e derivat din operatie la import (inca nu e implementat in
_resolve_row_for_preview).
"""
_seed_nomenclator(cod="OE-1", op_service="Schimb ulei")
data = _csv_bytes([{
"VIN": "WVWZZZ1JZXW0AB003",
"Nr": "B300CCC",
"Data": "2026-06-10",
"KM": "50000",
"Operatie": "Schimb ulei",
# Fara coloana Observatii in fisier
}])
iid = _upload(api_client, data)
_save_mapping(api_client, iid, {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
# "Observatii" nu e in mapare -> obs vine din derive
})
rows = _preview(api_client, iid)
resolved = rows[0]["resolved"]
obs = resolved.get("obs", "")
assert obs == "Schimb ulei", (
f"obs trebuie sa fie 'Schimb ulei' (copiat din operatie); got={obs!r}"
)
def test_anti_dublu_concat(api_client):
"""AC (E3): DERIVE-ON-EMPTY idempotent; re-preview si override explicit NU dubleaza obs.
RED: fara DERIVE-ON-EMPTY, un al doilea preview sau o editare cu obs setat ar putea
produce 'Schimb ulei; Schimb ulei'.
"""
_seed_nomenclator(cod="OE-1", op_service="Schimb ulei")
data = _csv_bytes([{
"VIN": "WVWZZZ1JZXW0AB004",
"Nr": "B400DDD",
"Data": "2026-06-10",
"KM": "50000",
"Operatie": "Schimb ulei",
}])
iid = _upload(api_client, data)
_save_mapping(api_client, iid, {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
})
# Primul preview: obs derivat din operatie
rows1 = _preview(api_client, iid)
obs1 = rows1[0]["resolved"].get("obs", "")
assert obs1 == "Schimb ulei", f"Primul preview: obs neasteptat: {obs1!r}"
# Simulam utilizatorul care seteaza explicit obs = valoarea deja derivata
r = api_client.post(
f"/v1/import/{iid}/rand/0/editeaza",
json={"obs": "Schimb ulei"},
)
assert r.status_code == 200, r.text
# Al doilea preview: obs NU trebuie dublat
rows2 = _preview(api_client, iid)
obs2 = rows2[0]["resolved"].get("obs", "")
assert obs2 == "Schimb ulei", (
f"Al doilea preview a produs obs gresit: {obs2!r} (asteptat: 'Schimb ulei')"
)
assert "Schimb ulei; Schimb ulei" not in obs2, (
f"obs a fost dublat: {obs2!r}"
)
# Al treilea preview (fara nicio alta editare): inca nu se dubleaza
rows3 = _preview(api_client, iid)
obs3 = rows3[0]["resolved"].get("obs", "")
assert obs3 == "Schimb ulei", (
f"Al treilea preview a produs obs gresit: {obs3!r}"
)
def test_obs_sters_explicit_nu_se_re_deriveaza(api_client):
"""Bug fix (code-review 5.15): obs='' (sters explicit de user) NU se re-deriveaza.
obs e camp derivat (copiaza denumirea operatiei cand e gol). Cand userul sterge
obs in preview (obs=''), _merge_override pastreaza acum obs='' in override (nu il
mai face pop) -> override aplicat ultimul suprascrie derive-on-empty -> obs ramane
gol. Inainte: pop -> obs gol -> re-derivat din denumire -> stergerea ignorata.
RED inainte de fix: al doilea preview re-deriveaza obs = 'Schimb ulei'.
"""
_seed_nomenclator(cod="OE-1", op_service="Schimb ulei")
data = _csv_bytes([{
"VIN": "WVWZZZ1JZXW0AB009",
"Nr": "B900GGG",
"Data": "2026-06-10",
"KM": "50000",
"Operatie": "Schimb ulei",
}])
iid = _upload(api_client, data)
_save_mapping(api_client, iid, {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
})
# Primul preview: obs derivat din operatie.
rows1 = _preview(api_client, iid)
assert rows1[0]["resolved"].get("obs") == "Schimb ulei"
# Userul STERGE obs (string gol).
r = api_client.post(
f"/v1/import/{iid}/rand/0/editeaza",
json={"obs": ""},
)
assert r.status_code == 200, r.text
# Preview dupa stergere: obs trebuie sa RAMANA gol (NU re-derivat).
rows2 = _preview(api_client, iid)
obs2 = rows2[0]["resolved"].get("obs", "")
assert obs2 == "", (
f"obs sters explicit a fost re-derivat: {obs2!r} (asteptat gol)"
)
# Idempotent: al treilea preview tot gol.
rows3 = _preview(api_client, iid)
assert rows3[0]["resolved"].get("obs", "") == "", (
f"obs sters re-derivat la al treilea preview: {rows3[0]['resolved'].get('obs')!r}"
)
def test_obs_nu_schimba_cheia_idempotenta():
"""AC (D8): editarea obs NU schimba cheia de idempotenta.
Fara import circular DB; testeaza direct functiile din idempotency.py.
RED: daca obs ar fi in build_key, doua versiuni (cu/fara obs) ar produce chei diferite.
"""
from app.idempotency import build_key, canonicalize_row
payload_fara_obs = {
"vin": "WVWZZZ1JZXW0AB005",
"nr_inmatriculare": "B500EEE",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
payload_cu_obs = {
**payload_fara_obs,
"obs": "Schimb ulei motor 5W30 adaugat dupa",
}
canon1 = canonicalize_row(payload_fara_obs)
canon2 = canonicalize_row(payload_cu_obs)
key1 = build_key(1, canon1)
key2 = build_key(1, canon2)
assert key1 == key2, (
f"obs a schimbat neasteptat cheia de idempotenta!\n"
f" fara obs: {key1}\n"
f" cu obs: {key2}"
)

View File

@@ -0,0 +1,546 @@
"""Teste US-006 (PRD 5.15): prestatii multi-cod (lista) la editare/corectie.
AC-uri verificate:
- Handler-ele accepta LISTA de cod_prestatie (form.getlist) -> prestatii cu mai multe coduri.
- cod_op_service/denumire RAMAN pe item (invariant D7, E1 IRON RULE).
- Cod invalid -> respins cu mesaj; cod necunoscut NU ajunge la RAR (ORA-12899).
- Lista goala -> ramane needs_mapping.
- Dedup per-item: (op_service, cod) unic, NU cod unic (doua ops diferite cu acelasi cod ok).
- Recalcul idempotenta dupa editare.
- odometruInitial obligatoriu cand cod_prestatie contine R-ODO/I-ODO.
- REGRESIE E1 (IRON RULE): op_service supravietuieste /repune cu cod.
TDD: toate testele sunt scrise INAINTE de implementare (RED -> GREEN).
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "prestatii.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()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
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", 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 nu gasit in login"
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("/?tab=coada")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token nu gasit in dashboard"
return m.group(1)
def _insert(acct: int, *, status: str, payload: dict, key: str | None = None) -> int:
from app.db import get_connection
conn = get_connection()
try:
k = key or f"k-{os.urandom(6).hex()}"
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(k, acct, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
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 _payload_json(sid: int) -> dict:
from app.db import get_connection
conn = get_connection()
try:
r = conn.execute("SELECT payload_json FROM submissions WHERE id=?", (sid,)).fetchone()
return json.loads(r["payload_json"])
finally:
conn.close()
def _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
"""Insereaza un cod in nomenclator_rar (fara operatii_mapping)."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, denumire),
)
conn.commit()
finally:
conn.close()
def _payload_cu_ops(vin: str, ops: list[tuple[str, str]]) -> dict:
"""Payload cu prestatii avand cod_op_service/denumire (needs_mapping state)."""
return {
"vin": vin,
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [
{"cod_op_service": op, "denumire": den}
for op, den in ops
],
}
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_mai_multe_coduri_acceptate(client):
"""US-006 AC1: LISTA de cod_prestatie -> prestatii cu N itemi, fiecare cu cod setat.
RED: form.get("cod_prestatie") intoarce doar primul cod; form.getlist necesar.
"""
acct = _create_account_user("multi.cod@test.com")
_login(client, "multi.cod@test.com")
_seed_cod("OE-1", "Schimb ulei")
_seed_cod("IG-1", "Inlocuire garnitura")
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0MC001",
[("Op-A", "Schimb ulei motor"), ("Op-B", "Inlocuire garnitura chiulasa")],
))
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", "IG-1"], # 2 coduri pentru 2 operatii
},
)
assert resp.status_code == 200, resp.text[:500]
r = _row(sid)
assert r["status"] == "queued", f"status asteptat queued, got {r['status']}"
prestatii = _payload_json(sid)["prestatii"]
assert len(prestatii) == 2, f"asteptat 2 prestatii, got {len(prestatii)}: {prestatii}"
coduri = [p.get("cod_prestatie") for p in prestatii]
assert "OE-1" in coduri, f"OE-1 lipsa din prestatii: {prestatii}"
assert "IG-1" in coduri, f"IG-1 lipsa din prestatii: {prestatii}"
def test_cod_op_service_pastrat_dupa_corecteaza(client):
"""E1/D7: cod_op_service si denumire RAMAN pe item dupa /corecteaza cu cod direct.
RED: implementarea veche injecta in prestatii[0] fara sa afecteze op_service
(intr-adevar in /corecteaza nu se facea pop), dar testul confirma explicit invariantul.
"""
acct = _create_account_user("op.pastrat@test.com")
_login(client, "op.pastrat@test.com")
_seed_cod("OE-1")
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0OP001",
[("Schimb ulei", "Schimb ulei motor 5W30")],
))
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
)
assert resp.status_code == 200
prestatii = _payload_json(sid)["prestatii"]
assert len(prestatii) == 1
item = prestatii[0]
assert item.get("cod_prestatie") == "OE-1", f"cod_prestatie lipsa: {item}"
assert item.get("cod_op_service") == "Schimb ulei", f"cod_op_service pierdut: {item}"
assert item.get("denumire") == "Schimb ulei motor 5W30", f"denumire pierduta: {item}"
def test_cod_invalid_respins(client):
"""US-006 AC3: cod necunoscut in nomenclator -> respins cu mesaj, status neschimbat.
RED: validarea fata de nomenclator nu e aplicata per-cod la multi-select.
"""
acct = _create_account_user("cod.invalid@test.com")
_login(client, "cod.invalid@test.com")
# NU seed-uim "XX-99" -> cod necunoscut
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0CI001",
[("Op-Test", "Operatie test")],
))
old_status = _row(sid)["status"]
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "XX-99"},
)
assert resp.status_code == 200
# Cod invalid -> mesaj de eroare vizibil
assert "XX-99" in resp.text or "necunoscut" in resp.text.lower(), (
f"Mesaj de eroare lipsa pentru cod invalid; text={resp.text[:500]}"
)
# Status neschimbat
assert _row(sid)["status"] == old_status, (
f"Status s-a schimbat desi codul e invalid: {_row(sid)['status']}"
)
def test_lista_goala_needs_mapping(client):
"""US-006 AC4: nicio cod_prestatie trimis -> submission ramane needs_mapping.
RED: cu multi-select, lista goala nu injecteaza nimic; resolve_prestatii
gaseste inca operatii nemapate -> trebuie sa ramana needs_mapping.
"""
acct = _create_account_user("goala.nemap@test.com")
_login(client, "goala.nemap@test.com")
# NU seed-uim nicio mapare -> operatia ramane nemapata
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0GN001",
[("Op-Nemap", "Operatie nemapata")],
))
csrf = _csrf(client)
# Trimit form FARA cod_prestatie (lista goala)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf},
)
assert resp.status_code == 200
assert _row(sid)["status"] == "needs_mapping", (
f"Status trebuia sa ramana needs_mapping, got {_row(sid)['status']}"
)
def test_idempotency_recalculat(client):
"""US-006 AC6: dupa setarea de coduri noi, cheia de idempotenta e recalculata.
RED: single-cod injecta in prestatii[0] si recalcula cheia; cu multi-cod
acelasi mecanism se aplica tuturor itemilor.
"""
acct = _create_account_user("ido.recalc@test.com")
_login(client, "ido.recalc@test.com")
_seed_cod("OE-1")
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0ND001",
[("Op-Ido", "Operatie ido")],
))
old_key = _row(sid)["idempotency_key"]
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
)
assert resp.status_code == 200
assert _row(sid)["status"] == "queued"
new_key = _row(sid)["idempotency_key"]
assert new_key != old_key, (
f"Cheia de idempotenta NU s-a schimbat dupa setarea codului: {new_key}"
)
def test_odometru_initial_conditionat_R_ODO(client):
"""US-006 AC7: cod_prestatie=R-ODO fara odometruInitial -> validate_prezentare
intoarce eroare -> submission ramane needs_data (NU queued).
RED: validarea R-ODO e deja in validate_prezentare; testul confirma ca
multi-cod nu bypass-eaza aceasta regula.
"""
acct = _create_account_user("odo.rodo@test.com")
_login(client, "odo.rodo@test.com")
_seed_cod("R-ODO", "Revizie odometru")
# Payload: needs_mapping (op fara cod), FARA odometru_initial
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0RO001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
# odometru_initial ABSENT
"prestatii": [{"cod_op_service": "Revizie", "denumire": "Revizie odometru"}],
})
csrf = _csrf(client)
# Trimit R-ODO ca cod (valid in nomenclator), dar fara odometru_initial
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "R-ODO"},
)
assert resp.status_code == 200
status = _row(sid)["status"]
# R-ODO fara odometruInitial -> validare esuata -> needs_data (nu queued)
assert status in ("needs_data", "needs_mapping"), (
f"Status neasteptat: {status}; trebuia needs_data/needs_mapping (R-ODO fara odo initial)"
)
assert status != "queued", (
"R-ODO fara odometruInitial NU trebuie sa treaca in queued!"
)
def test_dedup_per_item_nu_dupa_cod(client):
"""US-006 AC5 (E4): doua operatii DIFERITE cu ACELASI cod RAR ambele supravietuiesc.
Dedup = (op_service, cod) identice, NU cod singur. Doua ops distincte pot
mapa legitim la acelasi cod RAR fara sa fie sterse de dedup.
RED: dedupare naiva dupa cod ar sterge a doua operatie (op-B cu acelasi OE-1).
"""
acct = _create_account_user("dedup.ops@test.com")
_login(client, "dedup.ops@test.com")
_seed_cod("OE-1", "Schimb ulei")
# Doua operatii distincte, ambele vor primi OE-1
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0DD001",
[("Op-A", "Prima operatie"), ("Op-B", "A doua operatie")],
))
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", "OE-1"], # acelasi cod pentru ambele ops
},
)
assert resp.status_code == 200
prestatii = _payload_json(sid)["prestatii"]
# Ambele TREBUIE sa supravietuiasca: (Op-A, OE-1) != (Op-B, OE-1)
assert len(prestatii) == 2, (
f"Dedup a sters o operatie distincta! prestatii={prestatii} "
"(doua ops cu acelasi cod trebuie pastrate)"
)
ops = [p.get("cod_op_service") for p in prestatii]
assert "Op-A" in ops and "Op-B" in ops, f"ops_service pierdute: {ops}"
# --------------------------------------------------------------------------- #
# Test de regresie E1 (IRON RULE): op_service supravietuieste /repune cu cod #
# --------------------------------------------------------------------------- #
def test_op_service_supravietuieste_repune_cu_cod(client):
"""E1 IRON RULE: dupa /repune cu cod_prestatie, cod_op_service/denumire RAMAN pe item.
RED: routes.py:1371 face `p0.pop("cod_op_service", None)` — sterge operatia
cand se seteaza un cod direct prin /repune. US-006 ELIMINA acel pop.
Aceasta regresie e CRITICA: sterge contextul op->cod necesar pentru US-009
(salvare mapare din chip) si rupe invariantul D7.
"""
acct = _create_account_user("e1.repune@test.com")
_login(client, "e1.repune@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
# Starea error: payload cu op_service (operatia venita de la import/API)
sid = _insert(acct, status="error", payload={
"vin": "WVWZZZ1JZXW0E1001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{
"cod_op_service": "Schimb ulei",
"denumire": "Schimb ulei motor 5W30",
# fara cod_prestatie initial
}],
})
csrf = _csrf(client)
# /repune cu cod direct
resp = client.post(
f"/trimitere/{sid}/repune",
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
)
assert resp.status_code == 200, resp.text[:500]
r = _row(sid)
assert r["status"] == "queued", f"status neasteptat: {r['status']}"
prestatii = _payload_json(sid)["prestatii"]
assert len(prestatii) == 1
item = prestatii[0]
# IRON RULE E1: op_service si denumire TREBUIE sa fie prezente
assert item.get("cod_op_service") == "Schimb ulei", (
f"E1 VIOLATED: cod_op_service a fost sters de /repune! item={item}"
)
assert item.get("denumire") == "Schimb ulei motor 5W30", (
f"E1 VIOLATED: denumire a fost stearsa de /repune! item={item}"
)
# Codul trebuie setat
assert item.get("cod_prestatie") == "OE-1", (
f"cod_prestatie nu a fost setat corect: item={item}"
)
def test_repune_nu_trunchiaza_prestatii_multiple(client):
"""Bug fix (code-review 5.15): /repune NU pierde prestatii[1:].
Formularul /repune trimite UN SINGUR select cod_prestatie. Implementarea veche
itera `enumerate(codes)` -> pastra doar len(codes) itemi, deci un rand error cu
2+ prestatii pierdea toate prestatiile dupa prima -> declaratie INCOMPLETA la RAR
(FINALIZATA ireversibil). Fix: iteram peste `existing`, aplicam codes pozitional,
pastram toate prestatiile.
RED inainte de fix: len(prestatii) == 1 (a doua prestatie pierduta).
"""
acct = _create_account_user("repune.multi@test.com")
_login(client, "repune.multi@test.com")
_seed_cod("AAA", "Prestatie A")
_seed_cod("BBB", "Prestatie B")
_seed_cod("CCC", "Prestatie C")
# Rand error cu DOUA prestatii (ambele cu cod valid).
sid = _insert(acct, status="error", payload={
"vin": "WVWZZZ1JZXW0RM001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [
{"cod_prestatie": "AAA"},
{"cod_prestatie": "BBB"},
],
})
csrf = _csrf(client)
# /repune cu UN SINGUR cod nou (schimba prima prestatie).
resp = client.post(
f"/trimitere/{sid}/repune",
data={"csrf_token": csrf, "cod_prestatie": "CCC"},
)
assert resp.status_code == 200, resp.text[:500]
r = _row(sid)
assert r["status"] == "queued", f"status neasteptat: {r['status']}"
prestatii = _payload_json(sid)["prestatii"]
assert len(prestatii) == 2, (
f"AMBELE prestatii trebuie pastrate de /repune, nu doar prima! got={prestatii}"
)
coduri = [p.get("cod_prestatie") for p in prestatii]
assert coduri == ["CCC", "BBB"], (
f"Codul nou se aplica POZITIONAL primei prestatii, a doua ramane intacta: {coduri}"
)
def test_corectie_eroare_validare_pastreaza_picker(client):
"""Bug fix (code-review 5.15): re-render-ul de eroare validare pastreaza optiunile pickerului.
post_corectie_trimitere re-randa _trimitere_detaliu pe ramura erori-validare FARA
`conn`/`account_id` -> `nomenclator_rar=[]` -> picker-ul chips randa ZERO optiuni ->
userul nu mai poate alege cod RAR fara sa inchida+redeschida modalul. Fix: pasam
`conn`+`account_id` la _detaliu_ctx pe TOATE ramurile de re-render.
RED inainte de fix: codul de picker "PK-1" lipseste din re-render.
"""
acct = _create_account_user("corectie.picker@test.com")
_login(client, "corectie.picker@test.com")
_seed_cod("ZZ-9", "Operatie existenta") # codul curent al randului (valid -> fara unmapped)
_seed_cod("PK-1", "Optiune picker") # cod doar in nomenclator (detector de picker)
# needs_data editabil, prestatie cu cod direct valid (resolve OK, fara unmapped).
sid = _insert(acct, status="needs_data", payload={
"vin": "WVWZZZ1JZXW0PK001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "ZZ-9"}],
})
csrf = _csrf(client)
# Corectie cu VIN invalid -> validare esueaza -> ramura de re-render 1432.
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "vin": "BAD"},
)
assert resp.status_code == 200
assert _row(sid)["status"] == "needs_data"
# Picker-ul trebuie sa contina optiunile din nomenclator (conn/account_id pasate).
assert "PK-1" in resp.text, (
"Picker-ul chips e GOL dupa eroare de validare — _detaliu_ctx fara conn/account_id"
)
def test_repune_select_afiseaza_denumirea(client):
"""Bug fix (code-review 5.15): selectul /repune afiseaza denumirea operatiei.
Template-ul folosea cheia gresita `item.nome_prestatie` (typo) -> optiunile
apareau ca "AAA — " fara denumire. Cheia corecta e `nume_prestatie`.
"""
acct = _create_account_user("repune.denumire@test.com")
_login(client, "repune.denumire@test.com")
_seed_cod("AAA", "Schimb ulei motor")
sid = _insert(acct, status="error", payload={
"vin": "WVWZZZ1JZXW0RD001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "AAA"}],
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Optiunea trebuie sa afiseze denumirea, nu doar codul gol.
assert "Schimb ulei motor" in html, (
"Selectul /repune nu afiseaza denumirea operatiei (typo nome_prestatie)"
)
assert "AAA — Schimb ulei motor" in html, (
f"Optiunea select nu randeaza 'cod — denumire': {html[html.find('AAA'):html.find('AAA')+60]}"
)

View File

@@ -0,0 +1,496 @@
"""Teste TDD pentru US-007 (PRD 5.15): formular editare slim.
RED -> implementare -> GREEN.
AC-uri verificate:
- Un singur camp VIN (fara "Confirma VIN").
- Textarea obs (Observatii) prezent in formular.
- Chips multi-select prestatii cu hidden inputs name="cod_prestatie".
- Endpoint /form-chips re-randeaza sectiunea chips (add/remove).
- Acelasi _form_editare.html in ambele modale (trimitere detaliu + editare preview).
- Reveal dinamic odometru initial cand chips contin R-ODO/I-ODO.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
from pathlib import Path
import pytest
from starlette.testclient import TestClient
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "app" / "web" / "templates"
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "slim_form.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()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
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", 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 nu gasit in login"
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("/?tab=coada")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token nu gasit in dashboard"
return m.group(1)
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 _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, denumire),
)
conn.commit()
finally:
conn.close()
def _payload_needs_data_cu_cod(vin: str = "WVWZZZ1JZXW0US007A") -> dict:
"""Payload needs_data: cod RAR setat, dar odometru_final gol."""
return {
"vin": vin,
"nr_inmatriculare": "B200AA",
"data_prestatie": "2026-06-20",
"odometru_final": "", # gol -> needs_data
"prestatii": [{"cod_prestatie": "OE-1", "cod_op_service": "Op-A", "denumire": "Schimb ulei"}],
}
def _payload_cu_ops(vin: str, ops: list[tuple]) -> dict:
"""Payload cu prestatii avand cod_op_service (needs_mapping)."""
return {
"vin": vin,
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [
{"cod_op_service": op, "denumire": den}
for op, den in ops
],
}
def _payload_cu_r_odo(vin: str = "WVWZZZ1JZXW0RODO1") -> dict:
"""Payload needs_data cu R-ODO in chips — declanseaza reveal odometru initial."""
return {
"vin": vin,
"nr_inmatriculare": "B300RO",
"data_prestatie": "2026-06-20",
"odometru_final": "39000",
# odometru_initial ABSENT -> needs_data cand R-ODO
"prestatii": [{"cod_prestatie": "R-ODO", "cod_op_service": "", "denumire": "Revizie odometru"}],
}
# --------------------------------------------------------------------------- #
# Test 1: UN SINGUR camp VIN (fara "Confirma VIN") #
# --------------------------------------------------------------------------- #
def test_un_singur_vin(client):
"""US-007 AC1: formularul slim are UN SINGUR input name='vin'.
Fara camp 'Confirma VIN' — PRD si contractul RAR cer un singur VIN.
RED: daca ar exista doua campuri VIN sau un camp 'confirma_vin', testul pica.
"""
acct = _create_account_user("vin.unic@test.com")
_login(client, "vin.unic@test.com")
_seed_cod("OE-1")
sid = _insert(acct, status="needs_data", payload=_payload_needs_data_cu_cod())
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200, resp.text[:300]
html = resp.text
# Exact un singur input cu name="vin"
vin_inputs = re.findall(r'<input[^>]+name="vin"[^>]*>', html)
assert len(vin_inputs) == 1, (
f"Trebuie exact UN input name='vin', gasit {len(vin_inputs)}: {vin_inputs}"
)
# Fara camp "Confirma VIN" sau "confirma_vin"
assert "confirma_vin" not in html.lower(), (
"Formular NU trebuie sa aiba camp 'confirma_vin' (VIN unic per contract RAR)"
)
assert "confirma vin" not in html.lower(), (
"Formular NU trebuie sa afiseze eticheta 'confirma vin'"
)
# --------------------------------------------------------------------------- #
# Test 2: Camp Observatii (textarea name="obs") #
# --------------------------------------------------------------------------- #
def test_camp_observatii_prezent(client):
"""US-007 AC2: formularul are textarea name='obs' pentru Observatii (US-005).
RED: obs nu e inca in _form_editare.html (US-005 adauga backend-ul, US-007 adauga UI-ul).
"""
acct = _create_account_user("obs.forma@test.com")
_login(client, "obs.forma@test.com")
_seed_cod("OE-1")
sid = _insert(acct, status="needs_data", payload=_payload_needs_data_cu_cod())
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# textarea cu name="obs" trebuie sa existe
has_textarea_obs = bool(
re.search(r'<textarea[^>]+name="obs"', html) or
re.search(r'<textarea[^>]*name=["\']obs["\']', html)
)
assert has_textarea_obs, (
"Formularul trebuie sa contina <textarea name='obs'> pentru Observatii. "
"US-007 adauga campul obs (textarea) in _form_editare.html."
)
# Eticheta "Observatii" sau "obs" vizibila
assert re.search(r'[Oo]bservat', html), (
"Formularul trebuie sa afiseze eticheta 'Observatii'"
)
# --------------------------------------------------------------------------- #
# Test 3: Chips multi-select prestatii (hidden inputs name="cod_prestatie") #
# --------------------------------------------------------------------------- #
def test_chips_multi_select_prestatii(client):
"""US-007 AC3: submission cu cod_prestatie setat afiseaza chip cu hidden input.
RED: _form_editare.html nu are inca sectiunea de chips.
"""
acct = _create_account_user("chips.test@test.com")
_login(client, "chips.test@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
sid = _insert(acct, status="needs_data", payload=_payload_needs_data_cu_cod())
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Trebuie sa existe un input (de obicei hidden) cu name="cod_prestatie" si valoarea "OE-1"
has_cod_prestatie_chip = bool(
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html)
)
assert has_cod_prestatie_chip, (
"Formularul trebuie sa contina un hidden input cu name='cod_prestatie' value='OE-1' "
"reprezentand chip-ul de prestatie. "
"US-007 adauga sectiunea de chips in _form_editare.html."
)
# --------------------------------------------------------------------------- #
# Test 4: Endpoint /form-chips re-randeaza sectiunea chips #
# --------------------------------------------------------------------------- #
def test_adauga_sterge_chip(client):
"""US-007 AC (E6): POST /form-chips cu action=add re-randeaza chips cu noul cod.
RED: endpoint-ul /form-chips nu exista inca.
"""
acct = _create_account_user("form.chips@test.com")
_login(client, "form.chips@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client)
# POST /form-chips: adauga OE-1 la prima operatie (index 0)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
# Starea curenta: o operatie fara cod
"chip_op_service": ["Op-A"],
"chip_denumire": ["Schimb ulei motor"],
"cod_prestatie": [""], # nemaapat initial
# Actiunea
"chips_action": "add",
"chips_add_op_index": "0",
"chips_add_cod_0": "OE-1",
},
)
assert resp.status_code == 200, f"/form-chips a returnat {resp.status_code}: {resp.text[:400]}"
html = resp.text
# Dupa add, chip-ul cu OE-1 trebuie sa fie in HTML
assert "OE-1" in html, (
f"Dupa add, OE-1 trebuie sa apara in raspunsul /form-chips. html[:500]={html[:500]}"
)
# Si hidden input cu valoarea OE-1
has_hidden = bool(
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html)
)
assert has_hidden, (
"Dupa add, trebuie sa existe un input cu name='cod_prestatie' value='OE-1' "
f"in raspunsul /form-chips. html[:600]={html[:600]}"
)
def test_sterge_chip(client):
"""US-007: POST /form-chips cu action=remove sterge chip-ul la indexul dat.
RED: endpoint-ul /form-chips nu exista inca.
"""
acct = _create_account_user("form.chips.del@test.com")
_login(client, "form.chips.del@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client)
# POST /form-chips: sterge chip-ul de la index 0 (OE-1 existent)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
# Starea curenta: OE-1 mapat pe Op-A
"chip_op_service": ["Op-A"],
"chip_denumire": ["Schimb ulei motor"],
"cod_prestatie": ["OE-1"],
# Actiunea: sterge indexul 0
"chips_action": "remove",
"chips_remove_index": "0",
},
)
assert resp.status_code == 200, f"/form-chips remove a returnat {resp.status_code}: {resp.text[:400]}"
html = resp.text
# Dupa remove, OE-1 nu mai apare ca chip (input hidden cu acea valoare)
has_oe1_chip = bool(
re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', html) or
re.search(r'<input[^>]+value="OE-1"[^>]+name="cod_prestatie"', html)
)
assert not has_oe1_chip, (
"Dupa remove, OE-1 NU mai trebuie sa apara ca chip "
f"(hidden input cu cod_prestatie=OE-1). html[:500]={html[:500]}"
)
# --------------------------------------------------------------------------- #
# Test 5: Acelasi _form_editare.html in ambele modale #
# --------------------------------------------------------------------------- #
def test_form_slim_in_ambele_modale():
"""US-007 AC4: _form_editare.html e inclus ATAT in _trimitere_detaliu.html
CAT SI in _editare_preview_modal.html (fara duplicare logica).
"""
sursa_detaliu = (TEMPLATES_DIR / "_trimitere_detaliu.html").read_text(encoding="utf-8")
sursa_preview = (TEMPLATES_DIR / "_editare_preview_modal.html").read_text(encoding="utf-8")
assert "_form_editare.html" in sursa_detaliu, (
"_trimitere_detaliu.html trebuie sa includa _form_editare.html (DRY)"
)
assert "_form_editare.html" in sursa_preview, (
"_editare_preview_modal.html trebuie sa includa _form_editare.html (DRY)"
)
# --------------------------------------------------------------------------- #
# Test 6: Reveal dinamic odometru initial la R-ODO (D10c, E6) #
# --------------------------------------------------------------------------- #
def test_reveal_odometru_la_R_ODO(client):
"""US-007 D10c: cand chips contin R-ODO, campul odometru initial e dezvaluit
si marcat ca necesar (bordura warn + label).
Cand NU contine R-ODO, sectiunea e ascunsa/discreta.
RED: _form_editare.html nu are inca logica de reveal conditionat.
"""
acct = _create_account_user("odo.reveal@test.com")
_login(client, "odo.reveal@test.com")
_seed_cod("R-ODO", "Revizie odometru")
_seed_cod("OE-1", "Schimb ulei")
# Submission cu R-ODO -> reveal activ
sid_rodo = _insert(acct, status="needs_data", payload=_payload_cu_r_odo())
resp_rodo = client.get(f"/_fragments/trimitere/{sid_rodo}")
assert resp_rodo.status_code == 200
html_rodo = resp_rodo.text
# Cand R-ODO e prezent: campul odometru_initial trebuie sa fie dezvaluit
# cu un MARKER SPECIFIC al implementarii noi:
# - clasa "odo-initial-warn" pe div-ul sectiunii
# - SAU textul "necesar pentru r-odo" in label (exact, case-insensitive)
# Aceste lucruri NU exista in implementarea curenta (care arata mereu campul fara marker).
has_r_odo_reveal = (
"odo-initial-warn" in html_rodo or
"necesar pentru r-odo" in html_rodo.lower() or
"necesar pentru i-odo" in html_rodo.lower()
)
assert has_r_odo_reveal, (
"Cand chips contin R-ODO, formularul trebuie sa dezvaluie sectiunea odometru initial "
"cu clasa 'odo-initial-warn' sau text 'necesar pentru R-ODO'. "
f"html_rodo[:800]={html_rodo[:800]}"
)
# Submission fara R-ODO -> reveal inactiv (campul discret sau ascuns)
payload_fara_rodo = {
"vin": "WVWZZZ1JZXW0NOROD1",
"nr_inmatriculare": "B100AA",
"data_prestatie": "2026-06-20",
"odometru_final": "", # needs_data din alt motiv
"prestatii": [{"cod_prestatie": "OE-1", "cod_op_service": "", "denumire": "Schimb ulei"}],
}
sid_norm = _insert(acct, status="needs_data", payload=payload_fara_rodo)
resp_norm = client.get(f"/_fragments/trimitere/{sid_norm}")
assert resp_norm.status_code == 200
html_norm = resp_norm.text
# Fara R-ODO: "necesar pentru R-ODO" nu trebuie sa apara
has_r_odo_text_in_norm = (
"necesar pentru r-odo" in html_norm.lower() or
"necesar pentru i-odo" in html_norm.lower() or
"odo-initial-warn" in html_norm
)
assert not has_r_odo_text_in_norm, (
"Fara R-ODO in chips, formularul NU trebuie sa arate 'necesar pentru R-ODO'. "
f"html_norm[:800]={html_norm[:800]}"
)
# --------------------------------------------------------------------------- #
# Test 7: /form-chips via HTMX returneaza sectiune si cu reveal R-ODO #
# --------------------------------------------------------------------------- #
def test_form_chips_reveal_r_odo(client):
"""US-007 E6: POST /form-chips cu R-ODO in chips -> raspunsul marcheaza reveal odo.
RED: endpoint-ul nu exista + logica de reveal nu e implementata.
"""
acct = _create_account_user("chips.rodo@test.com")
_login(client, "chips.rodo@test.com")
_seed_cod("R-ODO", "Revizie odometru")
csrf = _csrf(client)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
# Stare curenta: R-ODO deja in chips (flat)
"chip_op_service": [""],
"chip_denumire": ["Revizie odometru"],
"cod_prestatie": ["R-ODO"],
# Nicio actiune — justa re-randare
"chips_action": "",
},
)
assert resp.status_code == 200, f"/form-chips R-ODO a returnat {resp.status_code}"
html = resp.text
# In sectiunea chip, R-ODO trebuie sa apara (chip warn sau chip normal)
assert "R-ODO" in html, (
f"R-ODO trebuie sa apara in raspunsul /form-chips. html[:500]={html[:500]}"
)
# Indicatorul has_r_odo trebuie sa fie un marker SPECIFIC al implementarii noi:
# chip-warn (clasa warn pe chip R-ODO) SAU data-has-r-odo="true"
# Aceste marcheri nu exista inainte de implementarea US-007.
has_r_odo_signal = (
"chip-warn" in html or # chip-ul R-ODO e stilat warn (CSS existent din US-002)
'data-has-r-odo="true"' in html # sau data-attr explicit
)
assert has_r_odo_signal, (
"Cand R-ODO e in chips, raspunsul /form-chips trebuie sa contina "
"class='chip-warn' pe chip-ul R-ODO sau data-has-r-odo='true'. "
f"html[:600]={html[:600]}"
)
# --------------------------------------------------------------------------- #
# Test 8: Picker per operatie (E4 binding) -- format op-row #
# --------------------------------------------------------------------------- #
def test_picker_per_operatie_in_form(client):
"""US-007 E4: operatie nemapata (needs_mapping) -> formularul afiseaza picker pe operatie.
RED: _form_editare.html nu are inca sectiunea de chips cu op-rows.
"""
acct = _create_account_user("picker.op@test.com")
_login(client, "picker.op@test.com")
# NU seed-uim nicio mapare -> operatia ramane nemapata
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0OP001",
[("REVIZIE PERIODICA", "Revizie periodica anuala")],
))
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Operatia REVIZIE PERIODICA trebuie sa apara in form (op-row cu clasa specifica US-007)
# clasa "op-row" din CSS base.html (US-002) e adaugata NUMAI de chips_prestatii.html nou
has_op_row = "op-row" in html
assert has_op_row, (
"Formularul trebuie sa contina clasa 'op-row' (din US-002 CSS) "
"pentru picker-ul per-operatie (E4 binding). "
"Aceasta clasa e adaugata de _chips_prestatii.html in US-007. "
f"html[:600]={html[:600]}"
)
# Operatia REVIZIE PERIODICA trebuie sa apara in context op-row
assert "REVIZIE PERIODICA" in html, (
"Operatia 'REVIZIE PERIODICA' trebuie sa apara in formularul de editare (op-row). "
f"html[:500]={html[:500]}"
)

View File

@@ -0,0 +1,340 @@
"""Teste TDD US-009 (PRD 5.15): salvare mapare din chip + cleanup (B) select redundant.
RED -> implementare -> GREEN.
AC-uri verificate:
- Endpoint /trimitere/{id}/salveaza-regula-chip salveaza regula via save_mapping+reresolve_account.
- Re-rezolvarea deblocheaza si submission-uri frate cu aceeasi operatie (batch_id IS NULL).
- Editarea one-off prin /corecteaza nu forteaza salvarea regulii in operations_mapping.
- Cleanup (B): detaliu needs_data NU mai contine <select name="cod_prestatie"> simultan cu chips.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "chip_mapare.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()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
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 US009", 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 nu gasit in login"
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("/?tab=coada")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token nu gasit in dashboard"
return m.group(1)
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 _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 _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, denumire),
)
conn.commit()
finally:
conn.close()
def _get_rule(acct: int, op: str):
"""Cauta regula op->cod in operations_mapping pentru cont + operatie."""
from app.db import get_connection
conn = get_connection()
try:
return conn.execute(
"SELECT cod_prestatie FROM operations_mapping WHERE account_id=? AND cod_op_service=?",
(acct, op),
).fetchone()
finally:
conn.close()
# --------------------------------------------------------------------------- #
# Test 1: Salvare regula din chip (US-009 AC1) #
# --------------------------------------------------------------------------- #
def test_salveaza_regula_din_chip(client):
"""US-009 AC1: POST /trimitere/{id}/salveaza-regula-chip -> save_mapping + reresolve.
RED: endpoint-ul nu exista inca; trebuie creat cu reuse EXACT save_mapping+reresolve_account.
"""
acct = _create_account_user("save.chip@test.com")
_login(client, "save.chip@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
op = "Schimb ulei motor"
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0SC001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": op, "denumire": "Schimb ulei 5W30"}],
})
csrf = _csrf(client)
# Salveaza regula din chip (endpoint nou US-009)
resp = client.post(
f"/trimitere/{sid}/salveaza-regula-chip",
data={
"csrf_token": csrf,
"salveaza_op": op,
"salveaza_cod": "OE-1",
},
)
assert resp.status_code == 200, (
f"Endpoint-ul salveaza-regula-chip a returnat {resp.status_code}: {resp.text[:500]}"
)
# Regula trebuie salvata in operations_mapping
rule = _get_rule(acct, op)
assert rule is not None, (
f"Regula nu a fost salvata in operations_mapping: op={op!r}"
)
assert rule["cod_prestatie"] == "OE-1", (
f"Codul salvat e gresit: {rule['cod_prestatie']!r} (asteptat OE-1)"
)
# Submission-ul propriu trebuie re-rezolvat (reresolve_account e apelat)
r = _row(sid)
assert r["status"] == "queued", (
f"Dupa salvarea regulii, submission-ul trebuia sa fie queued, got {r['status']!r}"
)
# Nota: mesajul de confirmare ("Regula salvata...") e in context dar flash-ul
# e randat in sectiunea editabila; dupa re-rezolvare la queued, detaliu e read-only.
# Verificarile esentiale (rule in DB + status queued) sunt de ajuns pentru US-009 AC1.
# --------------------------------------------------------------------------- #
# Test 2: Re-rezolvare deblocheaza submission frate (US-009 AC2) #
# --------------------------------------------------------------------------- #
def test_reresolve_deblocheaza_frate(client):
"""US-009 AC2: salvarea regulii din chip deblocheaza si alt submission needs_mapping
cu aceeasi operatie (canal API — batch_id IS NULL → reresolve_account scoped null).
RED: endpoint-ul nu exista; dupa implementare, reresolve_account re-rezolva ambele.
"""
acct = _create_account_user("frate.deblocat@test.com")
_login(client, "frate.deblocat@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
op = "Schimb ulei motor"
# Doua submission-uri cu aceeasi operatie, ambele needs_mapping, batch_id=NULL (API)
sid1 = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0RD001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": op, "denumire": "Schimb ulei 5W30"}],
})
sid2 = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0RD002",
"nr_inmatriculare": "B200BBB",
"data_prestatie": "2026-06-11",
"odometru_final": "60000",
"prestatii": [{"cod_op_service": op, "denumire": "Schimb ulei 5W30"}],
})
csrf = _csrf(client)
# Salveaza regula din chip pentru sid1 -> reresolve_account deblocheaza si sid2
resp = client.post(
f"/trimitere/{sid1}/salveaza-regula-chip",
data={
"csrf_token": csrf,
"salveaza_op": op,
"salveaza_cod": "OE-1",
},
)
assert resp.status_code == 200, resp.text[:500]
# sid1 trebuie sa fie re-rezolvat
r1 = _row(sid1)
assert r1["status"] == "queued", (
f"sid1 trebuia sa fie queued dupa salvarea regulii, got {r1['status']!r}"
)
# sid2 (fratele) trebuie sa fie de asemenea re-rezolvat de reresolve_account
r2 = _row(sid2)
assert r2["status"] == "queued", (
f"sid2 (fratele) trebuia sa fie deblocat (queued) de reresolve_account, "
f"got {r2['status']!r}. reresolve_account trebuie sa re-rezolve TOATE "
f"submission-urile cu aceeasi operatie pe canalul API (batch_id IS NULL)."
)
# --------------------------------------------------------------------------- #
# Test 3: Editare one-off nu forteaza salvarea regulii (US-009 AC3) #
# --------------------------------------------------------------------------- #
def test_optional_nu_forteaza(client):
"""US-009 AC3: editarea ramane one-off daca userul nu apeleaza salveaza-regula-chip.
POST-ul la /corecteaza (fara /salveaza-regula-chip) trebuie sa functioneze normal;
nicio regula nu se salveaza automat in operations_mapping.
RED: daca /corecteaza ar salva automat regula (nedorit), testul pica.
"""
acct = _create_account_user("optional.oneoff@test.com")
_login(client, "optional.oneoff@test.com")
_seed_cod("OE-1", "Schimb ulei")
op = "Schimb ulei special 0W20"
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0AP009",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": op, "denumire": "Ulei sintetic 0W20"}],
})
csrf = _csrf(client)
# Corecteaza one-off (direct la /corecteaza, fara /salveaza-regula-chip)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
)
assert resp.status_code == 200
r = _row(sid)
assert r["status"] == "queued", (
f"Status trebuia sa fie queued dupa corectie one-off, got {r['status']!r}"
)
# Regula NU trebuie salvata automat in operations_mapping
rule = _get_rule(acct, op)
assert rule is None, (
f"Regula a fost salvata AUTOMAT in operations_mapping (nu ar trebui!): "
f"op={op!r}, rule={dict(rule) if rule else None}. "
"Salvarea regulii trebuie sa fie OPTIONALA (US-009 AC3)."
)
# --------------------------------------------------------------------------- #
# Test 4: Cleanup (B) — <select name="cod_prestatie"> redundant eliminat #
# --------------------------------------------------------------------------- #
def test_fara_select_vechi_redundant(client):
"""Cleanup (B): detaliu needs_data NU mai contine <select name='cod_prestatie'>.
Dupa cleanup, formularul editabil foloseste NUMAI chips (hidden inputs cod_prestatie),
fara vechiul select dublu. Chips functioneaza ca singura sursa de cod_prestatie.
Nota: <select name="cod_prestatie"> RAMANE in formularul /repune pentru starea error
(neschimbat — nu e subiectul cleanup-ului B).
RED: inainte de cleanup, atat selectul cat si chips emit cod_prestatie → dublu.
"""
acct = _create_account_user("no.select.vechi@test.com")
_login(client, "no.select.vechi@test.com")
_seed_cod("OE-1", "Schimb ulei")
# needs_data: starea editabila (editabil=True); chip cu cod setat
sid = _insert(acct, status="needs_data", payload={
"vin": "WVWZZZ1JZXW0NS001",
"nr_inmatriculare": "B200AA",
"data_prestatie": "2026-06-20",
"odometru_final": "", # gol -> needs_data
"prestatii": [{"cod_prestatie": "OE-1", "cod_op_service": "Op-A", "denumire": "Schimb ulei"}],
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Chipurile trebuie sa fie prezente (hidden input cu name="cod_prestatie")
has_chip_hidden = (
re.search(r'<input[^>]+type=["\']hidden["\'][^>]+name=["\']cod_prestatie["\']', html) or
re.search(r'<input[^>]+name=["\']cod_prestatie["\'][^>]+type=["\']hidden["\']', html)
)
assert has_chip_hidden, (
"Chips: trebuie sa existe input hidden cu name='cod_prestatie' (din _chips_prestatii.html). "
f"html[:600]={html[:600]}"
)
# Vechiul <select name="cod_prestatie"> NU trebuie sa existe in sectiunea editabila.
# (needs_data nu are formular /repune, deci niciun select cu name="cod_prestatie" legal)
select_cod_prestatie = (
re.search(r'<select[^>]+name=["\']cod_prestatie["\']', html) or
re.search(r'<select[^>]*name="cod_prestatie"', html)
)
assert not select_cod_prestatie, (
"Sectiunea editabila needs_data NU trebuie sa mai contina "
"<select name='cod_prestatie'>. "
"Chips-urile (hidden inputs) il inlocuiesc (cleanup B, US-009). "
f"Gasit: {select_cod_prestatie.group(0) if select_cod_prestatie else 'N/A'}. "
f"html[:800]={html[:800]}"
)

View File

@@ -0,0 +1,99 @@
"""Bug fix (code-review 5.15): modalul de detaliu trebuie sa se deschida la
click/Enter pe randul SLIM.
US-004 a redenumit randul listei in `class="trimitere-slim"` (ID-ul ramane
`trimitere-row-{id}`). Handler-ele JS din base.html verificau doar
`classList.contains('trimitere-row')` -> nu se mai potriveau pe randul slim,
deci modalul nu se mai deschidea. Testul asserteaza ca JS-ul randat (pagina
completa /) trateaza explicit clasa `trimitere-slim` in AMBELE handler:
htmx:beforeRequest (click) si keydown (Enter/Space).
"""
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, "modal_slim_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_handler_click_recunoaste_rand_slim(client):
"""htmx:beforeRequest deschide modalul si pentru clasa trimitere-slim."""
_create_account_user("modal_click@test.com")
_login(client, "modal_click@test.com")
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# Handler-ul de click (htmx:beforeRequest) trebuie sa trateze trimitere-slim.
open_line = re.search(r"contains\('trimitere-slim'\)[^\n]*open\(elt\)", html)
assert open_line, (
"Handler-ul de click (htmx:beforeRequest -> open(elt)) nu recunoaste "
"clasa 'trimitere-slim' -> modalul nu se deschide la click pe randul slim"
)
def test_handler_keyboard_recunoaste_rand_slim(client):
"""keydown (Enter/Space) declanseaza click si pentru clasa trimitere-slim."""
_create_account_user("modal_kbd@test.com")
_login(client, "modal_kbd@test.com")
resp = client.get("/")
assert resp.status_code == 200
html = resp.text
# Handler-ul keydown verifica clasa inainte de a chema t.click().
# Trebuie sa includa 'trimitere-slim' in conditia de garda.
kbd = re.search(
r"keydown[^\n]*\n(?:.*\n){0,6}?.*contains\('trimitere-slim'\)",
html,
)
assert kbd, (
"Handler-ul keydown (Enter/Space) nu recunoaste clasa 'trimitere-slim' "
"-> tastatura nu deschide modalul pe randul slim"
)

View File

@@ -500,3 +500,241 @@ def test_liste_actionabile_o_coloana_pana_1024(client):
# Blocul tableta cardifica listele (thead ascuns = card per rand, o coloana).
assert ".tabel-trimiteri thead, .tabel-card thead { display:none; }" in html, \
"Blocul tableta nu ascunde thead-ul pentru cardificare (o coloana)"
# ============================================================
# PRD 5.15 US-002: componente de design slim (CSS, fara consumatori)
# ============================================================
def _bloc_componente_slim(html: str) -> str:
"""Extrage blocul CSS dintre sentinelii SENTINEL-COMPONENTE-SLIM (inceput si sfarsit).
Testeaza existenta ambilor sentineli si returneaza continutul dintre ei.
"""
sentinel = "SENTINEL-COMPONENTE-SLIM"
i = html.find(sentinel)
assert i != -1, "Lipseste SENTINEL-COMPONENTE-SLIM in base.html (US-002 PRD 5.15)"
i2 = html.find(sentinel, i + len(sentinel))
assert i2 != -1, "Lipseste al doilea SENTINEL-COMPONENTE-SLIM (sfarsit bloc US-002)"
return html[i : i2 + len(sentinel)]
def test_clasa_contor_card(client):
""".contor-card: fundal --card2, bordura --line, radius 8px, padding.
Sub-elemente: .contor-cifra (cifra mare bold), .contor-label (muted), .contor-sub (mono).
"""
_create_account_user("cc2@test.com")
_login(client, "cc2@test.com")
html = client.get("/?tab=acasa").text
assert ".contor-card" in html, ".contor-card lipseste din CSS (base.html)"
bloc = _bloc_componente_slim(html)
assert "var(--card2)" in bloc, ".contor-card nu foloseste var(--card2) ca fundal"
assert "var(--line)" in bloc, ".contor-card nu are bordura var(--line)"
assert "border-radius:8px" in bloc, ".contor-card lipseste border-radius:8px"
assert ".contor-cifra" in bloc, "sub-elementul .contor-cifra lipseste din bloc"
assert ".contor-label" in bloc, "sub-elementul .contor-label lipseste din bloc"
assert "var(--muted)" in bloc, ".contor-label nu foloseste var(--muted)"
def test_clasa_lista_slim(client):
""".lista-trimiteri-slim + .trimitere-slim: separator --line2, padding, tinta min-height:44px.
Sub-elemente: .slim-vin (mono) si .slim-meta (muted 11px).
"""
_create_account_user("ls2@test.com")
_login(client, "ls2@test.com")
html = client.get("/?tab=acasa").text
assert ".lista-trimiteri-slim" in html, ".lista-trimiteri-slim lipseste din CSS (base.html)"
assert ".trimitere-slim" in html, ".trimitere-slim lipseste din CSS (base.html)"
bloc = _bloc_componente_slim(html)
assert "var(--line2)" in bloc, ".trimitere-slim nu foloseste var(--line2) ca separator"
assert "min-height:44px" in bloc, ".trimitere-slim nu are tinta min-height:44px"
assert ".slim-vin" in bloc, ".slim-vin lipseste din bloc"
assert ".slim-meta" in bloc, ".slim-meta lipseste din bloc"
assert "var(--muted)" in bloc, ".slim-meta nu foloseste var(--muted)"
def test_clasa_camp_slim(client):
""".camp-slim CSS exista cu fundal --card2.
Macro-ul camp() din _macros.html suporta parametrul slim=False ca default.
Default slim=False garanteaza ca randarea actuala ramane neschimbata.
"""
_create_account_user("cslim2@test.com")
_login(client, "cslim2@test.com")
html = client.get("/?tab=acasa").text
assert ".camp-slim" in html, ".camp-slim lipseste din CSS (base.html)"
bloc = _bloc_componente_slim(html)
assert "var(--card2)" in bloc, ".camp-slim nu foloseste var(--card2) ca fundal"
# Macro-ul camp() din _macros.html trebuie sa aiba parametrul slim
macros_path = os.path.join(
os.path.dirname(__file__), "..", "app", "web", "templates", "_macros.html"
)
with open(macros_path, encoding="utf-8") as f:
macros = f.read()
assert "slim" in macros, "macro-ul camp() nu are parametrul slim in _macros.html"
assert "slim=False" in macros, (
"macro-ul camp() nu are slim=False ca default — randarea actuala poate fi rupta"
)
def test_clasa_chips(client):
""".chips (container) + .chip (item): accent 18%, font 10-11px.
.chip-del: buton de stergere accesibil (element separat in CSS).
"""
_create_account_user("chp2@test.com")
_login(client, "chp2@test.com")
html = client.get("/?tab=acasa").text
assert ".chips" in html, ".chips lipseste din CSS (base.html)"
assert ".chip" in html, ".chip lipseste din CSS (base.html)"
bloc = _bloc_componente_slim(html)
assert "var(--accent)" in bloc, ".chip nu foloseste var(--accent)"
assert "18%" in bloc, ".chip nu are fundal accent 18% (color-mix accent 18%)"
assert "11px" in bloc or "10px" in bloc, ".chip nu are font 10-11px"
assert ".chip-del" in bloc, ".chip-del (buton de stergere) lipseste din bloc"
def test_fara_hex_in_componente_noi(client):
"""Zero hex literal in blocul CSS nou (SENTINEL-COMPONENTE-SLIM).
Toate culorile folosesc EXCLUSIV var(--token), zero #rrggbb hardcodat.
Ancorat pe SENTINEL ca sa nu scaneze blocul CSS vechi (unde exista #fff).
"""
_create_account_user("hexfree2@test.com")
_login(client, "hexfree2@test.com")
html = client.get("/?tab=acasa").text
bloc = _bloc_componente_slim(html)
# Cauta hex literals in proprietati CSS de culoare
hex_in_props = re.findall(
r"(?:color|background|border(?:-color)?|fill|stroke)\s*:[^;{}]*?"
r"(#[0-9a-fA-F]{3,8})",
bloc,
)
assert not hex_in_props, (
f"Hex literal gasit in componente noi US-002 — folositi var(--token): {hex_in_props}"
)
# ============================================================
# PRD 5.15 US-004: lista slim trimiteri — layout consistent desktop + <=1024px
# ============================================================
def test_lista_slim_randeaza_si_are_tinta_touch(client):
"""US-004: lista slim randeaza cu .trimitere-slim; tinta touch >=44px
e garantata de CSS (min-height:44px din blocul SENTINEL-COMPONENTE-SLIM).
Cardurile .tabel-trimiteri din 5.8 nu regreseaza: regula tabel-trimiteri
thead display:none (card pe mobil) exista in continuare in base.html.
"""
acct = _create_account_user("slim_resp@test.com")
_insert_submission(acct, status="sent")
_login(client, "slim_resp@test.com")
html = client.get("/?tab=acasa").text
# Lista slim randeaza (elementele sunt injectate via hx-get="/_fragments/submissions"
# -> testam ca clasele CSS sunt prezente in base.html, gata sa fie consumate)
bloc = _bloc_componente_slim(html)
assert "lista-trimiteri-slim" in bloc, \
".lista-trimiteri-slim lipseste din blocul CSS slim (US-002 prerequisite)"
assert "trimitere-slim" in bloc, \
".trimitere-slim lipseste din blocul CSS slim"
assert "min-height:44px" in bloc, \
".trimitere-slim nu are min-height:44px — tinta touch mobil garantata"
# Regresie guard: regula card per rand 5.8 supravietuieste (o coloana pe mobil)
mobil = _bloc_mobil_principal(html)
assert ".tabel-trimiteri thead { display:none; }" in mobil, \
"Regula card 5.8 (.tabel-trimiteri thead display:none) a disparut din CSS"
def test_lista_slim_layout_tableta_1024(client):
"""US-004: blocul tableta (768-1024px) nu rupe lista slim.
.trimitere-slim e o lista stivuita (o coloana), fara grila 2/rand.
Regula tableta cardifica listele existente (thead display:none) fara a elimina slim.
"""
_create_account_user("slim_tab@test.com")
_login(client, "slim_tab@test.com")
html = client.get("/?tab=acasa").text
# Blocul tableta exista (PRD 5.12/5.13 — pastrat)
assert "@media (min-width:768px) and (max-width:1024px)" in html, \
"Lipseste blocul @media tableta — regresia 5.12"
idx_t = html.find("@media (min-width:768px) and (max-width:1024px)")
tableta = html[idx_t:idx_t + 800]
# Tableta ascunde thead (card per rand, o coloana) — lista slim e deja o coloana
assert "thead" in tableta, \
"Blocul tableta nu contine reguli pentru thead"
# Lista slim (ul.lista-trimiteri-slim) e o coloana prin constructie (flex-direction:column
# implicit pe ul); nu trebuie repeat(2) in CSS.
assert "repeat(2" not in html, \
"CSS contine repeat(2 — listele NU trebuie sa fie 2/rand pana la 1024px"
# ============================================================
# PRD 5.15 US-008: regresie componente slim + fara overflow orizontal
# ============================================================
def test_slim_list_fara_overflow_orizontal_css(client):
"""US-008: lista slim (.trimitere-slim) nu forteaza overflow orizontal pe 390px / 1280px.
Verifica la nivel CSS / markup (nu browser): display:flex garanteaza adaptarea
naturala la latimea containerului; niciun min-width fix mai mare de 390px pe elementele
din blocul SENTINEL-COMPONENTE-SLIM (ar depasi viewport-ul mobil de 390px).
Ancorare pe SENTINEL-COMPONENTE-SLIM — nu pe felii fixe din CSS global.
"""
_create_account_user("noovf_slim@test.com")
_login(client, "noovf_slim@test.com")
html = client.get("/?tab=acasa").text
bloc = _bloc_componente_slim(html)
# display:flex pe .trimitere-slim asigura adaptarea la latimea oricarui viewport
assert "display:flex" in bloc, (
".trimitere-slim nu are display:flex in SENTINEL-COMPONENTE-SLIM — "
"layout nu se adapteaza la viewport; poate cauza overflow orizontal."
)
# nicio valoare min-width > 390 in blocul slim (ar depasi viewport-ul mobil 390px)
min_widths = re.findall(r'min-width:(\d+)px', bloc)
for w in min_widths:
assert int(w) <= 390, (
f"min-width:{w}px in SENTINEL-COMPONENTE-SLIM poate cauza overflow orizontal "
f"pe viewport de 390px (mobil). Verificati daca e pe .trimitere-slim."
)
def test_strip_sanatate_fara_hex_hardcodat():
"""US-008: _status.html (strip sanatate D6 + contoare-contor) nu contine hex literal de culoare.
Garanteaza ca strip-ul adapteaza la temele luminoase (hartie/light) si intunecate (grafit/dark)
exclusiv prin var(--token), NU prin valori hex hardcodate care ar ramane aceleasi
indiferent de tema selectata.
Complement la test_fara_hex_in_componente_noi (care verifica SENTINEL din base.html).
Strip sanatate traieste in _status.html, deci e verificat separat.
"""
from pathlib import Path
templates_dir = Path(__file__).parent.parent / "app" / "web" / "templates"
content = (templates_dir / "_status.html").read_text(encoding="utf-8")
# Hex literals in context de proprietate CSS de culoare (color/background/border + inline style)
hex_in_culori = re.findall(
r'(?:color|background|border)\s*[:=][^;{}\n"\']*?(#[0-9a-fA-F]{6,8})\b',
content,
)
assert not hex_in_culori, (
f"Hex literal de culoare in _status.html — strip sanatate va arata gresit pe "
f"tema hartie (luminoasa) / light. Folositi var(--token). Gasite: {hex_in_culori}"
)

231
tests/test_web_scope.py Normal file
View File

@@ -0,0 +1,231 @@
"""US-011 (PRD 5.15): account-scope pe GET-urile de listare web (securitate).
Verifica:
- /_fragments/submissions: un cont nu vede randurile altui cont
- /_fragments/trimitere/{id}: 404-before-leak pe id strain
- /_fragments/nomenclator: necesita autentificare (fragment dashboard, nu endpoint public)
- /_fragments/trimiteri-versiune: necesita autentificare
- Unauthenticated access -> redirect 303 pe ORICE fragment cu date
Legatura cu implementare: mecanismul de scope existent (require_login +
account_scope_clause) se reutilizeaza fara logica noua. Nomenclatorul primeste
require_login din US-011 (fragment dashboard, nu endpoint public).
"""
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, "csrf_token negasit in pagina de login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _insert_submission(
acct: int,
status: str = "sent",
vin: str = "WVWZZZ1JZXW000777",
nr: str = "B777TST",
) -> int:
from app.db import get_connection
conn = get_connection()
try:
p = {
"vin": vin,
"nr_inmatriculare": nr,
"data_prestatie": "2026-06-18",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}],
}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"scope-{acct}-{os.urandom(4).hex()}", acct, status, json.dumps(p)),
)
conn.commit()
rid = cur.lastrowid
assert rid is not None
return int(rid)
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "scope.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()
# ---------------------------------------------------------------------------
# Test 1: cross-account isolation pe listare submissions
# ---------------------------------------------------------------------------
def test_get_listare_scoped_cont(client):
"""Un cont NU vede randurile (VIN/PII) ale altui cont in /_fragments/submissions.
Contul A are un submission cu nr. inmatriculare unic NR_A; contul B nu trebuie
sa vada NR_A in listarea sa. Verificam izolarea atat prin nr. cat si prin VIN
(ultimele 6 caractere afisate ca vin_scurt in template).
"""
NR_A = "BV01SCO"
NR_B = "BV02SCO"
VIN_A = "WVWZZZ1JZXW111AAA" # vin_scurt va fi '...111AAA'
VIN_B = "WVWZZZ1JZXW222BBB" # vin_scurt va fi '...222BBB'
acct_a = _create_account_user("scope-a@test.com", name="ContA")
acct_b = _create_account_user("scope-b@test.com", name="ContB")
_insert_submission(acct_a, vin=VIN_A, nr=NR_A)
_insert_submission(acct_b, vin=VIN_B, nr=NR_B)
# Login ca cont B
_login(client, "scope-b@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Contul B vede propriul nr inmatriculare
assert NR_B in html, "Contul B ar trebui sa vada propriul nr inmatriculare"
# Contul B NU vede nr inmatriculare si VIN (vin_scurt) ale contului A
assert NR_A not in html, "Scurgere cross-account: nr_inmatriculare contului A vizibil contului B"
assert "111AAA" not in html, "Scurgere cross-account: VIN (vin_scurt) contului A vizibil contului B"
# ---------------------------------------------------------------------------
# Test 2: unauthenticated -> redirect pe listare submissions
# ---------------------------------------------------------------------------
def test_get_listare_neautentificat_redirect_submissions(client):
"""Fara sesiune activa, /_fragments/submissions returneaza 303 (redirect /login)."""
resp = client.get("/_fragments/submissions")
assert resp.status_code == 303, (
f"Asteptat 303 redirect, primit {resp.status_code}. "
"/_fragments/submissions trebuie sa necesite autentificare."
)
# ---------------------------------------------------------------------------
# Test 3: 404-before-leak pe detaliu id strain
# ---------------------------------------------------------------------------
def test_get_detaliu_scoped_404(client):
"""Detaliul unui submission apartinand altui cont returneaza 404 (fara leak).
Acelasi 404 pentru id inexistent = nu confirmam existenta.
"""
acct_a = _create_account_user("detscope-a@test.com", name="DetA")
_create_account_user("detscope-b@test.com", name="DetB")
sid_a = _insert_submission(acct_a, vin="WVWZZZ1JZXWAAA111")
# Login ca cont B
_login(client, "detscope-b@test.com")
# Cerere detaliu pe submission-ul contului A
resp = client.get(f"/_fragments/trimitere/{sid_a}")
assert resp.status_code == 404, (
f"Asteptat 404, primit {resp.status_code}. "
"Nu trebuie confirmata existenta unui submission al altui cont."
)
# Id inexistent -> acelasi 404 (nu confirmam existenta)
resp2 = client.get("/_fragments/trimitere/999999")
assert resp2.status_code == 404
# ---------------------------------------------------------------------------
# Test 4: nomenclator necesita autentificare (RED inainte de fix)
# ---------------------------------------------------------------------------
def test_get_nomenclator_neautentificat_redirect(client):
"""/_fragments/nomenclator este un fragment al dashboard-ului autentificat.
Fara sesiune, trebuie sa returneze 303 redirect la /login.
RED inainte de fix: in prezent fragmentul nu apeleaza require_login
si returneaza 200 chiar fara autentificare.
"""
resp = client.get("/_fragments/nomenclator")
assert resp.status_code == 303, (
f"Asteptat 303 redirect pentru fragment dashboard neautentificat, "
f"primit {resp.status_code}. "
"/_fragments/nomenclator trebuie sa necesite autentificare."
)
def test_get_nomenclator_autentificat_ok(client):
"""/_fragments/nomenclator accesibil dupa autentificare."""
_create_account_user("nom-auth@test.com", name="NomAuth")
_login(client, "nom-auth@test.com")
resp = client.get("/_fragments/nomenclator")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Test 5: trimiteri-versiune necesita autentificare
# ---------------------------------------------------------------------------
def test_get_trimiteri_versiune_neautentificat_redirect(client):
"""/_fragments/trimiteri-versiune necesita autentificare (redirect 303 fara sesiune)."""
resp = client.get("/_fragments/trimiteri-versiune")
assert resp.status_code == 303
# ---------------------------------------------------------------------------
# Test 6: izolare paginare - filtru nu poate scoate randuri strain
# ---------------------------------------------------------------------------
def test_get_listare_filtru_nu_sparge_scope(client):
"""Filtrele (status, vehicul) nu pot scoate randuri din alt cont.
Un cont B cu filtru vehicul=VIN_A nu trebuie sa vada niciodata VIN_A.
"""
VIN_A = "WVWZZZ1JZXW333CCC"
NR_A = "BV03FLT"
acct_a = _create_account_user("filtru-a@test.com", name="FiltrA")
_create_account_user("filtru-b@test.com", name="FiltrB")
_insert_submission(acct_a, vin=VIN_A, nr=NR_A)
_login(client, "filtru-b@test.com")
# Cont B incearca sa filtreze dupa nr inmatriculare al contului A
resp = client.get(f"/_fragments/submissions?vehicul={NR_A}")
assert resp.status_code == 200
assert NR_A not in resp.text, (
"Filtrul vehicul a scos date din alt cont (scurgere cross-account prin filtru)."
)

View File

@@ -1,15 +1,21 @@
"""Teste US-001 (PRD 3.5): bara de status compacta cu bife accesibile + data formatata.
"""Teste US-003 (PRD 5.15): strip sanatate mereu-vizibil + carduri-contor pe dashboard.
Bifa = glifa distincta (✓ / ✗) + text, NU doar culoare (daltonism, design review).
Verde/✓ cand worker viu + RAR ok; rosu/✗ cand oprit/indisponibil.
D6 (strip sanatate): linie colorata DEASUPRA contoarelor — verde "declaratiile curg" /
rosu "Blocat: worker oprit / RAR inaccesibil", cu glifa accesibila (✓/✗).
D4 (contoare): In coada / Trimise (all-time + luna/azi) / De corectat.
E7 (timezone): azi/luna bucketate in timp local RO (UTC+3), nu UTC.
Actualizat in US-003 (PRD 5.15): bara veche cu bife individuale worker/RAR
inlocuita de strip unificat de sanatate + carduri-contor.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
import pytest
from starlette.testclient import TestClient
@@ -70,8 +76,12 @@ def client(monkeypatch):
get_settings.cache_clear()
# ---------------------------------------------------------------------------
# Teste existente — actualizate pentru US-003 D6 (strip unificat in loc de bife individuale)
# ---------------------------------------------------------------------------
def test_status_are_bife_verzi_cand_totul_ok(client):
"""Worker viu + RAR login recent -> bifa verde ✓ pentru ambele stari binare."""
"""Worker viu + RAR login recent -> glifa verde ✓ + text 'declaratiile curg normal'."""
_create_account_user("bifeok@test.com")
_login(client, "bifeok@test.com", "parolasecreta10")
@@ -81,15 +91,16 @@ def test_status_are_bife_verzi_cand_totul_ok(client):
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
# Glifa de OK prezenta (accesibilitate: nu doar culoare)
# Glifa accesibila ✓ (nu doar culoare)
assert "&#10003;" in html, f"Lipseste glifa ✓ cand totul e ok. HTML: {html[:600]}"
# Texte umane de OK
assert "activa" in html.lower()
assert "functionala" in html.lower()
# US-003 D6: strip unificat (nu bife individuale worker/RAR)
assert "curg normal" in html.lower(), (
f"Textul 'curg normal' din strip sanatate lipseste. HTML: {html[:600]}"
)
def test_status_are_bife_rosii_cand_worker_oprit(client):
"""Fara heartbeat -> worker oprit -> bifa rosie ✗ + text 'oprita'."""
"""Fara heartbeat -> worker oprit -> glifa rosie ✗ + text explicit 'blocat' / 'nu pleaca'."""
_create_account_user("biferosu@test.com")
_login(client, "biferosu@test.com", "parolasecreta10")
@@ -99,7 +110,9 @@ def test_status_are_bife_rosii_cand_worker_oprit(client):
assert resp.status_code == 200
html = resp.text
assert "&#10007;" in html, f"Lipseste glifa ✗ cand worker oprit. HTML: {html[:600]}"
assert "oprita" in html.lower()
# US-003 D6: mesaj explicit (nu text vag "oprita")
assert "blocat" in html.lower(), f"Cuvantul 'blocat' lipseste la worker oprit. HTML: {html[:600]}"
assert "nu pleaca" in html.lower(), f"Avertismentul 'nu pleaca' lipseste. HTML: {html[:600]}"
def test_status_data_formatata_romaneste(client):
@@ -118,12 +131,231 @@ def test_status_data_formatata_romaneste(client):
def test_status_fara_fonturi_minuscule(client):
"""Niciun text din bara nu mai foloseste font-size sub 13px (US-001 AC)."""
"""Niciun text din bara nu mai foloseste font-size literal sub 13px (US-001 AC)."""
_create_account_user("bifefont@test.com")
_login(client, "bifefont@test.com", "parolasecreta10")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
# Culorile prin clase CSS (nu inline font-size); shorthand font:N Xpx nu e acoperit de aceste litere
for bad in ("font-size:11px", "font-size:12px", "font-size: 11px", "font-size: 12px"):
assert bad not in html, f"Bara de status foloseste {bad} (sub 13px)."
assert bad not in html, f"Bara de status foloseste {bad} (sub 13px) inline."
# ---------------------------------------------------------------------------
# Teste NOI pentru US-003 (RED inainte de implementare)
# ---------------------------------------------------------------------------
def test_strip_sanatate_mereu_vizibil(client):
"""D6: strip de sanatate e prezent in fragment, indiferent de starea worker/RAR."""
_create_account_user("stripviz@test.com")
_login(client, "stripviz@test.com", "parolasecreta10")
# Stare worker viu
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert 'id="strip-sanatate"' in html, (
f"Strip sanatate (id='strip-sanatate') lipseste din fragment. HTML: {html[:600]}"
)
def test_strip_rosu_worker_oprit(client):
"""D6: worker oprit → strip rosu cu glifа ✗ + text 'Blocat: worker oprit — declaratiile NU pleaca'."""
_create_account_user("stroprosu@test.com")
_login(client, "stroprosu@test.com", "parolasecreta10")
_set_heartbeat(last_beat=None, last_rar_login_ok=None)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert 'id="strip-sanatate"' in html, "Strip sanatate lipseste."
assert "&#10007;" in html, "Glifa ✗ lipseste in strip rosu."
assert "blocat" in html.lower(), "Cuvantul 'Blocat' trebuie sa apara cand worker e oprit."
assert "worker oprit" in html.lower(), "Textul 'worker oprit' trebuie sa fie explicit in strip."
assert "nu pleaca" in html.lower(), "Avertismentul 'NU pleaca' trebuie sa fie in strip."
def test_trei_contoare_card(client):
"""US-003: fragment status contine exact 3 carduri .contor-card (In coada / Trimise / De corectat)."""
_create_account_user("treicont@test.com")
_login(client, "treicont@test.com", "parolasecreta10")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
count = html.count("contor-card")
assert count >= 3, (
f"Trebuie minim 3 elemente contor-card in fragment, gasit: {count}. HTML: {html[:800]}"
)
# Etichete asteptate
assert "In coada" in html, "Eticheta 'In coada' lipseste din contoare."
assert "Trimise" in html, "Eticheta 'Trimise' lipseste din contoare."
assert "De corectat" in html, "Eticheta 'De corectat' lipseste din contoare."
def test_trimise_all_time_luna_azi(client):
"""D4: cardul Trimise afiseaza all-time ca cifra principala + sub-linie 'luna N · azi N'."""
acct_id, _ = _create_account_user("trimisetime@test.com")
_login(client, "trimisetime@test.com", "parolasecreta10")
# Insereaza o trimitere sent cu updated_at = acum
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key, updated_at) "
"VALUES (?, 'sent', ?, 'key-luna-azi', datetime('now'))",
(acct_id, json.dumps({"vin": "VINTEST00000000001"})),
)
conn.commit()
finally:
conn.close()
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
# Sub-linia trebuie sa contina "luna" si "azi" (format: "luna N · azi N")
assert "luna" in html.lower(), (
f"Sub-linia 'luna N · azi N' lipseste din cardul Trimise. HTML: {html[:800]}"
)
assert "azi" in html.lower(), (
f"Sub-linia 'luna N · azi N' nu contine 'azi'. HTML: {html[:800]}"
)
def test_fara_bara_veche(client):
"""US-003: contoarele vechi inline ('In asteptare:' / 'Declarate la RAR:') nu mai apar."""
_create_account_user("faraveche@test.com")
_login(client, "faraveche@test.com", "parolasecreta10")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
# Stilul vechi: etichete inline cu colon (bara de la PRD 3.5)
assert "In asteptare:" not in html, (
f"Contorul vechi 'In asteptare:' inca prezent. HTML: {html[:600]}"
)
assert "Declarate la RAR:" not in html, (
f"Contorul vechi 'Declarate la RAR:' inca prezent. HTML: {html[:600]}"
)
def _set_tz_bucuresti(monkeypatch, request):
"""Forteaza TZ=Europe/Bucharest pentru ca modificatorul SQLite 'localtime' sa
rezolve la fusul RO indiferent de TZ-ul runner-ului (CI ruleaza de regula in UTC).
Restaureaza tzset-ul la teardown (monkeypatch reface env-ul TZ; tzset reciteste)."""
import time
monkeypatch.setenv("TZ", "Europe/Bucharest")
if hasattr(time, "tzset"):
time.tzset()
request.addfinalizer(time.tzset) # dupa ce monkeypatch reface TZ -> reciteste
def test_granita_miez_noapte_local_ro(monkeypatch, request):
"""E7: trimitere cu updated_at = ieri UTC 22:00 = azi Romania (UTC+2/+3) se numara 'azi'.
Cu date(updated_at) simplu (UTC) ar aparea pe ziua precedenta — GRESIT.
Cu date(updated_at, 'localtime') + TZ=Europe/Bucharest apare pe ziua de azi RO — CORECT
(DST-aware: +2h iarna, +3h vara).
"""
_set_tz_bucuresti(monkeypatch, request)
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "granita.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
from app.accounts import create_account
from app.web.routes import _status_counts
# Initializeaza schema (init_db o face idempotent)
init_db()
# Ieri la 22:00 UTC = azi 00:00 (iarna) / 01:00 (vara) Romania -> 'azi' in ambele.
today_utc = datetime.now(timezone.utc).date()
yesterday_utc = today_utc - timedelta(days=1)
boundary_updated_at = f"{yesterday_utc} 22:00:00"
conn = get_connection()
try:
acct_id = create_account(conn, "Service Granita", active=True)
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key, updated_at) "
"VALUES (?, 'sent', ?, 'key-granita-1', ?)",
(acct_id, json.dumps({"vin": "VIN00000000000001"}), boundary_updated_at),
)
conn.commit()
counts = _status_counts(conn, acct_id)
# 22:00 UTC -> azi in RO (localtime) => sent_today=1
assert counts["sent_today"] == 1, (
f"E7 FAIL: trimitere la {boundary_updated_at} UTC = azi in Romania "
f"trebuie sa fie 'azi', dar sent_today={counts.get('sent_today')}. "
"SQL trebuie sa foloseasca date(updated_at, 'localtime') = date('now', 'localtime')."
)
assert counts["sent_month"] >= 1, (
f"E7: sent_month trebuie sa fie >= 1, got {counts.get('sent_month')}"
)
finally:
conn.close()
get_settings.cache_clear()
def test_iarna_nu_bleed_in_ziua_urmatoare(monkeypatch, request):
"""Bug fix (code-review 5.15): iarna (UTC+2), o trimitere la 21:30 UTC = 23:30 RO AZI
NU trebuie sa cada pe ziua de MAINE.
Vechiul offset fix '+3 hours' o impingea la 00:30 maine -> sent_today gresit.
'localtime' (DST-aware) o pastreaza corect pe azi. Testul fixeaza o data de iarna
explicita (15 ianuarie) ca sa fie determinist indiferent de cand ruleaza.
"""
_set_tz_bucuresti(monkeypatch, request)
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "iarna.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
from app.accounts import create_account
init_db()
# Data de iarna fixa: 2026-01-15. 21:30 UTC = 23:30 RO (EET, UTC+2) -> ziua 15, nu 16.
conn = get_connection()
try:
acct_id = create_account(conn, "Service Iarna", active=True)
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key, updated_at) "
"VALUES (?, 'sent', ?, 'key-iarna-1', ?)",
(acct_id, json.dumps({"vin": "VIN00000000000002"}), "2026-01-15 21:30:00"),
)
conn.commit()
# Verifica direct ce zi RO atribuie SQLite (localtime vs vechiul +3h).
row = conn.execute(
"SELECT date(updated_at, 'localtime') AS zi_local, "
" date(updated_at, '+3 hours') AS zi_plus3 "
"FROM submissions WHERE idempotency_key='key-iarna-1'"
).fetchone()
assert row["zi_local"] == "2026-01-15", (
f"localtime (EET, UTC+2) trebuie sa pastreze 21:30 UTC pe 15 ian RO, "
f"got {row['zi_local']}"
)
# Demonstreaza bug-ul vechi: +3h impingea pe 16 ian (ziua gresita iarna).
assert row["zi_plus3"] == "2026-01-16", (
f"Confirmare bug vechi: '+3 hours' iarna pune 21:30 UTC pe 16 ian, got {row['zi_plus3']}"
)
finally:
conn.close()
get_settings.cache_clear()

View File

@@ -94,7 +94,11 @@ def client(monkeypatch):
# ============================================================
def test_status_fragment_text_uman(client):
"""GET /_fragments/status (autentificat) -> contine 'Trimitere automata', NU 'worker viu'."""
"""GET /_fragments/status (autentificat) -> contine text uman de sanatate, NU stari tehnice brute.
Actualizat US-003 (PRD 5.15): strip sanatate unificat in loc de bife individuale worker/RAR.
Textul 'Trimitere automata' a fost inlocuit cu 'declaratiile curg normal' / 'Blocat: ...'.
"""
_create_account_user("status@test.com", "parolasecreta10")
_login(client, "status@test.com", "parolasecreta10")
@@ -102,17 +106,14 @@ def test_status_fragment_text_uman(client):
assert resp.status_code == 200
html = resp.text
# Trebuie sa contina textul uman din eticheta_worker (labels.py)
assert "Trimitere automata" in html, (
f"Fragmentul nu contine 'Trimitere automata'. HTML (primele 500 ch): {html[:500]}"
# US-003 D6: strip sanatate cu text uman compus (nu bife individuale)
assert "declaratiile" in html.lower(), (
f"Fragmentul nu contine textul de sanatate ('declaratiile'). HTML (primele 500 ch): {html[:500]}"
)
# NU trebuie sa contina textul brut tehnic
# NU trebuie sa contina text tehnic brut
assert "worker viu" not in html.lower(), (
f"Fragmentul contine 'worker viu' (text tehnic brut). HTML (primele 500 ch): {html[:500]}"
)
# NU trebuie sa contina "mort" (stare tehnica bruta)
# (poate aparea in 'oprita' -> acceptam; 'mort' singur -> nu)
# Verificam ca nu apare 'mort' ca eticheta standalone
assert "viu</div>" not in html, (
"Fragmentul contine eticheta bruta 'viu'"
)

View File

@@ -78,7 +78,11 @@ def client(monkeypatch):
def test_submissions_coloane_umane(client):
"""Antete RO; stare umana (nu 'sent'); vehicul/operatie din payload; fara 'HTTP RAR' ca antet."""
"""US-004 (PRD 5.15): layout slim — informatia umana vizibila, cod brut ascuns.
Anterior (pana la US-004): tabel cu antete <th>; dupa US-004: lista slim.
Informatia ramane accesibila: vehicul/operatie din payload, stare umana,
nr. prezentare RAR pe linia meta discreta.
"""
acct = _create_account_user("col@test.com")
_insert_submission(acct, "sent", id_prezentare=68516)
_login(client, "col@test.com")
@@ -86,17 +90,25 @@ def test_submissions_coloane_umane(client):
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Antete romanesti (Motiv a iesit din tabel in PRD 5.8 US-007 -> traieste in detaliu)
for antet in ("Stare", "Vehicul", "Operatie", "Data prestatie", "Nr. prezentare RAR"):
assert antet in html, f"Lipseste antetul '{antet}'"
# "HTTP RAR" NU mai e antet principal de coloana
# Layout slim prezent (US-004 PRD 5.15)
assert "lista-trimiteri-slim" in html, "lista slim lipseste"
assert "trimitere-slim" in html, "rand slim lipseste"
# "HTTP RAR" NU e antet / eticheta vizibila
assert "<th>HTTP RAR</th>" not in html
# Starea afisata e text uman, nu 'sent' brut intr-un pill
assert ">sent<" not in html, "Starea bruta 'sent' nu ar trebui afisata"
assert "Declarate la RAR" in html, "Starea umana lipseste"
# Vehicul + operatie din payload, nu doar idPrezentare
assert "B777ZZZ" in html
assert "Reparatie frane" in html
assert "HTTP RAR" not in html
# Starea afisata e text uman, nu 'sent' brut
assert ">sent<" not in html, "Starea bruta 'sent' nu ar trebui afisata direct"
assert "Declarate la RAR" in html, "Starea umana (titlu pill) lipseste"
# Vehicul + operatie din payload vizibile pe rand slim
assert "B777ZZZ" in html, "Nr inmatriculare din payload lipseste"
assert "Reparatie frane" in html, "Operatia din payload lipseste"
# Nr. prezentare RAR accesibil pe linia meta discreta
assert "68516" in html, "Nr. prezentare RAR lipseste din linia meta"
def test_tab_eticheta_trimiteri(client):
@@ -405,3 +417,128 @@ def test_detaliu_trimitere_404_cross_account(client):
# acelasi 404 pentru un id inexistent
resp2 = client.get("/_fragments/trimitere/999999")
assert resp2.status_code == 404
# ---------------------------------------------------------------------------
# US-004 (PRD 5.15): lista trimiteri rand slim
# RED (inainte de implementare): testele de mai jos ESUEAZA pe tabelul vechi.
# GREEN (dupa implementare): lista slim cu .trimitere-slim / .lista-trimiteri-slim.
# ---------------------------------------------------------------------------
def test_rand_slim_vin_operatie_pill(client):
"""US-004: fiecare rand slim afiseaza VIN scurt in .slim-vin, operatie+ora in
.slim-meta si un pill de stare cu clasa stare_css si eticheta stare_scurt.
Lista e inconjurata de .lista-trimiteri-slim.
"""
acct = _create_account_user("slim1@test.com")
_insert_submission(acct, "sent", id_prezentare=80001)
_login(client, "slim1@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Structura slim prezenta
assert "lista-trimiteri-slim" in html, "lista-trimiteri-slim lipseste din raspuns"
assert "trimitere-slim" in html, "trimitere-slim lipseste din raspuns"
# VIN scurt in clasa slim-vin (mono, linia 1)
assert "slim-vin" in html, "slim-vin lipseste — linia 1 VIN mono"
# Linia 2 muted (operatie + ora/data)
assert "slim-meta" in html, "slim-meta lipseste — linia 2 muted"
# VIN scurt randat (WVWZZZ1JZXW000777 -> …000777)
assert "000777" in html, "VIN scurt (ultimele 6 cifre) lipseste"
# Pill de stare: clasa CSS + eticheta scurta
assert "s-sent" in html, "clasa pill s-sent lipseste"
assert "Finalizat" in html, "eticheta scurta stare_scurt lipseste"
def test_filtre_paginare_pastrate(client):
"""US-004 lock: filtrele si paginarea raman functionale dupa redesign slim.
Lista slim afiseaza rezultatele filtrate corect.
"""
acct = _create_account_user("filtrepag@test.com")
_insert_submission(acct, "sent")
_insert_submission(acct, "needs_data")
_login(client, "filtrepag@test.com")
# Fara filtru: ambele randuri, layout slim
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
assert "lista-trimiteri-slim" in html, "lista slim lipseste din randare nefiltered"
# OOB f-page exista (mecanismul de paginare intact)
assert 'id="f-page"' in html, "OOB input f-page lipseste — paginarea e rupta"
# Filtrare dupa status=sent: contul trebuie sa arate DOAR trimis
resp_sent = client.get("/_fragments/submissions?status=sent")
assert resp_sent.status_code == 200
html_sent = resp_sent.text
assert "lista-trimiteri-slim" in html_sent, "lista slim lipseste din randare filtrata"
# Randul needs_data dispare la filtru=sent
assert "s-needs_data" not in html_sent, "randul needs_data apare la filtru status=sent"
# Randul sent apare
assert "s-sent" in html_sent, "randul sent lipseste la filtru status=sent"
def test_bulk_doar_blocate(client):
"""US-004 lock: form bulk-trimiteri exista; checkbox DOAR pe randuri gestionabile
(needs_data/needs_mapping/error); randurile read-only (sent) nu au checkbox.
"""
acct = _create_account_user("bulk5@test.com")
sid_blocked = _insert_submission(acct, "needs_data") # gestionabil -> checkbox
_insert_submission(acct, "sent") # read-only -> fara checkbox
_login(client, "bulk5@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Layout slim prezent
assert "lista-trimiteri-slim" in html, "lista slim lipseste — test nu acopera US-004"
# Form bulk exista cu actiunea corecta
assert 'id="bulk-trimiteri"' in html, "form#bulk-trimiteri lipseste"
assert 'hx-post="/trimiteri/sterge-bulk"' in html, "actiunea bulk-delete lipseste"
# Checkbox prezent pe randul blocat
assert f'name="submission_id" value="{sid_blocked}"' in html, \
f"Checkbox lipseste pe randul blocat #{sid_blocked}"
# Exact 1 checkbox (randul sent nu are)
checkboxes = re.findall(r'name="submission_id"', html)
assert len(checkboxes) == 1, \
f"Trebuie exact 1 checkbox (doar randul blocat), gasit {len(checkboxes)}"
def test_click_deschide_detaliu(client):
"""US-004: click pe randul slim deschide /_fragments/trimitere/{id} in modalul global.
Randul are atributele HTMX si a11y necesare (role=button, aria-haspopup=dialog).
"""
acct = _create_account_user("clickdet@test.com")
sid = _insert_submission(acct, "sent")
_login(client, "clickdet@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Layout slim ca premisa (confirma ca testul acopera noul design)
assert "lista-trimiteri-slim" in html, "lista slim lipseste — test nu acopera US-004"
# Randul are hx-get catre fragmentul de detaliu
assert f'hx-get="/_fragments/trimitere/{sid}"' in html, \
f"hx-get spre /_fragments/trimitere/{sid} lipseste"
# Target = corpul modalului global (neschimbat fata de implementarea anterioara)
assert 'hx-target="#detaliu-modal-body"' in html, \
"hx-target #detaliu-modal-body lipseste"
# Atribute a11y: role=button, tabindex=0, aria-haspopup=dialog
assert 'role="button"' in html, "role=button lipseste pe randul slim"
assert 'tabindex="0"' in html, "tabindex=0 lipseste pe randul slim"
assert 'aria-haspopup="dialog"' in html, "aria-haspopup=dialog lipseste pe randul slim"

View File

@@ -82,14 +82,14 @@ def client(monkeypatch):
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.
"""VIN-ul apare intr-un element block-level cu clasa slim-vin (PRD 5.15 US-004).
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.
PRD 5.10 (US-005): VIN era <div class="muted"> sub nr in coloana Vehicul.
PRD 5.15 (US-004): VIN e acum identificatorul PRINCIPAL, linia 1 a randului slim,
in <div class="slim-vin"> (mono, prominent, block-level). NU mai e muted.
"""
acct = _create_account_user("vin_layout@test.com")
sid = _ins(acct, vin="WVWZZZ1JZXW000001", nr="B123XYZ")
_ins(acct, vin="WVWZZZ1JZXW000001", nr="B123XYZ")
_login(client, "vin_layout@test.com")
resp = client.get("/_fragments/submissions")
@@ -97,44 +97,29 @@ def test_vin_pe_rand_separat_sub_nr(client):
html = resp.text
# VIN trunchiat trebuie sa apara in HTML
assert "000001" in html, "VIN-ul trunchiat trebuie sa apara in tabel"
assert "000001" in html, "VIN-ul trunchiat trebuie sa apara in lista slim"
# 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 e intr-un element block-level (div cu clasa slim-vin)
# Pattern: <div class="slim-vin">...000001...</div>
vin_fragment = "000001"
found_block = any(
re.search(
rf"<{tag}[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</{tag}>",
html,
)
for tag in block_tags
found_slim_vin = re.search(
rf'<div[^>]*class="slim-vin[^"]*"[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</div>',
html,
)
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: "
assert found_slim_vin, (
f"VIN '{vin_fragment}' trebuie sa fie in <div class=\"slim-vin\"> (block-level, "
f"mono, linia 1 a randului slim). 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."""
"""Cand VIN-ul lipseste (sau e EMPTY=''), slim-vin nu afiseaza '' izolat.
Fallback: slim-vin afiseaza vehicul_nr (nr. inmatriculare) cu clasa muted.
(PRD 5.15 US-004: slim-vin are garda vin != '')
"""
acct = _create_account_user("vin_gol@test.com")
sid = _ins(acct, vin="", nr="B999TST") # VIN gol -> EMPTY="—"
sid = _ins(acct, vin="", nr="B999TST") # VIN gol -> vin_scurt='—'
_login(client, "vin_gol@test.com")
resp = client.get("/_fragments/submissions")
@@ -144,8 +129,13 @@ def test_vin_lipsa_nu_genereaza_rand_gol(client):
# 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)"
# slim-vin NU trebuie sa contina '—' izolat (VIN lipsa -> fallback vehicul_nr)
slim_vin_match = re.search(r'<div[^>]*class="slim-vin[^"]*"[^>]*>([^<]*)</div>', html)
assert slim_vin_match, "slim-vin lipseste din randul cu VIN gol"
slim_vin_content = slim_vin_match.group(1).strip()
assert slim_vin_content != "", (
"slim-vin afiseaza '' izolat cand VIN lipseste — "
"trebuie sa afiseze vehicul_nr ca fallback"
)
# Fallback: nr inmatriculare vizibil
assert "B999TST" in html, "Nr inmatriculare (fallback) lipseste cand VIN e gol"