feat(editor): editor prestatii unificat in modal - un select, un rand de chips (PRD 5.23)

Inlocuieste cele 3 controale de asociere cod RAR cu un singur rand de chips
+ un singur select care adauga instant la change. Stare = un hidden chips_state
JSON versionat; post_form_chips redus la 2 actiuni (add/remove). Optgroup
Sugestii (fuzzy/k-NN) + optiune "Nu se declara la RAR" in select, cu tinta
implicita evidentiata si placeholder care o numeste. exclus persistat prin
payload_json (treapta noua de precedenta in resolve_prestatii, round-trip complet).

Siguranta: itemii exclusi sunt scosi din payload la momentul trimiterii
(worker split_prestatii_excluse inainte de build_rar_payload + filtru defensiv),
ca sa nu ajunga NICIODATA la RAR ca codPrestatie:null.

Suita: 1680 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-08 15:45:34 +00:00
parent 0bc618763e
commit 56ebe1f7cb
20 changed files with 2839 additions and 552 deletions

View File

@@ -0,0 +1,78 @@
"""Robustete selectie tinta chips: doar operatii nemapate IDENTIFICABILE (cu
cod_op_service) pot deveni tinta. Un item fara cod_op_service (chip liber, fara
nicio operatie asociata) nu are cum sa fie afisat ca tinta in template (chip-ul
warning se randeaza doar cand cod_op_service e truthy) — daca ar fi ales tinta,
ar deveni invizibil: userul nu vede niciun highlight, dar selectul tot arata
sugestii/NEDECLARAT pentru un "cui" pe care nu-l poate vedea.
"""
from __future__ import annotations
import os
import tempfile
import pytest
@pytest.fixture()
def conn(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
def _seed_cod(conn, cod: str, nume: str) -> None:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, nume),
)
conn.commit()
def test_item_fara_cod_op_service_nu_devine_tinta(conn):
"""Chip liber (fara cod_op_service) nemapat nu poate fi tinta — e invizibil in template."""
from app.web.routes import _chips_target_si_sugestii
_seed_cod(conn, "OE-1", "Schimb ulei")
from app.mapping import load_nomenclator
nomenclator = load_nomenclator(conn)
chips = [{"cod_prestatie": "", "cod_op_service": "", "denumire": "", "exclus": 0}]
target_index, sugestii = _chips_target_si_sugestii(conn, chips, nomenclator)
assert target_index is None
assert sugestii == []
def test_operatie_identificabila_ramane_tinta(conn):
"""Regresie: o operatie nemapata cu cod_op_service ramane tinta normal."""
from app.web.routes import _chips_target_si_sugestii
_seed_cod(conn, "OE-1", "Schimb ulei motor")
from app.mapping import load_nomenclator
nomenclator = load_nomenclator(conn)
chips = [{"cod_prestatie": "", "cod_op_service": "Op-A", "denumire": "Schimb ulei motor", "exclus": 0}]
target_index, sugestii = _chips_target_si_sugestii(conn, chips, nomenclator)
assert target_index == 0
assert any(s.get("cod_prestatie") == "OE-1" for s in sugestii)
def test_sugestii_pentru_tinta_fara_denumire_intoarce_gol():
"""Denumire (si cod_op_service) goala pe tinta -> lista goala, fara sugestii irelevante."""
from app.web.routes import _sugestii_select_pentru_tinta
class _FakeConn:
pass
nomenclator = [{"cod_prestatie": "OE-1", "nume_prestatie": "Schimb ulei"}]
out = _sugestii_select_pentru_tinta(_FakeConn(), "", nomenclator)
assert out == []
out_none = _sugestii_select_pentru_tinta(_FakeConn(), None, nomenclator)
assert out_none == []

View File

@@ -8,8 +8,10 @@ Un cod ales explicit pe rand bate regula de excludere.
from __future__ import annotations
import io
import json
import os
import re
import tempfile
import pytest
@@ -191,8 +193,72 @@ def test_rand_mixt_exclude_doar_operatia_exclusa(client):
finally:
conn.close()
payload = json.loads(sub["payload_json"])
coduri = [p.get("cod_prestatie") for p in payload["prestatii"]]
assert coduri == ["OE-2"], f"Operatia exclusa nu trebuie sa plece la RAR: {coduri}"
# Round-trip: itemul exclus RAMANE in payload_json (persistat), adnotat exclus,
# dar fara cod_prestatie — nu pleaca niciodata la RAR (vezi build_rar_payload).
by_op = {p.get("cod_op_service"): p for p in payload["prestatii"] if p.get("cod_op_service")}
assert by_op["OP-ITP"]["exclus"] is True
assert not by_op["OP-ITP"].get("cod_prestatie")
coduri_declarabile = [p.get("cod_prestatie") for p in payload["prestatii"] if not p.get("exclus")]
assert coduri_declarabile == ["OE-2"], f"Operatia exclusa nu trebuie sa plece la RAR: {coduri_declarabile}"
def test_reupload_rand_cu_exclus_deja_comis_e_already_sent(client):
"""Cheia de idempotenta la commit (doar declarabile) e IDENTICA cu preview.
Re-incarcarea aceluiasi rand (dupa ce a fost deja comis cu un item exclus
adnotat in payload) trebuie recunoscuta drept duplicat — nu un rand nou.
"""
_seed()
rows = [{
"VIN": "WVWZZZ1KZAW000789",
"Nr": "B003TST",
"Data": "2026-06-12",
"KM": "90000",
"Operatie": "OP-ITP",
}]
iid = _upload_and_preview(client, rows=rows)
csrf = _get_csrf(client)
client.post(f"/_import/{iid}/mapare-operatii", data={
"cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf,
})
client.post(f"/_import/{iid}/rand/0/editeaza", data={
"cod_prestatie": ["", "OE-2"],
"chip_op_service": ["OP-ITP", ""],
"chip_denumire": ["", ""],
})
csrf = _get_csrf(client)
rc = client.post(f"/_import/{iid}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
assert rc.status_code == 200, rc.text
# Re-incarca acelasi rand + acelasi cod ales pe editorul de rand. Maparea de
# coloane e deja memorata din primul upload -> raspunsul sare direct la preview
# (fara link mapare-coloane); extragem import_id din hx-get pe #import-section.
csv_data = _csv_bytes(rows)
csrf = _get_csrf(client)
r2 = client.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
data={"csrf_token": csrf},
)
assert r2.status_code == 200, r2.text
m2 = re.search(r"/_import/(\d+)/preview", r2.text)
assert m2, f"import_id negasit in raspunsul de reupload: {r2.text[:300]}"
iid2 = int(m2.group(1))
csrf = _get_csrf(client)
client.post(f"/_import/{iid2}/mapare-operatii", data={
"cod_op_service": "OP-ITP", "cod_prestatie": "__NEDECLARAT__", "csrf_token": csrf,
})
client.post(f"/_import/{iid2}/rand/0/editeaza", data={
"cod_prestatie": ["", "OE-2"],
"chip_op_service": ["OP-ITP", ""],
"chip_denumire": ["", ""],
})
# already_sent se calculeaza doar la refresh-ul complet de preview (lookup pe
# tot batch-ul), nu la editarea unui singur rand — reincarcam preview-ul.
rp = client.get(f"/_import/{iid2}/preview")
assert rp.status_code == 200
assert "Deja trimis" in rp.text, \
"Randul re-incarcat trebuie recunoscut deja trimis (cheia de idempotenta neschimbata)"
def test_cod_explicit_pe_operatie_bate_regula_de_excludere(client):

View File

@@ -100,3 +100,35 @@ def test_normalizare_vin_nrinm_in_payload():
def test_sistem_reparat_default_null():
assert build_rar_payload(_internal())["sistemReparat"] == "null"
def test_item_exclus_nu_apare_in_payload():
"""Un item adnotat exclus=True nu produce niciodata {codPrestatie: null} la RAR."""
internal = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [
{"cod_prestatie": "OE-1"},
{"cod_prestatie": None, "cod_op_service": "200", "denumire": "Spalare", "exclus": True},
],
}
p = build_rar_payload(internal)
assert p["prestatii"] == [{"codPrestatie": "OE-1", "idPrezentare": None}]
def test_item_fara_cod_prestatie_nu_apare_in_payload():
"""Defense-in-depth: orice item fara cod (chiar neadnotat exclus) e sarit, niciodata null."""
internal = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [
{"cod_prestatie": "OE-1"},
{"cod_prestatie": None, "cod_op_service": "200"},
],
}
p = build_rar_payload(internal)
assert p["prestatii"] == [{"codPrestatie": "OE-1", "idPrezentare": None}]

