FiberGate / tests /test_cms_generator.py
AzizMiladi's picture
fix(ci): make ruff + mypy green on the new src/ layout
dc73111
Raw
History Blame
22.6 kB
"""
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"])