diff --git a/tests/test_web.py b/tests/test_web.py index a521865..f5948b2 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -268,3 +268,118 @@ def test_api_product_history_not_found(client_with_data): """GET /api/product//history retourne 404.""" resp = client_with_data.get("/api/product/ProduitInexistant/history") assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Tests /matches — DB vide et avec données +# --------------------------------------------------------------------------- + +def test_matches_page_empty_200(client): + """/matches accessible même si la base est vide.""" + resp = client.get("/matches") + assert resp.status_code == 200 + + +def test_matches_page_shows_no_pending(client): + """/matches sans données affiche un message indiquant qu'il n'y a rien à valider.""" + resp = client.get("/matches") + assert resp.status_code == 200 + # Le template affiche soit "Aucune paire" soit un message d'invitation + assert "match" in resp.text.lower() or "paire" in resp.text.lower() + + +@pytest.fixture +def db_path_with_match(db_path: Path) -> Path: + """Base avec 1 paire fuzzy pending dans product_matches.""" + conn = schema.get_connection(db_path) + try: + with conn: + conn.execute( + "INSERT INTO product_matches " + "(name_picnic, name_leclerc, score, status, created_at) " + "VALUES ('lait demi-écrémé', 'lait demi ecreme', 92.0, 'pending', '2026-01-01T00:00:00')" + ) + finally: + conn.close() + return db_path + + +@pytest.fixture +def client_with_match(db_path_with_match: Path): + """TestClient avec une paire fuzzy pending.""" + with patch("tickettracker.config.DB_PATH", db_path_with_match): + yield TestClient(app) + + +def test_matches_page_shows_pending(client_with_match): + """/matches affiche la paire pending.""" + resp = client_with_match.get("/matches") + assert resp.status_code == 200 + assert "lait demi" in resp.text.lower() + + +def test_api_match_validate_200(client_with_match, db_path_with_match): + """POST /api/match/1/validate retourne 200 et met à jour le statut.""" + resp = client_with_match.post("/api/match/1/validate") + assert resp.status_code == 200 + # Vérification en base + conn = schema.get_connection(db_path_with_match) + status = conn.execute("SELECT status FROM product_matches WHERE id=1").fetchone()["status"] + conn.close() + assert status == "validated" + + +def test_api_match_reject_200(client_with_match, db_path_with_match): + """POST /api/match/1/reject retourne 200 et met à jour le statut.""" + resp = client_with_match.post("/api/match/1/reject") + assert resp.status_code == 200 + conn = schema.get_connection(db_path_with_match) + status = conn.execute("SELECT status FROM product_matches WHERE id=1").fetchone()["status"] + conn.close() + assert status == "rejected" + + +def test_api_match_validate_not_found(client): + """POST /api/match/999/validate retourne 404.""" + resp = client.post("/api/match/999/validate") + assert resp.status_code == 404 + + +def test_api_match_reject_not_found(client): + """POST /api/match/999/reject retourne 404.""" + resp = client.post("/api/match/999/reject") + assert resp.status_code == 404 + + +def test_api_compare_includes_fuzzy_match(db_path_with_data: Path): + """GET /api/compare retourne les fuzzy matches validés dans les résultats.""" + # Insérer un fuzzy match validé + conn = schema.get_connection(db_path_with_data) + try: + with conn: + # Normaliser les articles pour avoir les données dans price_history + conn.execute( + "UPDATE items SET name_normalized = 'lait demi-écrémé' " + "WHERE name_raw = 'Lait demi-écremé'" + ) + conn.execute( + "UPDATE items SET name_normalized = 'lait demi ecreme' " + "WHERE name_raw = 'LAIT DEMI ECREME'" + ) + # Insérer un fuzzy match validé liant les deux noms + conn.execute( + "INSERT INTO product_matches " + "(name_picnic, name_leclerc, score, status, created_at) " + "VALUES ('lait demi-écrémé', 'lait demi ecreme', 92.0, 'validated', '2026-01-01T00:00:00')" + ) + finally: + conn.close() + + with patch("tickettracker.config.DB_PATH", db_path_with_data): + test_client = TestClient(app) + resp = test_client.get("/api/compare") + + assert resp.status_code == 200 + products = resp.json() + match_types = [p["match_type"] for p in products] + assert "fuzzy" in match_types diff --git a/tickettracker/web/app.py b/tickettracker/web/app.py index 0896e75..8f5bd14 100644 --- a/tickettracker/web/app.py +++ b/tickettracker/web/app.py @@ -188,6 +188,7 @@ async def page_matches(request: Request): "matches.html", { "pending": pending, + "pending_json": json.dumps(pending), "validated_count": validated_count, "rejected_count": rejected_count, }, diff --git a/tickettracker/web/static/style.css b/tickettracker/web/static/style.css index d2ad079..c686e99 100644 --- a/tickettracker/web/static/style.css +++ b/tickettracker/web/static/style.css @@ -71,7 +71,90 @@ .score-medium { background: #fff3cd; color: #856404; } .score-low { background: #f8d7da; color: #721c24; } -/* Boutons valider/rejeter dans la table matches */ +/* ── Carte de révision match (page /matches) ── */ + +.match-card { + max-width: 780px; + margin: 0 auto; + padding: 1.5rem 2rem; +} + +/* Barre de progression + score */ +.match-progress { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.25rem; + font-size: 0.9rem; + color: var(--pico-muted-color); +} + +/* Les deux colonnes Picnic / Leclerc */ +.match-sides { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 1rem; + align-items: center; + margin-bottom: 1.75rem; +} + +.match-side { + background: var(--pico-card-background-color, #f8f9fa); + border-radius: 8px; + padding: 1rem 1.25rem; + min-height: 120px; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.match-side-picnic { border-left: 4px solid #4a9eff; } +.match-side-leclerc { border-left: 4px solid #ff6b35; } + +.match-store-label { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--pico-muted-color); +} + +/* Nom du produit : petit, word-wrap pour les noms longs */ +.match-name { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.35; + word-break: break-word; + overflow-wrap: anywhere; +} + +.match-price { + font-size: 0.85rem; + color: var(--pico-muted-color); + margin-top: auto; +} + +.match-vs { + font-size: 1.4rem; + color: var(--pico-muted-color); + text-align: center; + user-select: none; +} + +/* Boutons d'action */ +.match-buttons { + display: flex; + gap: 1rem; + justify-content: center; +} + +.match-buttons button { + flex: 1; + max-width: 280px; + margin: 0; +} + +/* Garder .btn-validate / .btn-reject pour rétrocompat éventuelle */ .btn-validate { background: var(--pico-primary); color: white; diff --git a/tickettracker/web/templates/matches.html b/tickettracker/web/templates/matches.html index 3248613..30a9ebf 100644 --- a/tickettracker/web/templates/matches.html +++ b/tickettracker/web/templates/matches.html @@ -5,69 +5,152 @@ {% block content %}