745
tests/test_web_chips.py Normal file
View File

@@ -0,0 +1,745 @@
"""US-001: stare chips extinsa (JSON versionat, D4) + actiuni add/remove unificate.
Contract nou /form-chips:
- Stare curenta: UN SINGUR hidden `chips_state` = JSON {"v":1,"items":[{"cod","op","den","exclus"}]}.
- chips_action=add + chips_pick (codul ales) + chips_target_index optional:
cu tinta -> leaga codul de operatia de la acel index; fara tinta -> chip liber.
chips_pick=__NEDECLARAT__ + tinta valida -> item exclus=1, fara cod_prestatie.
- chips_action=remove + chips_remove_index: chip legat de operatie -> revine warning
(cod_prestatie="", exclus=0); chip liber -> iese din lista.
- Actiunile vechi (add_flat/add_extra/remove_flat/chips_add_cod_{i}/chips_add_cod_flat
ca actiuni) sunt ELIMINATE.
"""
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, "chips.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 _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 _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 _state(items: list[dict]) -> str:
"""Construieste JSON-ul hidden chips_state din itemi scurti {cod,op,den,exclus}."""
return json.dumps({"v": 1, "items": items}, ensure_ascii=False)
def _login_seed(client, email: str) -> None:
_create_account_user(email)
_login(client, email)
# --------------------------------------------------------------------------- #
# add cu tinta -> leaga codul de operatia respectiva #
# --------------------------------------------------------------------------- #
def test_add_cu_tinta_leaga_de_operatie(client):
_login_seed(client, "add.tinta@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
state = _state([
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "add",
"chips_target_index": "0",
"chips_pick": "OE-1",
})
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "OE-1" in html
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert m, f"hidden chips_state lipsa din raspuns: {html[:400]}"
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["v"] == 1
items = payload["items"]
assert len(items) == 1
assert items[0]["cod"] == "OE-1"
assert items[0]["op"] == "Op-A"
assert items[0]["exclus"] == 0
# --------------------------------------------------------------------------- #
# add fara tinta -> chip liber #
# --------------------------------------------------------------------------- #
def test_add_fara_tinta_adauga_cod_liber(client):
_login_seed(client, "add.liber@test.com")
_seed_cod("FRN1", "Sistem de franare")
csrf = _csrf(client)
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": _state([]),
"chips_action": "add",
"chips_pick": "FRN1",
})
assert resp.status_code == 200, resp.text[:400]
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
items = payload["items"]
assert len(items) == 1
assert items[0]["cod"] == "FRN1"
assert items[0]["op"] == ""
# --------------------------------------------------------------------------- #
# add __NEDECLARAT__ cu tinta -> marcheaza exclus #
# --------------------------------------------------------------------------- #
def test_add_nedeclarat_marcheaza_exclus(client):
_login_seed(client, "add.ned@test.com")
csrf = _csrf(client)
state = _state([
{"cod": "", "op": "OP-ITP", "den": "Inspectie tehnica", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "add",
"chips_target_index": "0",
"chips_pick": "__NEDECLARAT__",
})
assert resp.status_code == 200, resp.text[:400]
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
items = payload["items"]
assert len(items) == 1
assert items[0]["exclus"] == 1
assert items[0]["cod"] == "", f"item exclus nu trebuie sa aiba cod_prestatie: {items[0]}"
assert items[0]["op"] == "OP-ITP"
assert "nu se va declara" in resp.text.lower()
def test_add_nedeclarat_fara_tinta_da_eroare(client):
"""Sentinelul __NEDECLARAT__ fara tinta valida -> chips_error explicit, fara modificare."""
_login_seed(client, "add.ned.notinta@test.com")
csrf = _csrf(client)
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": _state([]),
"chips_action": "add",
"chips_pick": "__NEDECLARAT__",
# fara chips_target_index
})
assert resp.status_code == 200, resp.text[:400]
assert "chips-extra-error" in resp.text, (
f"Eroare vizibila lipsa pentru NEDECLARAT fara tinta: {resp.text[:400]}"
)
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["items"] == [], "NEDECLARAT fara tinta nu trebuie sa modifice starea"
def test_add_nedeclarat_tinta_deja_mapata_da_eroare(client):
"""Tinta trebuie sa fie o operatie NEMAPATA — index spre operatie deja rezolvata -> eroare."""
_login_seed(client, "add.ned.mapata@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
state = _state([
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "add",
"chips_target_index": "0",
"chips_pick": "__NEDECLARAT__",
})
assert resp.status_code == 200
assert "chips-extra-error" in resp.text
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["items"][0]["cod"] == "OE-1", "Tinta invalida nu trebuie sa strice starea existenta"
# --------------------------------------------------------------------------- #
# remove -> readuce operatia nemapata (warning) #
# --------------------------------------------------------------------------- #
def test_remove_cod_readuce_operatia_nemapata(client):
_login_seed(client, "remove.readuce@test.com")
csrf = _csrf(client)
state = _state([
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "remove",
"chips_remove_index": "0",
})
assert resp.status_code == 200, resp.text[:400]
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
items = payload["items"]
assert len(items) == 1, "Operatia trebuie sa RAMANA (doar codul dispare)"
assert items[0]["cod"] == ""
assert items[0]["op"] == "Op-A"
assert items[0]["exclus"] == 0
def test_remove_pe_nedeclarat_readuce_warning(client):
"""x pe un chip Nedeclarat readuce operatia la warning (nu se sterge operatia)."""
_login_seed(client, "remove.nedeclarat@test.com")
csrf = _csrf(client)
state = _state([
{"cod": "", "op": "OP-ITP", "den": "Inspectie", "exclus": 1},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "remove",
"chips_remove_index": "0",
})
assert resp.status_code == 200
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
payload = json.loads(m.group(1).replace("&#34;", '"'))
items = payload["items"]
assert len(items) == 1
assert items[0]["exclus"] == 0
assert items[0]["cod"] == ""
assert items[0]["op"] == "OP-ITP"
def test_remove_cod_liber_il_sterge_complet(client):
"""Chip liber (fara operatie sursa) e eliminat integral din lista la remove."""
_login_seed(client, "remove.liber@test.com")
csrf = _csrf(client)
state = _state([
{"cod": "FRN1", "op": "", "den": "", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "remove",
"chips_remove_index": "0",
})
assert resp.status_code == 200
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["items"] == []
# --------------------------------------------------------------------------- #
# dedup (operatie, cod) #
# --------------------------------------------------------------------------- #
def test_dedup_operatie_cod(client):
"""Acelasi cod liber nu se adauga de doua ori (pereche op='',cod deja existenta)."""
_login_seed(client, "dedup.op.cod@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
state = _state([
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": "add",
"chips_pick": "OE-1",
})
assert resp.status_code == 200
assert "chips-extra-error" in resp.text or "deja in lista" in resp.text
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert len(payload["items"]) == 1, f"Dedup a esuat, itemi: {payload['items']}"
# --------------------------------------------------------------------------- #
# actiuni vechi eliminate #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("actiune", ["add_flat", "add_extra", "remove_flat"])
def test_actiuni_vechi_eliminate_sunt_no_op(client, actiune):
"""Actiunile eliminate nu mai au ramura dedicata — cad prin fara sa schimbe starea."""
_login_seed(client, f"vechi.{actiune}@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
state = _state([{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}])
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": state,
"chips_action": actiune,
"chips_add_cod_flat": "FRN1",
"chips_remove_cod": "OE-1",
})
assert resp.status_code == 200
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["items"] == [{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0}]
# --------------------------------------------------------------------------- #
# chips_state prezent mereu, inclusiv gol #
# --------------------------------------------------------------------------- #
def test_chips_state_gol_ramane_prezent_in_raspuns(client):
_login_seed(client, "state.gol@test.com")
csrf = _csrf(client)
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": _state([]),
"chips_action": "",
})
assert resp.status_code == 200
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m, "hidden chips_state trebuie sa fie mereu prezent, chiar cu lista goala"
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload == {"v": 1, "items": []}
# --------------------------------------------------------------------------- #
# JSON invalid -> eroare vizibila, nu stare goala silentioasa #
# --------------------------------------------------------------------------- #
def test_chips_state_json_invalid_da_eroare_vizibila(client):
_login_seed(client, "state.corupt@test.com")
csrf = _csrf(client)
resp = client.post("/form-chips", data={
"csrf_token": csrf,
"chips_state": "{not valid json",
"chips_action": "",
})
assert resp.status_code == 200
assert "chips-extra-error" in resp.text or "invalid" in resp.text.lower(), (
f"JSON corupt trebuie sa dea eroare vizibila: {resp.text[:400]}"
)
# --------------------------------------------------------------------------- #
# compat tranzitorie: chips_state lipsa -> fallback pe listele vechi #
# --------------------------------------------------------------------------- #
def test_compat_fallback_pe_liste_vechi_cand_chips_state_lipseste(client):
_login_seed(client, "compat.fallback@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
resp = client.post("/form-chips", data={
"csrf_token": csrf,
# FARA chips_state -> fallback pe listele vechi
"cod_prestatie": [""],
"chip_op_service": ["Op-A"],
"chip_denumire": ["Schimb ulei motor"],
"chips_action": "add",
"chips_target_index": "0",
"chips_pick": "OE-1",
})
assert resp.status_code == 200, resp.text[:400]
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["items"][0]["cod"] == "OE-1"
assert payload["items"][0]["exclus"] == 0
# =========================================================================== #
# US-002: template unificat -- un container .chips, un select, fara op-row #
# =========================================================================== #
def _post_chips(client, csrf, items, **extra):
data = {
"csrf_token": csrf,
"chips_state": _state(items),
"chips_action": "",
}
data.update(extra)
return client.post("/form-chips", data=data)
def test_render_un_singur_container_chips(client):
"""UN singur container `.chips` -- indiferent de mixul de tipuri de chip."""
_login_seed(client, "un.container@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
items = [
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
{"cod": "", "op": "Op-B", "den": "Vulcanizare", "exclus": 0},
{"cod": "FRN1", "op": "", "den": "", "exclus": 0},
{"cod": "", "op": "Op-C", "den": "ITP anual", "exclus": 1},
]
_seed_cod("FRN1", "Sistem de franare")
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert html.count('class="chips"') == 1, (
f"Trebuie sa existe UN SINGUR container .chips, gasit {html.count('class=\"chips\"')}: {html[:800]}"
)
def test_render_fara_op_row_si_fara_butoane_adauga(client):
"""Fara .op-row, fara butoane Adauga/+, fara actiunile vechi in markup."""
_login_seed(client, "fara.oprow@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
items = [
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
{"cod": "", "op": "Op-B", "den": "Vulcanizare", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "op-row" not in html
assert "Adauga" not in html
assert "add_flat" not in html
assert "chips_add_cod_" not in html
def test_chip_warning_pentru_operatie_nemapata(client):
"""Operatie nemapata -> chip warning `warn OP-X . fara cod` cu title complet."""
_login_seed(client, "chip.warning@test.com")
csrf = _csrf(client)
items = [
{"cod": "", "op": "OP-ITP", "den": "Inspectie tehnica periodica", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "OP-ITP" in html
assert "fara cod" in html
assert 'title="OP-ITP' in html
def test_select_are_hx_disabled_elt_this(client):
"""Selectul unic are hx-disabled-elt='this' EXPLICIT (capcana 5.22 -- nu mostenit de la form)."""
_login_seed(client, "select.disabled@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
resp = _post_chips(client, csrf, [])
assert resp.status_code == 200, resp.text[:400]
html = resp.text
m = re.search(r'<select[^>]*name="chips_pick"[^>]*>', html)
assert m, f"selectul chips_pick lipseste: {html[:600]}"
select_tag = m.group(0)
assert 'id="chips-picker"' in select_tag
assert 'hx-trigger="change"' in select_tag
assert 'hx-post="/form-chips"' in select_tag
assert 'hx-target="#chips-section"' in select_tag
assert 'hx-swap="outerHTML"' in select_tag
assert 'hx-include="closest form"' in select_tag
assert 'hx-disabled-elt="this"' in select_tag, (
f"hx-disabled-elt='this' lipseste de pe select (capcana 5.22): {select_tag}"
)
def test_mod_plat_fara_operatii_select_doar_coduri_libere(client):
"""Mod plat (zero operatii din import): doar chips libere + select placeholder generic."""
_login_seed(client, "mod.plat@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
items = [
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "" not in html, "Mod plat nu trebuie sa aiba niciun chip warning"
m = re.search(r'<select[^>]*name="chips_pick"[^>]*>\s*<option value="">([^<]*)</option>', html)
assert m, f"select cu optiune placeholder lipseste: {html[:600]}"
assert "adauga cod RAR" in m.group(1)
def test_has_r_odo_pastrat_dupa_actiuni(client):
"""has_r_odo / data-has-r-odo + chip-warn pe R-ODO/I-ODO raman neschimbate."""
_login_seed(client, "r.odo@test.com")
_seed_cod("R-ODO", "Odometru initial")
csrf = _csrf(client)
items = [
{"cod": "R-ODO", "op": "", "den": "", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert 'data-has-r-odo="true"' in html
assert "chip-warn" in html
assert "R-ODO" in html
def test_chip_nedeclarat_are_x_functional(client):
"""Item exclus=1 -> chip Nedeclarat (line-through) cu buton de sters functional (remove -> warning)."""
_login_seed(client, "nedeclarat.x@test.com")
csrf = _csrf(client)
items = [
{"cod": "", "op": "OP-ITP", "den": "Inspectie", "exclus": 1},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "Nedeclarat" in html
assert 'hx-vals=\'{"chips_action":"remove","chips_remove_index":0}\'' in html
def test_chips_submission_id_ecouat(client):
"""chips_submission_id trimis in form e ecouat ca hidden in raspunsul /form-chips."""
_login_seed(client, "sub.id.ecou@test.com")
csrf = _csrf(client)
resp = _post_chips(client, csrf, [], chips_submission_id="4242")
assert resp.status_code == 200, resp.text[:400]
assert re.search(r'name="chips_submission_id"\s+value="4242"', resp.text), (
f"chips_submission_id nu a fost ecouat: {resp.text[:600]}"
)
# =========================================================================== #
# US-003: sugestii fuzzy/k-NN + optiunea "Nu se declara la RAR" in select #
# =========================================================================== #
def _extrage_select(html: str) -> str:
"""Extrage markup-ul complet al <select name="chips_pick">...</select>."""
m = re.search(r'<select[^>]*name="chips_pick".*?</select>', html, re.DOTALL)
assert m, f"select chips_pick lipseste din raspuns: {html[:600]}"
return m.group(0)
def test_select_are_optgroup_sugestii_pt_tinta(client):
"""Cu tinta activa (operatie nemapata): optgroup 'Sugestii' + __NEDECLARAT__ + nomenclator."""
_login_seed(client, "sugestii.tinta@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
_seed_cod("FRN1", "Sistem de franare")
csrf = _csrf(client)
items = [
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
select_html = _extrage_select(resp.text)
assert '<optgroup label="Sugestii">' in select_html, (
f"optgroup Sugestii lipseste cu tinta activa: {select_html[:600]}"
)
assert "OE-1" in select_html.split('<optgroup label="Sugestii">')[1].split("</optgroup>")[0], (
"sugestia fuzzy pentru 'Schimb ulei motor' trebuie sa contina OE-1"
)
assert "__NEDECLARAT__" in select_html
assert "Nu se declara la RAR" in select_html
# Nomenclatorul complet ramane dupa Sugestii/NEDECLARAT.
assert "FRN1" in select_html
def test_select_are_nedeclarat_doar_cu_tinta(client):
"""Optiunea __NEDECLARAT__ apare DOAR cand exista o tinta (operatie nemapata) activa."""
_login_seed(client, "nedeclarat.doar.tinta@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
# Cu tinta (operatie nemapata) -> NEDECLARAT prezent.
resp_cu_tinta = _post_chips(client, csrf, [
{"cod": "", "op": "Op-A", "den": "Schimb ulei", "exclus": 0},
])
assert "__NEDECLARAT__" in _extrage_select(resp_cu_tinta.text)
# Fara tinta (doar cod liber, nicio operatie nemapata) -> NEDECLARAT absent.
resp_fara_tinta = _post_chips(client, csrf, [
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
])
assert "__NEDECLARAT__" not in _extrage_select(resp_fara_tinta.text)
def test_select_fara_tinta_fara_sugestii(client):
"""Fara tinta: selectul listeaza DOAR nomenclatorul (fara optgroup Sugestii, fara NEDECLARAT)."""
_login_seed(client, "fara.tinta.fara.sugestii@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client)
items = [
{"cod": "OE-1", "op": "", "den": "", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
select_html = _extrage_select(resp.text)
assert "<optgroup" not in select_html
assert "__NEDECLARAT__" not in select_html
assert "OE-1" in select_html
def test_sugestii_indisponibile_degradare_fara_optgroup(client, monkeypatch):
"""Motor de sugestii care arunca exceptie -> select DOAR cu nomenclatorul (fara optgroup),
fara eroare 500 si fara blocare (degradare gratioasa, GAP inchis din PRD 5.23)."""
_login_seed(client, "sugestii.indisponibile@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client)
from app.web import routes as _routes
def _boom(*args, **kwargs):
raise RuntimeError("motor de sugestii indisponibil (simulat)")
monkeypatch.setattr(_routes, "suggest_codes", _boom)
monkeypatch.setattr(_routes, "enrich_suggestions", _boom)
items = [
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
]
resp = _post_chips(client, csrf, items)
assert resp.status_code == 200, resp.text[:400]
select_html = _extrage_select(resp.text)
assert "<optgroup" not in select_html, (
f"optgroup nu trebuie sa apara cand motorul de sugestii arunca exceptie: {select_html[:600]}"
)
assert "OE-1" in select_html, "nomenclatorul complet trebuie sa ramana disponibil"
# =========================================================================== #
# FIX: tinta implicita + sugestii la RANDAREA INITIALA a modalului (fara #
# niciun round-trip /form-chips) — invariant PRD 5.23 §3.1. #
# =========================================================================== #
def _insert_submission(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 test_randare_initiala_are_tinta_si_optgroup(client):
"""Deschiderea INITIALA a modalului de detaliu (needs_mapping, operatie nemapata),
FARA niciun /form-chips, trebuie sa aiba deja is-target pe chip-ul warning,
placeholder-ul D2 "Cod pentru OP-X — alege" si optgroup-ul Sugestii + NEDECLARAT."""
acct = _login_seed_ret(client, "randare.initiala@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
_seed_cod("FRN1", "Sistem de franare")
op = "Schimb ulei motor"
sid = _insert_submission(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0SC001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": op, "denumire": op}],
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200, resp.text[:400]
html = resp.text
assert "is-target" in html, (
f"chip-ul warning al primei operatii nemapate trebuie sa fie tinta la deschiderea initiala: {html[:2000]}"
)
assert f"Cod pentru {op} — alege" in html, (
f"placeholder-ul D2 trebuie sa numeasca operatia tinta la deschiderea initiala: {html[:2000]}"
)
select_html = _extrage_select(html)
assert '<optgroup label="Sugestii">' in select_html, (
f"optgroup Sugestii trebuie sa apara la deschiderea initiala (fara /form-chips): {select_html[:600]}"
)
assert "OE-1" in select_html.split('<optgroup label="Sugestii">')[1].split("</optgroup>")[0], (
"sugestia fuzzy pentru 'Schimb ulei motor' trebuie sa contina OE-1 chiar la randarea initiala"
)
assert "__NEDECLARAT__" in select_html
def _login_seed_ret(client, email: str) -> int:
"""Ca `_login_seed`, dar intoarce account_id (necesar pentru inserarea directa a submission-ului)."""
acct = _create_account_user(email)
_login(client, email)
return acct

View File

@@ -535,8 +535,8 @@ def test_picker_flat_arata_cod_si_denumire(client):
def test_adauga_cod_extra_in_mod_operatii(client):
"""In mod operatii, actiunea add_extra adauga un cod RAR liber."""
acct = _create_account_user("add.extra.ops@test.com")
"""In mod operatii, actiunea unificata 'add' fara tinta adauga un cod RAR liber."""
_create_account_user("add.extra.ops@test.com")
_login(client, "add.extra.ops@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
_seed_cod("FRN1", "Sistem de franare")
@@ -547,11 +547,11 @@ def test_adauga_cod_extra_in_mod_operatii(client):
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"], # chip existent (op mapata)
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei motor"],
"chips_action": "add_extra",
"chips_add_cod_flat": "FRN1", # codul extra de adaugat
"chips_state": json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei motor", "exclus": 0},
]}),
"chips_action": "add",
"chips_pick": "FRN1", # codul extra de adaugat, fara tinta -> chip liber
},
)
assert resp.status_code == 200, resp.text[:300]
@@ -601,32 +601,36 @@ def test_extra_cod_persistat_la_salvare(client):
def test_extra_cod_validat_nomenclator(client):
"""add_extra respinge cod necunoscut in nomenclator (invariant ORA-12899)."""
acct = _create_account_user("extra.valid@test.com")
"""'add' fara tinta respinge cod necunoscut in nomenclator (invariant ORA-12899)."""
_create_account_user("extra.valid@test.com")
_login(client, "extra.valid@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
# add_extra cu cod INVALID (XX-99 nu e in nomenclator)
# add fara tinta cu cod INVALID (XX-99 nu e in nomenclator)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"],
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei"],
"chips_action": "add_extra",
"chips_add_cod_flat": "XX-99", # cod necunoscut
"chips_state": json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
]}),
"chips_action": "add",
"chips_pick": "XX-99", # cod necunoscut
},
)
assert resp.status_code == 200
html = resp.text
# XX-99 NU trebuie sa apara ca chip valid (hidden input cu valoarea XX-99)
# XX-99 NU trebuie sa apara in starea persistata (chips_state)
import re as _re
hidden_xx99 = _re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="XX-99"', html)
assert hidden_xx99 is None, (
f"Codul invalid XX-99 a fost adaugat ca chip! HTML: {html[:500]}"
m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
coduri = [it["cod"] for it in payload["items"]]
assert "XX-99" not in coduri, (
f"Codul invalid XX-99 a fost adaugat ca chip! items={payload['items']}"
)
assert "chips-extra-error" in html
def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client):
@@ -703,12 +707,12 @@ def test_salvare_fara_chip_explicit_nu_e_no_op(client):
def test_picker_by_index_op2_nu_op1(client):
"""Codul ales pe picker-ul op#2 aterizeaza pe op#2, NU pe op#1.
"""Codul ales pentru op#2 aterizeaza pe op#2, NU pe op#1.
Verifica alinierea by-index in modul operatii: chips_add_op_index=1 + chips_add_cod_1
actualizeaza chips[1] (op#2), nu chips[0] (op#1).
Verifica alinierea by-index in modul operatii: chips_target_index=1 +
chips_pick actualizeaza chips[1] (op#2), nu chips[0] (op#1).
"""
acct = _create_account_user("byindex.op2@test.com")
_create_account_user("byindex.op2@test.com")
_login(client, "byindex.op2@test.com")
_seed_cod("OE-1", "Schimb ulei")
_seed_cod("FRN1", "Sistem de franare")
@@ -719,27 +723,25 @@ def test_picker_by_index_op2_nu_op1(client):
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", ""], # idx 0=OE-1 (mapata), idx 1="" (nemapata)
"chip_op_service": ["Op-A", "Op-B"],
"chip_denumire": ["Prima", "A doua"],
"chips_state": json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "Op-A", "den": "Prima", "exclus": 0},
{"cod": "", "op": "Op-B", "den": "A doua", "exclus": 0},
]}),
"chips_action": "add",
"chips_add_op_index": "1", # adauga pe op#2 (idx=1)
"chips_add_cod_1": "FRN1", # picker-ul op#2 contine FRN1
"chips_target_index": "1", # adauga pe op#2 (idx=1)
"chips_pick": "FRN1",
},
)
assert resp.status_code == 200, resp.text[:300]
html = resp.text
import re as _re
hidden_vals = _re.findall(r'<input[^>]+name="cod_prestatie"[^>]+value="([^"]*)"', html)
assert "OE-1" in hidden_vals, f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {hidden_vals}"
assert "FRN1" in hidden_vals, f"FRN1 nu a aterizat pe op#2: {hidden_vals}"
# By-index: OE-1 trebuie sa fie INAINTE de FRN1 (idx 0 < idx 1)
oe1_pos = hidden_vals.index("OE-1") if "OE-1" in hidden_vals else -1
frn1_pos = hidden_vals.index("FRN1") if "FRN1" in hidden_vals else -1
assert oe1_pos < frn1_pos, (
f"FRN1 (op#2, idx=1) trebuie dupa OE-1 (op#1, idx=0) by-index: {hidden_vals}"
)
m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
items = payload["items"]
assert items[0]["cod"] == "OE-1", f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {items}"
assert items[1]["cod"] == "FRN1", f"FRN1 nu a aterizat pe op#2 (idx=1): {items}"
def test_empty_state_picker_nomenclator_gol(client):
@@ -774,25 +776,25 @@ def test_empty_state_picker_nomenclator_gol(client):
def test_add_extra_semnal_vizibil_cod_invalid(client):
"""add_extra cu cod invalid da semnal vizibil (nu esua silentios).
"""'add' fara tinta cu cod invalid da semnal vizibil (nu esua silentios).
div.chips-extra-error vizibil cand codul e invalid sau selectul e gol.
"""
acct = _create_account_user("extra.err.signal@test.com")
_create_account_user("extra.err.signal@test.com")
_login(client, "extra.err.signal@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
# add_extra cu cod necunoscut in nomenclator
# add fara tinta cu cod necunoscut in nomenclator
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"],
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei"],
"chips_action": "add_extra",
"chips_add_cod_flat": "XX-99", # cod inexistent
"chips_state": json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
]}),
"chips_action": "add",
"chips_pick": "XX-99", # cod inexistent
},
)
assert resp.status_code == 200
@@ -802,7 +804,7 @@ def test_add_extra_semnal_vizibil_cod_invalid(client):
def test_add_per_op_cod_invalid_afiseaza_mesaj(client):
"""Actiunea 'add' (picker per operatie) cu cod invalid da semnal vizibil."""
"""Actiunea 'add' (picker per operatie, cu tinta) cu cod invalid da semnal vizibil."""
_create_account_user("add.op.err@test.com")
_login(client, "add.op.err@test.com")
_seed_cod("OE-1", "Schimb ulei")
@@ -812,12 +814,12 @@ def test_add_per_op_cod_invalid_afiseaza_mesaj(client):
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": [""],
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei"],
"chips_state": json.dumps({"v": 1, "items": [
{"cod": "", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
]}),
"chips_action": "add",
"chips_add_op_index": "0",
"chips_add_cod_0": "XX-99", # cod inexistent
"chips_target_index": "0",
"chips_pick": "XX-99", # cod inexistent
},
)
assert resp.status_code == 200
@@ -837,21 +839,19 @@ def test_add_flat_cod_invalid_afiseaza_mesaj(client):
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"],
"chip_op_service": [""],
"chip_denumire": [""],
"chips_action": "add_flat",
"chips_add_cod_flat": "XX-99",
"chips_state": json.dumps({"v": 1, "items": [{"cod": "OE-1", "op": "", "den": "", "exclus": 0}]}),
"chips_action": "add",
"chips_pick": "XX-99",
},
)
assert resp.status_code == 200
assert "chips-extra-error" in resp.text, (
f"Semnal de eroare lipsa la add_flat cu cod invalid: {resp.text[:300]}"
f"Semnal de eroare lipsa la add cu cod invalid: {resp.text[:300]}"
)
def test_add_flat_duplicat_afiseaza_mesaj(client):
"""add_flat cu un cod deja prezent NU dubleaza chip-ul si afiseaza mesaj."""
"""'add' fara tinta cu un cod deja prezent NU dubleaza chip-ul si afiseaza mesaj."""
_create_account_user("add.flat.dup@test.com")
_login(client, "add.flat.dup@test.com")
_seed_cod("OE-1", "Schimb ulei")
@@ -861,24 +861,24 @@ def test_add_flat_duplicat_afiseaza_mesaj(client):
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"],
"chip_op_service": [""],
"chip_denumire": [""],
"chips_action": "add_flat",
"chips_add_cod_flat": "OE-1", # deja in lista
"chips_state": json.dumps({"v": 1, "items": [{"cod": "OE-1", "op": "", "den": "", "exclus": 0}]}),
"chips_action": "add",
"chips_pick": "OE-1", # deja in lista
},
)
assert resp.status_code == 200
import re as _re
hidden = _re.findall(r'<input[^>]+name="cod_prestatie"[^>]+value="OE-1"', resp.text)
assert len(hidden) == 1, f"Chip OE-1 duplicat: {len(hidden)} aparitii"
m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert len(payload["items"]) == 1, f"Chip OE-1 duplicat: {payload['items']}"
assert "deja in lista" in resp.text, (
f"Mesaj de duplicat lipsa la add_flat: {resp.text[:300]}"
f"Mesaj de duplicat lipsa la add fara tinta: {resp.text[:300]}"
)
def test_add_extra_duplicat_afiseaza_mesaj(client):
"""add_extra cu chip extra identic existent afiseaza mesaj (nu no-op)."""
"""'add' fara tinta cu chip liber identic existent afiseaza mesaj (nu no-op)."""
_create_account_user("add.extra.dup@test.com")
_login(client, "add.extra.dup@test.com")
_seed_cod("OE-1", "Schimb ulei")
@@ -889,16 +889,17 @@ def test_add_extra_duplicat_afiseaza_mesaj(client):
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", "FRN1"],
"chip_op_service": ["SchimbUlei", ""],
"chip_denumire": ["Schimb ulei", ""],
"chips_action": "add_extra",
"chips_add_cod_flat": "FRN1", # chip extra identic deja existent
"chips_state": json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
{"cod": "FRN1", "op": "", "den": "", "exclus": 0},
]}),
"chips_action": "add",
"chips_pick": "FRN1", # chip liber identic deja existent
},
)
assert resp.status_code == 200
assert "deja in lista" in resp.text, (
f"Mesaj de duplicat lipsa la add_extra: {resp.text[:300]}"
f"Mesaj de duplicat lipsa la add fara tinta: {resp.text[:300]}"
)
@@ -914,11 +915,11 @@ def test_add_reusit_marcheaza_chip_nou(client):
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"],
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei"],
"chips_action": "add_extra",
"chips_add_cod_flat": "FRN1",
"chips_state": json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
]}),
"chips_action": "add",
"chips_pick": "FRN1",
},
)
assert resp.status_code == 200

