""" Unit tests for `cms_generator.py` — the module that turns a Verdict into a filled CMS IMMO 9 BANBOU xlsx. Covers every pure derivation function (Type Site, Détection, Pré-équipé, AU-type detection, DLPI adjustment, address parsing, name splitting, PF extraction) plus one end-to-end `fill_cms` call that loads the actual template and verifies the expected cells are written. """ from __future__ import annotations from datetime import datetime, timedelta from pathlib import Path import pytest # ────────────────────────────────────────────────────────────────────────── # Type Site (S/C) — slide 7 # ────────────────────────────────────────────────────────────────────────── @pytest.mark.parametrize("nb_res, nb_pro, expected", [ (1, 0, "S"), # single house, 1 res (2, 0, "S"), # single house, 2 res (3, 0, "C"), # ≥ 3 res → collectif (5, 0, "C"), (0, 1, "C"), # any P el → collectif (1, 1, "C"), (5, 3, "C"), (0, 0, "S"), # nothing extracted → conservative default ]) def test_compute_type_site(cms_mod, nb_res, nb_pro, expected): assert cms_mod.compute_type_site(nb_res, nb_pro) == expected # ────────────────────────────────────────────────────────────────────────── # Project type — heuristic that drives Pré-équipé + syndic-sheet trigger # ────────────────────────────────────────────────────────────────────────── @pytest.mark.parametrize("nb_res, nb_pro, expected", [ (1, 0, "PIM"), (2, 0, "PIM"), (3, 0, "COLLECTIF"), (14, 0, "COLLECTIF"), (0, 1, "COLLECTIF"), (5, 3, "COLLECTIF"), ]) def test_compute_project_type(cms_mod, nb_res, nb_pro, expected): assert cms_mod.compute_project_type(nb_res, nb_pro) == expected # ────────────────────────────────────────────────────────────────────────── # AU prefix detection — must NOT match French words like "rue", "Parcelle" # ────────────────────────────────────────────────────────────────────────── @pytest.mark.parametrize("ref, expected", [ ("PC 044 035 25 00035", "PC"), ("PC0440352500035", "PC"), ("Pc0440352500035", "PC"), ("PA 022 360 22 00027", "PA"), ("DP 044 035", "DP"), ("CU 12345", "CU"), ("rue Abbé Guinard", ""), # must reject — "ru" is NOT a valid prefix ("Parcelle", ""), # must reject — "PA" only counts before digits ("", ""), (None, ""), ]) def test_detect_au_type(cms_mod, ref, expected): assert cms_mod.detect_au_type(ref) == expected # ────────────────────────────────────────────────────────────────────────── # Pré-équipé — slide 14 table # ────────────────────────────────────────────────────────────────────────── @pytest.mark.parametrize("type_au, proj, expected", [ ("PC", "COLLECTIF", "O"), ("PA", "COLLECTIF", "N"), ("DP", "COLLECTIF", "O"), ("PC", "PIM", "N"), ("PA", "PIM", "N"), ("DP", "PIM", "N"), ("", "COLLECTIF", ""), ]) def test_compute_pre_equipe(cms_mod, type_au, proj, expected): assert cms_mod.compute_pre_equipe(type_au, proj) == expected # ────────────────────────────────────────────────────────────────────────── # Détection — slide 13 table (the most complex derivation) # ────────────────────────────────────────────────────────────────────────── @pytest.mark.parametrize("nb_res, nb_pro, type_au, proj, expected", [ # ≤ 3 els, 1-2 R, no P → RAMI Fibre (1, 0, "PC", "PIM", "RAMI Fibre"), (2, 0, "PC", "PIM", "RAMI Fibre"), # ≤ 3 els, mix or 3 R → MixteProL fibre (3, 0, "PC", "PIM", "MixteProL fibre"), (1, 1, "PC", "COLLECTIF", "MixteProL fibre"), # > 3 els, 100 % résidentiel → Zlin 0% cuivre (14, 0, "PC", "COLLECTIF", "Zlin 0% cuivre"), (73, 0, "PC", "COLLECTIF", "Zlin 0% cuivre"), # > 3 els, RES >= PRO → Zlin 0% cuivre (residential-dominated) (21, 1, "PC", "COLLECTIF", "Zlin 0% cuivre"), (10, 10, "PC", "COLLECTIF", "Zlin 0% cuivre"), # tie → res # > 3 els, PRO > RES → ZLIN ProPur (1, 5, "PC", "COLLECTIF", "ZLIN ProPur"), (0, 4, "PC", "COLLECTIF", "ZLIN ProPur"), # DP + PIM-sized = "lot individuel adduction sur rue" → MixteProL fibre (1, 0, "DP", "PIM", "MixteProL fibre"), ]) def test_compute_detection(cms_mod, nb_res, nb_pro, type_au, proj, expected): assert cms_mod.compute_detection(nb_res, nb_pro, type_au, proj) == expected # ────────────────────────────────────────────────────────────────────────── # DLPI adjustment — slide 12 # ────────────────────────────────────────────────────────────────────────── def test_adjust_dlpi_past_date_pushed_to_six_months(cms_mod): soon = (datetime.now() + timedelta(days=30)).strftime("%d/%m/%Y") adjusted = cms_mod.adjust_dlpi(soon) # Should be pushed to ≥ today + 6 months target = datetime.now() + timedelta(days=180) parsed = datetime.strptime(adjusted, "%d/%m/%Y") assert parsed.date() >= (target - timedelta(days=1)).date() def test_adjust_dlpi_far_future_unchanged(cms_mod): far = (datetime.now() + timedelta(days=400)).strftime("%d/%m/%Y") assert cms_mod.adjust_dlpi(far) == far def test_adjust_dlpi_empty_returns_empty(cms_mod): assert cms_mod.adjust_dlpi("") == "" assert cms_mod.adjust_dlpi(None) == "" def test_adjust_dlpi_unparseable_passed_through(cms_mod): # If we can't parse it, leave it for the consultant to inspect assert cms_mod.adjust_dlpi("janvier 2027") == "janvier 2027" # ────────────────────────────────────────────────────────────────────────── # Address parsing # ────────────────────────────────────────────────────────────────────────── def test_parse_address_full(cms_mod): a = cms_mod.parse_french_address("10 rue de Cotalard, 44240 La Chapelle-sur-Erdre.") assert a["numero"] == "10" assert a["voie"] == "rue de Cotalard" assert a["cp_ville"] == "44240 La Chapelle-sur-Erdre" def test_parse_address_with_complement(cms_mod): a = cms_mod.parse_french_address("350 BIS AVENUE J R G GAUTIER, 13290 AIX EN PROVENCE") assert a["numero"] == "350" assert a["complement"] == "BIS" assert "13290" in a["cp_ville"] def test_parse_address_voie_only(cms_mod): """Some certificats only have the street name with no number / no CP.""" a = cms_mod.parse_french_address("rue du Saint Blaise") assert "voie" in a def test_parse_address_empty(cms_mod): assert cms_mod.parse_french_address("") == {} assert cms_mod.parse_french_address(None) == {} # ────────────────────────────────────────────────────────────────────────── # Name splitting — "FAURE Mael" → ("FAURE", "Mael") # ────────────────────────────────────────────────────────────────────────── @pytest.mark.parametrize("full, expected", [ ("FAURE Mael", ("FAURE", "Mael")), ("PASCALIN Marine", ("PASCALIN", "Marine")), ("Mr. BRECHBIEHL Vivien", ("BRECHBIEHL", "Vivien")), ("CLAVIER YOHANN", ("CLAVIER YOHANN", "")), # both UPPER → all go to nom ("Florence", ("Florence", "")), ("", ("", "")), ]) def test_split_name(cms_mod, full, expected): assert cms_mod._split_name(full) == expected # ────────────────────────────────────────────────────────────────────────── # PF code extraction from filenames # ────────────────────────────────────────────────────────────────────────── def test_extract_pf_code_from_documents(cms_mod): docs = [ {"file": "Random_doc.pdf"}, {"file": "PF0442402600168_Fiche-de-renseignement_1.pdf"}, ] assert cms_mod._extract_pf_code(docs) == "PF0442402600168" def test_extract_pf_code_missing(cms_mod): docs = [{"file": "no_pf_here.pdf"}, {"file": "still_nothing.jpg"}] assert cms_mod._extract_pf_code(docs) == "" # ────────────────────────────────────────────────────────────────────────── # _pick_address — Certificat > fiche > any doc fallback chain # ────────────────────────────────────────────────────────────────────────── def _make_verdict_with_address(certif_addr=None, fiche_addr=None, autorisation_addr=None): docs = [] if certif_addr is not None: docs.append({"file": "cert.pdf", "doc_class": "Certificat", "doc_confidence": 0.9, "fields": {"Batiment_Adresse": {"value": certif_addr, "confidence": 0.95}}}) if autorisation_addr is not None: docs.append({"file": "auto.pdf", "doc_class": "Autorisation", "doc_confidence": 0.9, "fields": {"Batiment_Adresse": {"value": autorisation_addr, "confidence": 0.7}}}) fiche_fields = {} if fiche_addr is not None: fiche_fields["Batiment_Adresse"] = {"value": fiche_addr, "confidence": 0.8} docs.append({"file": "fiche.pdf", "doc_class": "fiche", "doc_confidence": 0.95, "fields": fiche_fields}) return {"documents": docs, "fiche_summary": fiche_fields} def test_pick_address_prefers_certificat(cms_mod): v = _make_verdict_with_address( certif_addr="10 rue du Certif", fiche_addr="20 rue de la Fiche", ) assert cms_mod._pick_address(v) == "10 rue du Certif" def test_pick_address_falls_back_to_fiche(cms_mod): v = _make_verdict_with_address(fiche_addr="20 rue de la Fiche") assert cms_mod._pick_address(v) == "20 rue de la Fiche" def test_pick_address_falls_back_to_any_doc(cms_mod): """When neither Certificat nor fiche has Batiment_Adresse, fall back to any document that does (regression: previously returned empty).""" v = _make_verdict_with_address(autorisation_addr="5 rue de l'Auto") assert cms_mod._pick_address(v) == "5 rue de l'Auto" def test_pick_address_empty_when_nothing(cms_mod): v = _make_verdict_with_address() assert cms_mod._pick_address(v) == "" # ────────────────────────────────────────────────────────────────────────── # Eligibility check # ────────────────────────────────────────────────────────────────────────── @pytest.mark.parametrize("status, expected", [ ("complète", True), ("complète sous réserve", True), ("incomplète", False), ("hors-périmètre", False), ("", False), ]) def test_is_cms_eligible(cms_mod, status, expected): assert cms_mod.is_cms_eligible({"status": status}) is expected # ────────────────────────────────────────────────────────────────────────── # End-to-end: fill the actual CMS template from a synthetic verdict # ────────────────────────────────────────────────────────────────────────── def _make_verdict_pim_complete() -> dict: """PF0442402600168-style verdict: 1 logement, full extraction.""" return { "status": "complète", "documents": [ { "file": "PF0442402600168_Fiche-de-renseignement_1.pdf", "doc_class": "fiche", "doc_confidence": 0.98, "fields": { "Reference_Urbanisme": {"value": "Pc0440352500035", "confidence": 0.99}, "DLPI": {"value": "20/10/2026", "confidence": 0.97}, "cabinet_conseil": {"value": "ORANGE BEIN PPIN","confidence": 0.96}, "nb_log_totale": {"value": "1", "confidence": 0.70}, }, }, { "file": "PF0442402600168_Certificat-d-adressage_1.pdf", "doc_class": "Certificat", "doc_confidence": 0.89, "fields": { "Batiment_Adresse": { "value": "10 rue de Cotalard, 44240 La Chapelle-sur-Erdre.", "confidence": 0.99, }, }, }, ], "fiche_summary": { "Reference_Urbanisme": {"value": "Pc0440352500035", "confidence": 0.99}, "DLPI": {"value": "20/10/2026", "confidence": 0.97}, "cabinet_conseil": {"value": "ORANGE BEIN PPIN","confidence": 0.96}, "nb_log_totale": {"value": "1", "confidence": 0.70}, }, "missing_documents": [], "incomplete_documents": [], "manual_review_documents": [], "ar_mail_body": "", } def test_fill_cms_pim_writes_creation_row(cms_mod, tmp_path): out = tmp_path / "cms_pim.xlsx" result = cms_mod.fill_cms(_make_verdict_pim_complete(), out) # Result-shape contract assert result["project_type"] == "PIM" assert "missing_extractions" in result assert "manual_lookup" in result assert Path(result["output_path"]).exists() # Inspect the written sheet from openpyxl import load_workbook wb = load_workbook(out) creation_sheet = next(n for n in wb.sheetnames if "creation imb" in n.lower().replace("é", "e")) ws = wb[creation_sheet] # Row 4 is the first data row assert ws.cell(row=4, column=1).value == "S" # Type Site assert ws.cell(row=4, column=5).value == "10" # Numero assert ws.cell(row=4, column=7).value == "rue de Cotalard" # Voie assert ws.cell(row=4, column=9).value == "Guichet Accueil OI" # Zone Nouvelle assert "44240" in ws.cell(row=4, column=10).value # CP/Ville assert ws.cell(row=4, column=11).value == 1 # Nb log R assert ws.cell(row=4, column=13).value == "Pc0440352500035" # Ref AU assert ws.cell(row=4, column=14).value == "PF0442402600168" # PF Agilis assert ws.cell(row=4, column=16).value == 9 # Detection = RAMI Fibre code assert ws.cell(row=4, column=17).value == "N" # Pré-équipé = N (PIM) assert ws.cell(row=4, column=21).value == 13 # Typologie = OSA def test_fill_cms_pim_clears_syndic_row(cms_mod, tmp_path): """For PIM projects the création-syndic sample row in the template must be wiped (otherwise the consultant inherits SCCV xxxxx / CLAVIER YOHANN from the template).""" out = tmp_path / "cms_pim_syndic_clear.xlsx" cms_mod.fill_cms(_make_verdict_pim_complete(), out) from openpyxl import load_workbook wb = load_workbook(out) syndic = next(n for n in wb.sheetnames if "syndic" in n.lower()) ws = wb[syndic] # All columns of row 4 should be empty/None for col in range(1, ws.max_column + 1): assert ws.cell(row=4, column=col).value in (None, ""), \ f"col {col} not cleared: {ws.cell(row=4, column=col).value!r}" def test_fill_cms_collectif_populates_syndic(cms_mod, tmp_path): """COLLECTIF + Mandat: syndic sheet is filled from Mandat + cabinet.""" verdict = { "status": "complète", "documents": [ { "file": "PF0335202600876_Fiche-de-renseignement_1.pdf", "doc_class": "fiche", "doc_confidence": 0.96, "fields": { "Reference_Urbanisme": {"value": "PC0330752500012", "confidence": 0.99}, "DLPI": {"value": "03/07/2028", "confidence": 0.97}, "cabinet_conseil": {"value": "ORANGE BEIN SO", "confidence": 0.96}, "nb_log_totale": {"value": "14", "confidence": 0.70}, }, }, { "file": "PF0335202600876_Mandat.pdf", "doc_class": "Mandat", "doc_confidence": 0.90, "fields": { "Representant_Nom_Complet": {"value": "PASCALIN Marine", "confidence": 0.72}, "Representant_Email": {"value": "marine.pascalin@orange.com", "confidence": 0.77}, "Representant_Telephone": {"value": "06 70495507", "confidence": 0.81}, }, }, ], "fiche_summary": { "Reference_Urbanisme": {"value": "PC0330752500012", "confidence": 0.99}, "DLPI": {"value": "03/07/2028", "confidence": 0.97}, "cabinet_conseil": {"value": "ORANGE BEIN SO", "confidence": 0.96}, "nb_log_totale": {"value": "14", "confidence": 0.70}, }, "missing_documents": [], "incomplete_documents": [], "manual_review_documents": [], "ar_mail_body": "", } out = tmp_path / "cms_collectif.xlsx" result = cms_mod.fill_cms(verdict, out) assert result["project_type"] == "COLLECTIF" from openpyxl import load_workbook wb = load_workbook(out) creation = next(n for n in wb.sheetnames if "creation imb" in n.lower().replace("é", "e")) syndic = next(n for n in wb.sheetnames if "syndic" in n.lower()) # creation IMB: type site C, 14 logements R, detection = Zlin 0% cuivre (code 2) assert wb[creation].cell(row=4, column=1).value == "C" assert wb[creation].cell(row=4, column=11).value == 14 assert wb[creation].cell(row=4, column=16).value == 2 assert wb[creation].cell(row=4, column=17).value == "O" # PC + Collectif # création syndic: filled from cabinet + Mandat ws_s = wb[syndic] assert ws_s.cell(row=4, column=1).value == "ORANGE BEIN SO" assert ws_s.cell(row=4, column=7).value == "PASCALIN" assert ws_s.cell(row=4, column=8).value == "Marine" assert ws_s.cell(row=4, column=10).value == "marine.pascalin@orange.com" assert ws_s.cell(row=4, column=11).value == 18 # 18 = Promoteur def test_fill_cms_reports_missing_fields_when_extraction_incomplete(cms_mod, tmp_path): """Verdict with no address → numero/voie/cp_ville should appear in missing_extractions.""" verdict = { "status": "incomplète", "documents": [ { "file": "PF0562502601177_Fiche-de-renseignement_1.pdf", "doc_class": "fiche", "doc_confidence": 0.98, "fields": { "Reference_Urbanisme": {"value": "PC0562552500009", "confidence": 0.99}, "DLPI": {"value": "14/09/2026", "confidence": 0.97}, }, }, ], "fiche_summary": { "Reference_Urbanisme": {"value": "PC0562552500009", "confidence": 0.99}, "DLPI": {"value": "14/09/2026", "confidence": 0.97}, }, "missing_documents": [], "incomplete_documents": [], "manual_review_documents": [], "ar_mail_body": "", } out = tmp_path / "cms_partial.xlsx" result = cms_mod.fill_cms(verdict, out) missing = " ".join(result["missing_extractions"]) assert "logements" in missing # no R/P count assert "voie" in missing.lower() # no address assert "Code postal" in missing # no CP/ville # always-manual always present assert any("Géoréso" in s for s in result["manual_lookup"])