Correspondances Picnic ↔ Leclerc

-

- Ces paires ont été détectées automatiquement par fuzzy matching. - Validez celles qui désignent le même produit pour enrichir la comparaison de prix. -

- - -
+ +
-

{{ pending | length }}

+

{{ pending | length }}

En attente

-

{{ validated_count }}

+

{{ validated_count }}

Validées

-

{{ rejected_count }}

+

{{ rejected_count }}

Rejetées

{% if pending %} -
-

Paires à valider

-
- - - - - - - - - - - - - {% for m in pending %} - - - - - - - - - {% endfor %} - -
Produit PicnicPrix moy.Produit LeclercPrix moy.ScoreAction
{{ m.name_picnic }}{% if m.price_picnic %}{{ "%.2f"|format(m.price_picnic) }} €{% else %}—{% endif %}{{ m.name_leclerc }}{% if m.price_leclerc %}{{ "%.2f"|format(m.price_leclerc) }} €{% else %}—{% endif %} - - {{ "%.0f"|format(m.score) }}% - - -
- -
-
- -
-
-
+ + +
+
+ + +
+ + +
+ + +
+
+
🛍 Picnic
+
+
+
+
+
+
🏪 Leclerc
+
+
+
+
+ + +
+ + +
+ + + +
+
+ + + + + {% else %}

@@ -77,6 +160,7 @@

python -m tickettracker.cli match --threshold 85
{% else %} Toutes les paires ont été traitées ({{ validated_count }} validées, {{ rejected_count }} rejetées). + Voir la comparaison → {% endif %}