View File

@@ -125,7 +125,8 @@ def client(monkeypatch):
def test_editabil_arata_select_cod_rar(client):
"""needs_data cu nomenclator populat → formularul de detaliu afiseaza <select name='cod_prestatie'>."""
"""needs_data cu nomenclator populat → formularul de detaliu afiseaza <select name='chips_pick'>
(picker-ul unificat de cod RAR; starea chips traieste in hidden name='chips_state', PRD 5.23)."""
acct = _create_account_user("sel1@test.com")
_ins_nomenclator("OE-1", "OE-2")
sid = _ins(acct, status="needs_data")
@@ -133,8 +134,11 @@ def test_editabil_arata_select_cod_rar(client):
html = _detaliu(client, sid)
assert 'name="cod_prestatie"' in html, (
"Formularul de detaliu needs_data trebuie sa contina un select cu name='cod_prestatie'"
assert 'name="chips_pick"' in html, (
"Formularul de detaliu needs_data trebuie sa contina un select cu name='chips_pick'"
)
assert 'name="chips_state"' in html, (
"Starea chips trebuie persistata intr-un hidden name='chips_state'"
)
assert "<select" in html, (
"Elementul <select> trebuie sa apara in detaliu pentru starea needs_data"

View File

@@ -0,0 +1,421 @@
"""US-004: persistarea `exclus` prin salvare (import + Trimiteri), round-trip complet.
Acopera:
- resolve_prestatii: item-exclus EXPLICIT supravietuieste re-rezolvarii (treapta noua
de precedenta) FARA sa strice restul precedentei (IRON RULE, regresiune-cheie).
- Import (web_editeaza_rand / apply_row_override): `exclus` persistat in override_json,
chip Nedeclarat reapare la redeschiderea modalului, x il readuce la warning.
- Rand cu toate operatiile excluse -> stare preview 'excluded' ("Nedeclarat").
- Trimiteri (post_corectie_trimitere): acelasi round-trip pe chips_state; toate
excluse -> needs_data cu motiv explicit.
- Sentinelul __NEDECLARAT__ NU ajunge in payload daca apare direct ca cod_prestatie
in starea hidden (nu doar ales din select), pe AMBELE cai.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# --------------------------------------------------------------------------- #
# Test unitar (fara client): precedenta resolve_prestatii #
# --------------------------------------------------------------------------- #
def test_exclus_supravietuieste_resolve_chiar_cu_mapare_pe_cont():
"""IRON RULE: item cu `exclus=1` explicit pe item RAMANE exclus dupa resolve_prestatii,
chiar daca operatia are (a) o mapare exacta pe cont SAU (b) un cod_prestatie deja
valid pe rand. Regresiune-cheie: restul precedentei (cod valid > exclus de cont >
mapare exacta > reguli text > nemapat) trebuie sa ramana IDENTICA pentru itemii
FARA `exclus` explicit — altfel stricam maparile existente.
"""
from app.mapping import resolve_prestatii
valid_codes = {"OE-1", "IG-1", "FRN1"}
mapping = {"Schimb-Ulei": "OE-1"} # mapare exacta pe cont pentru Schimb-Ulei
excluded_ops_cont = {"Filtru-Aer"} # regula de excludere PE CONT (alt mecanism)
prestatii = [
# (a) exclus explicit + mapare exacta pe cont exista -> exclus CASTIGA
{"cod_op_service": "Schimb-Ulei", "denumire": "Schimb ulei motor", "exclus": 1},
# (b) exclus explicit + cod_prestatie deja valid pe rand -> exclus CASTIGA
{"cod_op_service": "Frana", "denumire": "Reparatie frana", "cod_prestatie": "FRN1", "exclus": 1},
# regresiune: FARA exclus explicit, mapare exacta trebuie sa functioneze ca inainte
{"cod_op_service": "Schimb-Ulei", "denumire": "Schimb ulei motor 2"},
# regresiune: FARA exclus explicit, regula de excludere PE CONT trebuie sa functioneze
{"cod_op_service": "Filtru-Aer", "denumire": "Filtru aer"},
# regresiune: cod deja valid pe rand, FARA exclus -> pastrat ca atare (cod_valid)
{"cod_op_service": "", "denumire": "", "cod_prestatie": "IG-1"},
# regresiune: nemapat ramane nemapat
{"cod_op_service": "Necunoscut-Op", "denumire": "Operatie necunoscuta"},
]
resolved, unmapped = resolve_prestatii(
prestatii, mapping, valid_codes=valid_codes, text_rules=None, excluded_ops=excluded_ops_cont,
)
item_a = resolved[0]
assert item_a.get("exclus") is True, f"exclus explicit trebuia sa supravietuiasca: {item_a}"
assert not item_a.get("cod_prestatie"), f"item exclus explicit nu trebuie sa aiba cod_prestatie: {item_a}"
item_b = resolved[1]
assert item_b.get("exclus") is True, f"exclus explicit trebuia sa domine cod_prestatie valid: {item_b}"
assert not item_b.get("cod_prestatie"), f"cod_prestatie valid trebuia sters de exclus explicit: {item_b}"
# Regresiune: mapare exacta pe cont NESCHIMBATA pentru itemul fara exclus
item_c = resolved[2]
assert item_c.get("cod_prestatie") == "OE-1", f"mapare exacta pe cont stricata: {item_c}"
assert not item_c.get("exclus"), f"item fara exclus explicit nu trebuie marcat exclus: {item_c}"
# Regresiune: regula de excludere PE CONT (mecanism vechi) NESCHIMBATA
item_d = resolved[3]
assert item_d.get("exclus") is True, f"regula de excludere pe cont stricata: {item_d}"
assert not item_d.get("cod_prestatie")
# Regresiune: cod deja valid pastrat ca atare
item_e = resolved[4]
assert item_e.get("cod_prestatie") == "IG-1", f"cod valid pastrat stricat: {item_e}"
# Regresiune: nemapat ramane nemapat
assert len(unmapped) == 1
assert unmapped[0]["cod_op_service"] == "Necunoscut-Op"
def test_precedenta_neschimbata_pentru_itemi_fara_exclus():
"""Regresiune suplimentara: fara `exclus` explicit pe niciun item, comportamentul
lui resolve_prestatii e IDENTIC cu cel dinaintea treptei noi (cod valid > exclus
de cont > mapare exacta > reguli text > nemapat).
"""
from app.mapping import resolve_prestatii
valid_codes = {"OE-1"}
mapping = {"Op-Mapata": "OE-1"}
text_rules = [{"pattern": "frana", "cod_prestatie": "OE-1", "auto_send": 1, "priority": 1}]
excluded_ops = {"Op-Exclusa"}
prestatii = [
{"cod_op_service": "Op-Mapata", "denumire": "Operatie mapata"},
{"cod_op_service": "Op-Exclusa", "denumire": "Operatie exclusa"},
{"cod_op_service": "Op-Text", "denumire": "Reparatie frana fata"},
{"cod_op_service": "Op-Nemapat", "denumire": "Chestie noua"},
]
resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
assert resolved[0]["cod_prestatie"] == "OE-1"
assert not resolved[0].get("exclus")
assert resolved[1]["exclus"] is True
assert not resolved[1].get("cod_prestatie")
assert resolved[2]["cod_prestatie"] == "OE-1" # via regula text
assert resolved[2].get("cod_sursa")
assert len(unmapped) == 1
assert unmapped[0]["cod_op_service"] == "Op-Nemapat"
# --------------------------------------------------------------------------- #
# Fixtures + helpere HTTP (import + Trimiteri, fara auth — cont 1 implicit) #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "editare_rand.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()
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 _csv_bytes(rows: list[dict]) -> bytes:
import csv as _csv
import io
buf = io.StringIO()
writer = _csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
_MAP_COLS = {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
}
def _upload_and_preview(client: TestClient, rows: list[dict]) -> int:
import io as _io
csv_data = _csv_bytes(rows)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", _io.BytesIO(csv_data), "text/csv")},
data={},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"import_id negasit in raspuns: {r.text[:300]}"
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
r2 = client.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames,
"canon": canons,
"format_data": "YYYY-MM-DD",
})
assert r2.status_code == 200, r2.text
return iid
def _chips_state_din_html(html: str) -> dict:
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert m, f"hidden chips_state lipsa din HTML: {html[:400]}"
return json.loads(m.group(1).replace("&#34;", '"'))
# --- Trimiteri: helpere submissions direct in DB (cont 1, fara auth) --------- #
def _insert_submission(*, 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()}", 1, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _submission_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 _submission_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 _payload_cu_ops(vin: str, ops: list[tuple[str, str]]) -> dict:
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],
}
# --------------------------------------------------------------------------- #
# Import: salvare + round-trip (web_editeaza_rand / apply_row_override) #
# --------------------------------------------------------------------------- #
_SAMPLE_ROW_1OP = [{
"VIN": "WVWZZZ1KZAW000999",
"Nr": "B009TST",
"Data": "2026-06-10",
"KM": "123456",
"Operatie": "Op-Nemapata",
}]
def test_salvare_pastreaza_exclus_in_override(client):
"""Salvarea unui chip Nedeclarat in modalul de editare (import) persista `exclus=1`
in override_json — la reincarcarea preview-ului randul ramane 'Nedeclarat'."""
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
chips_state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1},
]})
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
assert r.status_code == 200, r.text
trigger_settle = r.headers.get("HX-Trigger-After-Settle", "")
assert "inchideModal" in trigger_settle, f"Salvarea trebuia sa reuseasca: {r.text[:400]}"
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
assert r2.status_code == 200
payload = _chips_state_din_html(r2.text)
item = payload["items"][0]
assert item.get("exclus") == 1, f"exclus nu a fost persistat: {payload}"
assert not item.get("cod"), f"item exclus nu trebuie sa aiba cod: {payload}"
def test_redeschidere_modal_arata_chip_nedeclarat_si_x_readuce_warning(client):
"""Round-trip complet: dupa salvare, redeschiderea modalului arata chipul
'Nedeclarat' (nu warning); actiunea x (/form-chips remove) il readuce la warning."""
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
chips_state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1},
]})
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
assert r.status_code == 200
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
assert r2.status_code == 200
html = r2.text
assert "Nedeclarat" in html, f"Chipul Nedeclarat trebuia sa reapara la redeschidere: {html[:600]}"
assert "chip-nedeclarat" in html
# x pe chip Nedeclarat -> /form-chips remove index 0 -> revine la warning.
payload = _chips_state_din_html(html)
r3 = client.post("/form-chips", data={
"chips_state": json.dumps(payload),
"chips_action": "remove",
"chips_remove_index": "0",
})
assert r3.status_code == 200, r3.text
assert "Nedeclarat" not in r3.text, "Dupa x, chipul Nedeclarat nu mai trebuie sa apara"
payload3 = _chips_state_din_html(r3.text)
item3 = payload3["items"][0]
assert not item3.get("exclus"), f"x trebuia sa readuca operatia la warning (exclus=0): {payload3}"
assert not item3.get("cod"), f"operatia trebuia sa ramana fara cod dupa x: {payload3}"
def test_rand_toate_operatiile_excluse_devine_excluded(client):
"""Rand cu unica operatie exclusa -> stare preview 'excluded' ('Nedeclarat')."""
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
chips_state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1},
]})
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
assert r.status_code == 200, r.text
trigger = json.loads(r.headers.get("HX-Trigger", "{}"))
stare = trigger.get("randSalvat", {}).get("stare", "")
assert "Nedeclarat" in stare, f"randSalvat.stare trebuia sa arate Nedeclarat, got: {stare!r}"
# --------------------------------------------------------------------------- #
# Trimiteri: salvare + round-trip (post_corectie_trimitere) #
# --------------------------------------------------------------------------- #
def test_salvare_trimitere_cu_exclus(client):
"""/trimitere/{id}/corecteaza cu un item mapat + un item exclus -> queued,
payload_json pastreaza AMBELE (itemul exclus adnotat exclus=1, fara cod_prestatie)."""
_seed_cod("OE-1", "Schimb ulei")
sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0EX001",
[("SchimbUlei", "Schimb ulei motor"), ("Filtru-Aer", "Filtru aer")],
))
chips_state = json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei motor", "exclus": 0},
{"cod": "", "op": "Filtru-Aer", "den": "Filtru aer", "exclus": 1},
]})
r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state})
assert r.status_code == 200, r.text[:500]
row = _submission_row(sid)
assert row["status"] == "queued", f"status asteptat queued, got {row['status']}: {r.text[:500]}"
prestatii = _submission_payload(sid)["prestatii"]
assert len(prestatii) == 2, f"ambele itemi trebuiau pastrate in payload: {prestatii}"
by_op = {p.get("cod_op_service"): p for p in prestatii}
assert by_op["SchimbUlei"].get("cod_prestatie") == "OE-1"
assert by_op["Filtru-Aer"].get("exclus") is True, f"itemul exclus trebuia adnotat: {prestatii}"
assert not by_op["Filtru-Aer"].get("cod_prestatie"), f"itemul exclus nu trebuie sa aiba cod: {prestatii}"
def test_trimitere_toate_excluse_devine_needs_data(client):
"""Toate operatiile excluse -> needs_data cu motiv explicit (nu se trimite la RAR)."""
sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0EX002",
[("Filtru-Aer", "Filtru aer")],
))
chips_state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Filtru-Aer", "den": "Filtru aer", "exclus": 1},
]})
r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state})
assert r.status_code == 200, r.text[:500]
row = _submission_row(sid)
assert row["status"] == "needs_data", f"status asteptat needs_data, got {row['status']}"
assert "excluse" in (row["rar_error"] or "").lower()
prestatii = _submission_payload(sid)["prestatii"]
assert prestatii[0].get("exclus") is True, "itemul exclus trebuie pastrat in payload pt afisare motiv"
# --------------------------------------------------------------------------- #
# Sentinel __NEDECLARAT__ respins explicit daca apare ca cod_prestatie #
# --------------------------------------------------------------------------- #
def test_sentinel_nedeclarat_nu_ajunge_in_payload_import(client):
"""Daca cineva fabrica direct chips_state cu cod=__NEDECLARAT__ (nu prin selectul
care seteaza exclus), salvarea trebuie respinsa — sentinelul nu e in nomenclator."""
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
chips_state = json.dumps({"v": 1, "items": [
{"cod": "__NEDECLARAT__", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 0},
]})
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
assert r.status_code == 200
assert "necunoscut" in r.text.lower(), f"Sentinelul trebuia respins ca cod necunoscut: {r.text[:400]}"
trigger_settle = r.headers.get("HX-Trigger-After-Settle", "")
assert "inchideModal" not in trigger_settle, "Salvarea NU trebuia sa reuseasca cu sentinelul in payload"
# Confirma ca sentinelul NU a ajuns in override (redeschiderea nu-l arata ca si cod).
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
payload2 = _chips_state_din_html(r2.text)
coduri = [it.get("cod") for it in payload2["items"]]
assert "__NEDECLARAT__" not in coduri, f"Sentinelul a ajuns in starea persistata: {payload2}"
def test_sentinel_nedeclarat_nu_ajunge_in_payload_trimitere(client):
"""Aceeasi respingere pe calea Trimiteri (post_corectie_trimitere)."""
sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0SN001",
[("Op-Test", "Operatie test")],
))
old_status = _submission_row(sid)["status"]
chips_state = json.dumps({"v": 1, "items": [
{"cod": "__NEDECLARAT__", "op": "Op-Test", "den": "Operatie test", "exclus": 0},
]})
r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state})
assert r.status_code == 200
assert "necunoscut" in r.text.lower(), f"Sentinelul trebuia respins ca cod necunoscut: {r.text[:400]}"
row = _submission_row(sid)
assert row["status"] == old_status, f"Statusul nu trebuia sa se schimbe: {row['status']}"
prestatii = _submission_payload(sid)["prestatii"]
coduri = [p.get("cod_prestatie") for p in prestatii]
assert "__NEDECLARAT__" not in coduri, f"Sentinelul a ajuns in payload: {prestatii}"

View File

@@ -212,7 +212,7 @@ def test_camp_observatii_prezent(client):
# --------------------------------------------------------------------------- #
def test_chips_multi_select_prestatii(client):
"""Submission cu cod_prestatie setat afiseaza chip cu hidden input."""
"""Submission cu cod_prestatie setat afiseaza chip cu OE-1 in starea hidden chips_state."""
acct = _create_account_user("chips.test@test.com")
_login(client, "chips.test@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
@@ -222,14 +222,13 @@ def test_chips_multi_select_prestatii(client):
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."
# Starea chips traieste intr-UN SINGUR hidden JSON versionat (D4, PRD 5.23).
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert m, "Formularul trebuie sa contina hidden input name='chips_state'"
payload = json.loads(m.group(1).replace("&#34;", '"'))
coduri = [it["cod"] for it in payload["items"]]
assert "OE-1" in coduri, (
f"chips_state trebuie sa contina codul OE-1 al prestatiei. items={payload['items']}"
)
@@ -244,19 +243,19 @@ def test_adauga_sterge_chip(client):
_seed_cod("OE-1", "Schimb ulei motor")
csrf = _csrf(client)
# POST /form-chips: adauga OE-1 la prima operatie (index 0)
# POST /form-chips: adauga OE-1 la prima operatie (index 0), stare curenta = un
# singur hidden chips_state (JSON versionat, D4).
state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 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_state": state,
"chips_action": "add",
"chips_add_op_index": "0",
"chips_add_cod_0": "OE-1",
"chips_target_index": "0",
"chips_pick": "OE-1",
},
)
assert resp.status_code == 200, f"/form-chips a returnat {resp.status_code}: {resp.text[:400]}"
@@ -266,34 +265,31 @@ def test_adauga_sterge_chip(client):
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]}"
# Si in starea hidden chips_state
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert m, f"hidden chips_state lipsa. html[:600]={html[:600]}"
payload = json.loads(m.group(1).replace("&#34;", '"'))
assert payload["items"][0]["cod"] == "OE-1", (
f"Dupa add, chips_state trebuie sa contina OE-1. items={payload['items']}"
)
def test_sterge_chip(client):
"""POST /form-chips cu action=remove sterge chip-ul la indexul dat."""
"""POST /form-chips cu action=remove sterge codul chip-ului la indexul dat."""
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)
# POST /form-chips: sterge codul chip-ului de la index 0 (OE-1 existent, mapat pe Op-A)
state = json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "Op-A", "den": "Schimb ulei motor", "exclus": 0},
]})
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_state": state,
"chips_action": "remove",
"chips_remove_index": "0",
},
@@ -301,15 +297,15 @@ def test_sterge_chip(client):
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]}"
# Dupa remove, OE-1 nu mai apare ca cod in starea chips (operatia RAMANE, nemapata)
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert m
payload = json.loads(m.group(1).replace("&#34;", '"'))
coduri = [it["cod"] for it in payload["items"]]
assert "OE-1" not in coduri, (
f"Dupa remove, OE-1 NU mai trebuie sa apara ca cod in chips_state. items={payload['items']}"
)
assert payload["items"][0]["op"] == "Op-A", "Operatia trebuie sa ramana (doar codul dispare)"
# --------------------------------------------------------------------------- #
@@ -437,11 +433,16 @@ def test_form_chips_reveal_r_odo(client):
# --------------------------------------------------------------------------- #
# Test 8: Picker per operatie (E4 binding) -- format op-row #
# Test 8: Chip warning pentru operatie nemapata (control unificat, PRD 5.23) #
# --------------------------------------------------------------------------- #
def test_picker_per_operatie_in_form(client):
"""Operatie nemapata (needs_mapping) -> formularul afiseaza picker pe operatie."""
"""Operatie nemapata (needs_mapping) -> formularul afiseaza chip warning + selectul unic.
PRD 5.23 US-002: controlul unificat elimina `.op-row` (rand per operatie cu
picker propriu); operatia nemapata devine un chip warning in containerul
unic `.chips`, iar selectul e unul singur (`chips_pick`, id `chips-picker`).
"""
acct = _create_account_user("picker.op@test.com")
_login(client, "picker.op@test.com")
# NU seed-uim nicio mapare -> operatia ramane nemapata
@@ -455,17 +456,15 @@ def test_picker_per_operatie_in_form(client):
assert resp.status_code == 200
html = resp.text
# Operatia REVIZIE PERIODICA trebuie sa apara in form (op-row cu clasa specifica)
# clasa "op-row" din CSS base.html e adaugata NUMAI de chips_prestatii.html
has_op_row = "op-row" in html
assert has_op_row, (
"Formularul trebuie sa contina clasa 'op-row' "
"pentru picker-ul per-operatie. "
"Aceasta clasa e adaugata de _chips_prestatii.html. "
# Controlul unificat (PRD 5.23): fara .op-row, UN singur container .chips.
assert "op-row" not in html, (
"op-row a fost eliminat de controlul unificat (PRD 5.23 US-002). "
f"html[:600]={html[:600]}"
)
# Operatia REVIZIE PERIODICA trebuie sa apara in context op-row
assert 'class="chips"' in html
assert 'id="chips-picker"' in html
# Operatia REVIZIE PERIODICA trebuie sa apara ca chip warning
assert "REVIZIE PERIODICA" in html, (
"Operatia 'REVIZIE PERIODICA' trebuie sa apara in formularul de editare (op-row). "
f"html[:500]={html[:500]}"
"Operatia 'REVIZIE PERIODICA' trebuie sa apara in formularul de editare "
f"(chip warning). html[:500]={html[:500]}"
)

View File

@@ -280,8 +280,8 @@ def test_optional_nu_forteaza(client):
def test_fara_select_vechi_redundant(client):
"""Detaliu needs_data NU mai contine <select name='cod_prestatie'>.
Formularul editabil foloseste NUMAI chips (hidden inputs cod_prestatie),
fara vechiul select dublu. Chips functioneaza ca singura sursa de cod_prestatie.
Formularul editabil foloseste NUMAI chips (stare in hidden name='chips_state',
JSON versionat, PRD 5.23), fara vechiul select dublu.
Nota: <select name="cod_prestatie"> RAMANE in formularul /repune pentru starea error.
"""
@@ -302,15 +302,14 @@ def test_fara_select_vechi_redundant(client):
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)
)
# Chipurile trebuie sa fie prezente (hidden input cu name="chips_state", JSON versionat)
has_chip_hidden = re.search(r'<input[^>]+type=["\']hidden["\'][^>]+name=["\']chips_state["\']', html)
assert has_chip_hidden, (
"Chips: trebuie sa existe input hidden cu name='cod_prestatie' (din _chips_prestatii.html). "
"Chips: trebuie sa existe input hidden cu name='chips_state' (din _chips_prestatii.html). "
f"html[:600]={html[:600]}"
)
# Cod-ul OE-1 trebuie sa apara in stare (JSON), nu ca input separat
assert "OE-1" in html, f"OE-1 trebuie sa apara in chips_state. 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)

View File

@@ -0,0 +1,97 @@
"""Prestatiile excluse de la declarare nu ajung niciodata la RAR.
content['prestatii'] pastreaza itemii exclusi (round-trip in editor), dar
payload-ul trimis efectiv la RAR (build_rar_payload, apelat din process_one)
trebuie sa contina DOAR partea declarabila. Un item exclus/fara cod nu produce
niciodata {codPrestatie: null} in payload.
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
settings = get_settings()
yield conn, settings
conn.close()
get_settings.cache_clear()
_CONTENT_CU_EXCLUS = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [
{"cod_prestatie": "OE-1", "cod_op_service": "100"},
{"cod_prestatie": None, "cod_op_service": "200", "denumire": "Spalare", "exclus": True},
],
"sistem_reparat": "null",
}
def _insert(conn, content):
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, retry_count) "
"VALUES (?, 'queued', ?, 0)",
(f"key-{os.urandom(4).hex()}", json.dumps(content)),
)
return int(cur.lastrowid)
class FakeRarCapturePayload:
"""RAR mock care retine payload-ul trimis efectiv la postPrezentare."""
def __init__(self):
self.sent_payload = None
def get_finalizate(self, token):
return []
def post_prezentare(self, token, payload):
self.sent_payload = payload
return {"id": 999}
def test_process_one_nu_trimite_item_exclus_la_rar(env):
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn, _CONTENT_CU_EXCLUS)
rar = FakeRarCapturePayload()
claimed = {"id": sid, "account_id": None, "content": _CONTENT_CU_EXCLUS}
status = process_one(conn, settings, rar, "tok", claimed)
assert status == "sent"
assert rar.sent_payload is not None
coduri = [p["codPrestatie"] for p in rar.sent_payload["prestatii"]]
assert None not in coduri
assert coduri == ["OE-1"]
def test_process_one_pastreaza_content_intact_in_db(env):
"""content['prestatii'] din DB nu se ingusteaza — doar payload-ul RAR."""
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn, _CONTENT_CU_EXCLUS)
rar = FakeRarCapturePayload()
claimed = {"id": sid, "account_id": None, "content": dict(_CONTENT_CU_EXCLUS)}
process_one(conn, settings, rar, "tok", claimed)
row = conn.execute("SELECT payload_json FROM submissions WHERE id=?", (sid,)).fetchone()
saved = json.loads(row["payload_json"])
# payload_json in DB nu e atins de process_one (doar payload-ul RAR e ingustat) —
# ramane cu ambele prestatii, inclusiv cea exclusa.
assert len(saved["prestatii"]) == 2