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:
@@ -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
226
tests/test_api_scope.py
Normal 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)."
|
||||
)
|
||||
@@ -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
152
tests/test_device_mix.py
Normal 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
233
tests/test_embeddings.py
Normal 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
527
tests/test_heldout_eval.py
Normal 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
286
tests/test_holdout.py
Normal 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")
|
||||
578
tests/test_mapare_integrare_l14.py
Normal file
578
tests/test_mapare_integrare_l14.py
Normal 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
491
tests/test_or_label.py
Normal 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
290
tests/test_shared_store.py
Normal 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
|
||||
@@ -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
248
tests/test_web_bulk_fix.py
Normal 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!"
|
||||
@@ -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):
|
||||
|
||||
472
tests/test_web_corectie_obs.py
Normal file
472
tests/test_web_corectie_obs.py
Normal 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}"
|
||||
)
|
||||
546
tests/test_web_corectie_prestatii.py
Normal file
546
tests/test_web_corectie_prestatii.py
Normal 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]}"
|
||||
)
|
||||
496
tests/test_web_form_editare_slim.py
Normal file
496
tests/test_web_form_editare_slim.py
Normal 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]}"
|
||||
)
|
||||
340
tests/test_web_mapare_din_chip.py
Normal file
340
tests/test_web_mapare_din_chip.py
Normal 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]}"
|
||||
)
|
||||
99
tests/test_web_modal_slim.py
Normal file
99
tests/test_web_modal_slim.py
Normal 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"
|
||||
)
|
||||
@@ -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
231
tests/test_web_scope.py
Normal 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)."
|
||||
)
|
||||
@@ -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 "✓" 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 "✗" 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 "✗" 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()
|
||||
|
||||
@@ -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'"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user