Code/tests/unit/test_ics.py
Stéphan Peccini 8e2556c2b0
test(unit): +381 tests unitaires — couverture 16%→35%
- 9 nouveaux fichiers de tests (persistance, translations, fiches, indices, IHH)
- Enrichissement des tests existants (graph_utils, gitea, widgets, tickets)
- 67→448 tests, tous passent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:52:21 +01:00

578 lines
22 KiB
Python

"""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<!---- AUTO-BEGIN:PIVOT -->\nancien pivot\n<!---- AUTO-END:PIVOT -->\n")
parts.append("\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->\nancien tableau\n<!---- AUTO-END:TABLEAU-FINAL -->\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 "<!---- AUTO-BEGIN:PIVOT -->" in result
assert "<!---- AUTO-END:PIVOT -->" 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 "<!---- AUTO-BEGIN:TABLEAU-FINAL -->" in result
assert "<!---- AUTO-END:TABLEAU-FINAL -->" 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