"""Tests unitaires pour le module app.fiches.utils.dynamic.indice.ics. Ces tests verifient les fonctions de traitement Markdown pour l'indice ICS : - _normalize_unicode - _pairs_dataframe - _fill - _segments - _pivot - _synth - build_dynamic_sections """ import textwrap import pandas as pd import pytest from app.fiches.utils.dynamic.indice.ics import ( PAIR_RE, _fill, _normalize_unicode, _pairs_dataframe, _pivot, _segments, _synth, build_dynamic_sections, ) # ────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────── def _yaml_bloc(pair_dict: dict) -> str: """Construit un bloc YAML markdown a partir d'un dictionnaire pair.""" lignes = ["```yaml", "pair:"] for k, v in pair_dict.items(): lignes.append(f" {k}: {v}") lignes.append("```") return "\n".join(lignes) def _sample_pair(**overrides) -> dict: """Retourne un dictionnaire pair avec des valeurs par defaut.""" base = { "composant": "Batterie", "minerai": "Lithium", "f_tech": 0.80, "delai": 0.50, "cout": 0.70, "ics": 0.65, } base.update(overrides) return base # ────────────────────────────────────────────── # _normalize_unicode # ────────────────────────────────────────────── class TestNormalizeUnicode: """Tests pour la normalisation Unicode NFKC.""" def test_texte_ascii_inchange(self): """Test qu'un texte ASCII pur n'est pas modifie.""" texte = "Hello world 123" assert _normalize_unicode(texte) == texte def test_ligatures_decomposees(self): """Test que les ligatures Unicode sont decomposees (NFKC).""" # U+FB01 = fi ligature -> "fi" en NFKC assert _normalize_unicode("\ufb01") == "fi" def test_exposants_normalises(self): """Test que les caracteres exposants sont normalises.""" # U+00B2 = superscript 2 -> "2" en NFKC assert _normalize_unicode("\u00b2") == "2" def test_indices_normalises(self): """Test que les caracteres indices sont normalises.""" # U+2082 = subscript 2 -> "2" en NFKC assert _normalize_unicode("\u2082") == "2" def test_texte_vide(self): """Test avec un texte vide.""" assert _normalize_unicode("") == "" def test_accents_francais_preserves(self): """Test que les accents francais courants sont preserves.""" texte = "Criticite par couple Composant" resultat = _normalize_unicode(texte) assert "Criticite" in resultat # ────────────────────────────────────────────── # PAIR_RE (regex) # ────────────────────────────────────────────── class TestPairRegex: """Tests pour l'expression reguliere PAIR_RE.""" def test_match_bloc_yaml_simple(self): """Test la detection d'un bloc yaml simple.""" md = "texte\n```yaml\npair:\n ics: 0.5\n```\ntexte" matches = PAIR_RE.findall(md) assert len(matches) == 1 def test_match_blocs_yaml_multiples(self): """Test la detection de plusieurs blocs yaml.""" md = "```yaml\na: 1\n```\ntexte\n```yaml\nb: 2\n```" matches = PAIR_RE.findall(md) assert len(matches) == 2 def test_pas_de_match_sans_yaml(self): """Test qu'un texte sans bloc yaml ne matche pas.""" md = "Du texte simple sans bloc." matches = PAIR_RE.findall(md) assert len(matches) == 0 def test_match_insensible_casse(self): """Test que YAML en majuscules est aussi detecte.""" md = "```YAML\ndata: 1\n```" matches = PAIR_RE.findall(md) assert len(matches) == 1 def test_yaml_avec_annotation(self): """Test un bloc yaml avec annotation apres le tag.""" md = "```yaml pair-data\npair:\n ics: 0.3\n```" matches = PAIR_RE.findall(md) assert len(matches) == 1 # ────────────────────────────────────────────── # _pairs_dataframe # ────────────────────────────────────────────── class TestPairsDataframe: """Tests pour l'extraction des paires en DataFrame.""" def test_une_paire(self): """Test l'extraction d'une seule paire YAML.""" pair = _sample_pair() md = _yaml_bloc(pair) df = _pairs_dataframe(md) assert isinstance(df, pd.DataFrame) assert len(df) == 1 assert df.iloc[0]["composant"] == "Batterie" assert df.iloc[0]["minerai"] == "Lithium" assert df.iloc[0]["ics"] == pytest.approx(0.65) def test_plusieurs_paires(self): """Test l'extraction de plusieurs paires YAML.""" pair1 = _sample_pair(composant="Batterie", minerai="Lithium", ics=0.65) pair2 = _sample_pair(composant="Ecran", minerai="Indium", ics=0.80) md = _yaml_bloc(pair1) + "\ntexte\n" + _yaml_bloc(pair2) df = _pairs_dataframe(md) assert len(df) == 2 assert set(df["composant"]) == {"Batterie", "Ecran"} def test_pas_de_bloc_yaml(self): """Test avec un markdown sans bloc yaml.""" df = _pairs_dataframe("Texte sans bloc yaml.") assert df.empty def test_bloc_yaml_sans_cle_pair(self): """Test avec un bloc yaml qui n'a pas la cle 'pair'.""" md = "```yaml\nautres_donnees:\n x: 1\n```" df = _pairs_dataframe(md) assert df.empty def test_bloc_yaml_liste_pas_dict(self): """Test avec un bloc yaml contenant une liste au lieu d'un dict.""" md = "```yaml\n- item1\n- item2\n```" df = _pairs_dataframe(md) assert df.empty def test_texte_vide(self): """Test avec un texte vide.""" df = _pairs_dataframe("") assert df.empty def test_melange_blocs_valides_invalides(self): """Test avec un melange de blocs valides et invalides.""" pair_valide = _sample_pair(composant="GPU", minerai="Gallium", ics=0.40) md = ( "```yaml\ninfos: test\n```\n" + _yaml_bloc(pair_valide) + "\n" + "```yaml\n- liste\n```" ) df = _pairs_dataframe(md) assert len(df) == 1 assert df.iloc[0]["composant"] == "GPU" # ────────────────────────────────────────────── # _fill # ────────────────────────────────────────────── class TestFill: """Tests pour le remplissage de placeholders dans un segment.""" def test_remplacement_simple(self): """Test le remplacement d'un placeholder simple.""" segment = "Le composant {{ composant }} utilise {{ minerai }}." pair = {"composant": "Batterie", "minerai": "Lithium", "ics": 0.65} result = _fill(segment, pair) assert "Batterie" in result assert "Lithium" in result assert "{{ composant }}" not in result def test_remplacement_valeur_numerique(self): """Test le remplacement avec des valeurs numeriques formatees.""" segment = "ICS = {{ ics }} et f_tech = {{ f_tech }}." pair = {"ics": 0.65, "f_tech": 0.80} result = _fill(segment, pair) assert "0.65" in result assert "0.80" in result def test_remplacement_ics_dans_formule(self): """Test le remplacement de la valeur ICS dans une expression ICS = X.""" segment = "Le resultat est ICS = 0.00 pour ce couple." pair = {"ics": 0.75} result = _fill(segment, pair) assert "ICS = 0.75" in result def test_remplacement_ics_valeur_existante(self): """Test que ICS = ancien est remplace par la nouvelle valeur.""" segment = "Calcul : ICS = 0.99 fin." pair = {"ics": 0.42} result = _fill(segment, pair) assert "ICS = 0.42" in result assert "ICS = 0.99" not in result def test_placeholder_insensible_casse(self): """Test que les placeholders sont insensibles a la casse.""" segment = "{{ COMPOSANT }} et {{ Minerai }}." pair = {"composant": "RAM", "minerai": "Silicium", "ics": 0.50} result = _fill(segment, pair) assert "RAM" in result assert "Silicium" in result def test_placeholder_espaces_variables(self): """Test les placeholders avec des espacements differents.""" segment = "{{composant}} et {{ minerai }}." pair = {"composant": "PCB", "minerai": "Cuivre", "ics": 0.30} result = _fill(segment, pair) assert "PCB" in result assert "Cuivre" in result def test_valeur_entiere(self): """Test le formatage d'une valeur entiere en .2f.""" segment = "Valeur: {{ ics }}." pair = {"ics": 1} result = _fill(segment, pair) assert "1.00" in result def test_unicode_normalise(self): """Test que le segment est normalise Unicode avant remplacement.""" # U+00B2 (superscript 2) normalise en "2" segment = "ICS\u00b2 {{ composant }}" pair = {"composant": "X", "ics": 0.5} result = _fill(segment, pair) assert "X" in result # ────────────────────────────────────────────── # _segments # ────────────────────────────────────────────── class TestSegments: """Tests pour l'extraction des segments entre blocs YAML.""" def test_un_segment(self): """Test l'extraction d'un seul segment.""" pair = _sample_pair() md = _yaml_bloc(pair) + "\nSegment apres le bloc." segments = list(_segments(md)) assert len(segments) == 1 pair_result, seg = segments[0] assert pair_result["composant"] == "Batterie" assert "Segment apres le bloc." in seg def test_deux_segments(self): """Test l'extraction de deux segments entre trois blocs.""" pair1 = _sample_pair(composant="A", minerai="X", ics=0.1, f_tech=0.2, delai=0.3, cout=0.4) pair2 = _sample_pair(composant="B", minerai="Y", ics=0.5, f_tech=0.6, delai=0.7, cout=0.8) md = _yaml_bloc(pair1) + "\nSegment 1\n" + _yaml_bloc(pair2) + "\nSegment 2" segments = list(_segments(md)) assert len(segments) == 2 assert segments[0][0]["composant"] == "A" assert "Segment 1" in segments[0][1] assert segments[1][0]["composant"] == "B" assert "Segment 2" in segments[1][1] def test_segment_vide_entre_blocs(self): """Test que les segments entre blocs consecutifs sont captures.""" pair1 = _sample_pair(composant="C", minerai="Z", ics=0.2, f_tech=0.3, delai=0.4, cout=0.5) pair2 = _sample_pair(composant="D", minerai="W", ics=0.6, f_tech=0.7, delai=0.8, cout=0.9) md = _yaml_bloc(pair1) + "\n" + _yaml_bloc(pair2) segments = list(_segments(md)) assert len(segments) == 2 def test_pas_de_bloc_yaml(self): """Test avec un markdown sans bloc yaml.""" md = "Texte sans bloc yaml." segments = list(_segments(md)) assert len(segments) == 0 # ────────────────────────────────────────────── # _pivot # ────────────────────────────────────────────── class TestPivot: """Tests pour la generation du tableau pivot par minerai.""" @pytest.fixture def df_simple(self): """DataFrame simple avec deux paires.""" return pd.DataFrame([ {"composant": "Batterie", "minerai": "Lithium", "ics": 0.65, "f_tech": 0.80, "delai": 0.50, "cout": 0.70}, {"composant": "Ecran", "minerai": "Lithium", "ics": 0.45, "f_tech": 0.60, "delai": 0.30, "cout": 0.40}, ]) @pytest.fixture def df_multi_minerai(self): """DataFrame avec plusieurs minerais.""" return pd.DataFrame([ {"composant": "Batterie", "minerai": "Lithium", "ics": 0.65, "f_tech": 0.80, "delai": 0.50, "cout": 0.70}, {"composant": "Ecran", "minerai": "Indium", "ics": 0.80, "f_tech": 0.90, "delai": 0.60, "cout": 0.85}, ]) def test_en_tetes_tableau(self, df_simple): """Test que les en-tetes du tableau sont presents.""" result = _pivot(df_simple) assert "| Composant | ICS | Faisabilit\u00e9 technique | D\u00e9lai d'impl\u00e9mentation | Impact \u00e9conomique |" in result def test_titre_minerai(self, df_simple): """Test que le titre du minerai est un h2.""" result = _pivot(df_simple) assert "## Lithium" in result def test_tri_ics_descendant(self, df_simple): """Test que les lignes sont triees par ICS descendant.""" result = _pivot(df_simple) lignes = result.strip().split("\n") # Trouver les lignes de donnees (apres les en-tetes) data_lignes = [line for line in lignes if line.startswith(("| Batterie", "| Ecran"))] assert len(data_lignes) == 2 # Batterie (0.65) doit apparaitre avant Ecran (0.45) idx_batterie = result.find("Batterie") idx_ecran = result.find("Ecran") assert idx_batterie < idx_ecran def test_formatage_valeurs(self, df_simple): """Test le formatage des valeurs en .2f.""" result = _pivot(df_simple) assert "0.65" in result assert "0.80" in result def test_plusieurs_minerais(self, df_multi_minerai): """Test avec plusieurs minerais genere plusieurs sections.""" result = _pivot(df_multi_minerai) assert "## Lithium" in result or "## Indium" in result # Les deux minerais doivent etre presents assert "Lithium" in result assert "Indium" in result def test_dataframe_vide(self): """Test avec un DataFrame vide.""" df = pd.DataFrame(columns=["composant", "minerai", "ics", "f_tech", "delai", "cout"]) result = _pivot(df) assert result == "" # ────────────────────────────────────────────── # _synth # ────────────────────────────────────────────── class TestSynth: """Tests pour la generation du tableau de synthese ICS.""" @pytest.fixture def df_synth(self): """DataFrame pour la synthese.""" return pd.DataFrame([ {"composant": "Batterie", "minerai": "Lithium", "ics": 0.65}, {"composant": "Ecran", "minerai": "Indium", "ics": 0.80}, {"composant": "PCB", "minerai": "Cuivre", "ics": 0.30}, ]) def test_en_tetes_synthese(self, df_synth): """Test que les en-tetes du tableau de synthese sont presents.""" result = _synth(df_synth) assert "| Composant | Minerai | ICS |" in result assert "| :-- | :-- | :--: |" in result def test_tri_ics_descendant(self, df_synth): """Test que la synthese est triee par ICS descendant.""" result = _synth(df_synth) idx_ecran = result.find("Ecran") # ics=0.80 idx_batterie = result.find("Batterie") # ics=0.65 idx_pcb = result.find("PCB") # ics=0.30 assert idx_ecran < idx_batterie < idx_pcb def test_formatage_ics(self, df_synth): """Test le formatage des valeurs ICS en .2f.""" result = _synth(df_synth) assert "0.80" in result assert "0.65" in result assert "0.30" in result def test_toutes_lignes_presentes(self, df_synth): """Test que toutes les lignes de donnees sont presentes.""" result = _synth(df_synth) lignes = result.strip().split("\n") # 2 en-tetes + 3 donnees = 5 assert len(lignes) == 5 def test_une_seule_paire(self): """Test la synthese avec une seule paire.""" df = pd.DataFrame([{"composant": "GPU", "minerai": "Gallium", "ics": 0.50}]) result = _synth(df) assert "GPU" in result assert "Gallium" in result assert "0.50" in result # ────────────────────────────────────────────── # build_dynamic_sections # ────────────────────────────────────────────── class TestBuildDynamicSections: """Tests pour la fonction principale de construction des sections dynamiques ICS.""" def _make_full_md(self, pairs: list[dict], with_markers: bool = True) -> str: """Construit un markdown complet avec entete, blocs YAML et marqueurs.""" parts = ["# Pr\u00e9sentation\n\nIntro du document.\n"] parts.append("# Criticit\u00e9 par couple Composant -> Minerai\n") for pair in pairs: parts.append(_yaml_bloc(pair)) parts.append("\nAnalyse de {{ composant }} avec {{ minerai }}.") parts.append("ICS = 0.00\n") if with_markers: parts.append("\n\nancien pivot\n\n") parts.append("\n\nancien tableau\n\n") return "\n".join(parts) def test_remplacement_complet(self): """Test que build_dynamic_sections remplace les sections dynamiques.""" pair = _sample_pair() md = self._make_full_md([pair]) result = build_dynamic_sections(md) # Le pivot et la synthese doivent etre generes assert "## Lithium" in result # pivot assert "| Composant | Minerai | ICS |" in result # synthese def test_placeholders_remplaces(self): """Test que les placeholders sont remplaces dans les segments.""" pair = _sample_pair() md = self._make_full_md([pair]) result = build_dynamic_sections(md) assert "Batterie" in result assert "Lithium" in result assert "{{ composant }}" not in result def test_ics_remplace_dans_segment(self): """Test que la valeur ICS est mise a jour dans le segment.""" pair = _sample_pair(ics=0.72) md = self._make_full_md([pair]) result = build_dynamic_sections(md) assert "ICS = 0.72" in result assert "ICS = 0.00" not in result def test_marqueurs_pivot_preserves(self): """Test que les marqueurs AUTO-BEGIN/END:PIVOT sont preserves.""" pair = _sample_pair() md = self._make_full_md([pair]) result = build_dynamic_sections(md) assert "" in result assert "" in result def test_marqueurs_tableau_final_preserves(self): """Test que les marqueurs AUTO-BEGIN/END:TABLEAU-FINAL sont preserves.""" pair = _sample_pair() md = self._make_full_md([pair]) result = build_dynamic_sections(md) assert "" in result assert "" in result def test_pas_de_bloc_yaml_retourne_original(self): """Test qu'un markdown sans bloc yaml est retourne tel quel.""" md = "# Titre\n\nTexte sans bloc yaml." result = build_dynamic_sections(md) assert "Texte sans bloc yaml." in result def test_texte_vide(self): """Test avec un texte vide.""" result = build_dynamic_sections("") assert result == "" def test_plusieurs_paires(self): """Test avec plusieurs paires genere un tableau complet.""" pair1 = _sample_pair(composant="Batterie", minerai="Lithium", ics=0.65, f_tech=0.80, delai=0.50, cout=0.70) pair2 = _sample_pair(composant="Ecran", minerai="Indium", ics=0.80, f_tech=0.90, delai=0.60, cout=0.85) md = self._make_full_md([pair1, pair2]) result = build_dynamic_sections(md) assert "Batterie" in result assert "Ecran" in result assert "Lithium" in result assert "Indium" in result def test_unicode_normalise(self): """Test que les caracteres Unicode sont normalises dans le resultat.""" pair = _sample_pair() md = self._make_full_md([pair]) # Injecter un caractere Unicode non-normalise md = md.replace("Batterie", "Batterie\u00b2") result = build_dynamic_sections(md) # Le resultat doit etre normalise (superscript 2 -> "2") assert isinstance(result, str) def test_ancien_contenu_pivot_remplace(self): """Test que l'ancien contenu entre les marqueurs PIVOT est remplace.""" pair = _sample_pair() md = self._make_full_md([pair]) result = build_dynamic_sections(md) assert "ancien pivot" not in result def test_ancien_contenu_tableau_final_remplace(self): """Test que l'ancien contenu entre les marqueurs TABLEAU-FINAL est remplace.""" pair = _sample_pair() md = self._make_full_md([pair]) result = build_dynamic_sections(md) assert "ancien tableau" not in result def test_bloc_yaml_sans_pair_ignore(self): """Test qu'un bloc yaml sans la cle 'pair' ne casse pas le traitement.""" md = textwrap.dedent("""\ # Presentation # Criticite par couple Composant -> Minerai ```yaml metadata: version: 1 ``` Texte d'analyse. """) result = build_dynamic_sections(md) # Aucune paire trouvee, le texte original est retourne assert "Texte d'analyse." in result