diff --git a/tests/__init__.py b/tests/__init__.py index daab496..53c8c68 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,4 @@ -""" -Package de tests pour l'application FabNum. +"""Package de tests pour l'application FabNum. Organisation : - unit/ : Tests unitaires (fonctions isolées) diff --git a/tests/conftest.py b/tests/conftest.py index 7b94733..4870e4d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,14 @@ -""" -Configuration pytest et fixtures globales pour les tests FabNum. +"""Configuration pytest et fixtures globales pour les tests FabNum. Ce fichier contient les fixtures partagées entre tous les tests. """ -import pytest import sys -import tempfile -import networkx as nx from pathlib import Path +import networkx as nx +import pytest + # Ajouter le répertoire racine au PYTHONPATH pour les imports ROOT_DIR = Path(__file__).parent.parent sys.path.insert(0, str(ROOT_DIR)) @@ -37,8 +36,7 @@ def temp_log_dir(tmp_path): @pytest.fixture def simple_graph(): - """ - Crée un graphe NetworkX simple pour les tests. + """Crée un graphe NetworkX simple pour les tests. Structure: ProduitA (niveau 0) → ComposantB (niveau 1) → MineraiC (niveau 2) @@ -75,8 +73,7 @@ def simple_graph(): @pytest.fixture def complex_graph(): - """ - Crée un graphe plus complexe avec multiples chemins. + """Crée un graphe plus complexe avec multiples chemins. Structure: ProduitX → ComposantY → MineraiZ1 diff --git a/tests/unit/test_fiche_utils.py b/tests/unit/test_fiche_utils.py new file mode 100644 index 0000000..1085060 --- /dev/null +++ b/tests/unit/test_fiche_utils.py @@ -0,0 +1,476 @@ +"""Tests unitaires pour le module app.fiches.utils.fiche_utils. + +Ces tests verifient les fonctions utilitaires de gestion des fiches : +chargement de seuils, migration de metadonnees, rendu Markdown, +verification de fichiers recents et logique de regeneration. +""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from app.fiches.utils.fiche_utils import ( + _migrate_metadata, + doit_regenerer_fiche, + fichier_plus_recent, + load_seuils, + render_fiche_markdown, +) + +# ============================================================================= +# Tests pour load_seuils +# ============================================================================= + + +class TestLoadSeuils: + """Tests pour la fonction load_seuils.""" + + def test_chargement_fichier_valide(self, tmp_path): + """Test le chargement d'un fichier YAML valide avec des seuils.""" + contenu = { + "seuils": { + "ISG": {"vert": {"max": 40}, "rouge": {"min": 70}}, + } + } + fichier = tmp_path / "seuils.yaml" + fichier.write_text(yaml.dump(contenu), encoding="utf-8") + + resultat = load_seuils(fichier) + + assert "ISG" in resultat + assert resultat["ISG"]["vert"]["max"] == 40 + + def test_fichier_sans_cle_seuils(self, tmp_path): + """Test le chargement d'un fichier YAML sans la cle 'seuils'.""" + fichier = tmp_path / "vide.yaml" + fichier.write_text("autre_cle: valeur", encoding="utf-8") + + resultat = load_seuils(fichier) + + assert resultat == {} + + def test_fichier_inexistant_leve_erreur(self): + """Test qu'un fichier inexistant leve une exception.""" + with pytest.raises(FileNotFoundError): + load_seuils("/chemin/inexistant/seuils.yaml") + + +# ============================================================================= +# Tests pour _migrate_metadata +# ============================================================================= + + +class TestMigrateMetadata: + """Tests pour la fonction _migrate_metadata.""" + + def test_migration_sheet_type(self): + """Test la migration de sheet_type vers type_fiche.""" + meta = {"sheet_type": "minerai", "titre": "Test"} + resultat = _migrate_metadata(meta) + + assert "type_fiche" in resultat + assert resultat["type_fiche"] == "minerai" + assert "sheet_type" not in resultat + + def test_migration_indice_code(self): + """Test la migration de indice_code vers indice_court.""" + meta = {"indice_code": "IVC"} + resultat = _migrate_metadata(meta) + + assert "indice_court" in resultat + assert resultat["indice_court"] == "IVC" + assert "indice_code" not in resultat + + def test_pas_de_migration_si_nouvelle_cle_existe(self): + """Test que la migration n'ecrase pas une cle existante.""" + meta = {"sheet_type": "ancien", "type_fiche": "nouveau"} + resultat = _migrate_metadata(meta) + + # type_fiche existait deja, sheet_type est conserve + assert resultat["type_fiche"] == "nouveau" + assert "sheet_type" in resultat + + def test_aucune_migration_necessaire(self): + """Test avec des metadonnees deja normalisees.""" + meta = {"type_fiche": "composant", "titre": "Test"} + resultat = _migrate_metadata(meta) + + assert resultat == {"type_fiche": "composant", "titre": "Test"} + + def test_dict_vide(self): + """Test avec un dictionnaire vide.""" + assert _migrate_metadata({}) == {} + + +# ============================================================================= +# Tests pour render_fiche_markdown +# ============================================================================= + + +class TestRenderFicheMarkdown: + """Tests pour la fonction render_fiche_markdown.""" + + def test_rendu_basique_avec_placeholders(self, tmp_path): + """Test le rendu Markdown avec remplacement de placeholders.""" + licence = tmp_path / "licence.md" + licence.write_text("---\nLicence CC BY-SA\n---", encoding="utf-8") + + md_text = """--- +indice: Test +indice_court: TST +description: Un test +--- +## Description +{{ description }} +""" + seuils = {} + resultat = render_fiche_markdown(md_text, seuils, str(licence)) + + assert "# Test (TST)" in resultat + assert "Un test" in resultat + assert "Licence CC BY-SA" in resultat + + def test_rendu_sans_titre_h1_existant(self, tmp_path): + """Test que le titre h1 est ajoute quand absent.""" + licence = tmp_path / "licence.md" + licence.write_text("", encoding="utf-8") + + md_text = """--- +indice: Mon Indice +indice_court: MI +--- +## Section +Contenu +""" + resultat = render_fiche_markdown(md_text, {}, str(licence)) + + assert resultat.startswith("# Mon Indice (MI)") + + def test_rendu_avec_titre_h1_existant(self, tmp_path): + """Test que le titre h1 n'est pas duplique s'il existe deja.""" + licence = tmp_path / "licence.md" + licence.write_text("", encoding="utf-8") + + md_text = """--- +indice: Mon Indice +indice_court: MI +--- +# Titre existant +## Section +Contenu +""" + resultat = render_fiche_markdown(md_text, {}, str(licence)) + + # Le titre existant est conserve, pas de doublon + assert resultat.count("# Titre existant") == 1 + assert "# Mon Indice (MI)" not in resultat + + def test_licence_inseree_avant_premier_h2(self, tmp_path): + """Test que la licence est inseree avant le premier h2.""" + licence = tmp_path / "licence.md" + licence.write_text("LICENCE_MARKER", encoding="utf-8") + + md_text = """--- +indice: Test +indice_court: T +--- +Introduction + +## Section 1 + +Contenu +""" + resultat = render_fiche_markdown(md_text, {}, str(licence)) + + pos_licence = resultat.index("LICENCE_MARKER") + pos_h2 = resultat.index("## Section 1") + assert pos_licence < pos_h2 + + def test_licence_ajoutee_en_fin_sans_h2(self, tmp_path): + """Test que la licence est ajoutee a la fin s'il n'y a pas de h2.""" + licence = tmp_path / "licence.md" + licence.write_text("LICENCE_FIN", encoding="utf-8") + + md_text = """--- +indice: Test +indice_court: T +--- +Contenu sans aucun titre de niveau 2 +""" + resultat = render_fiche_markdown(md_text, {}, str(licence)) + + assert resultat.rstrip().endswith("LICENCE_FIN") + + def test_erreur_lecture_licence(self): + """Test que l'erreur de lecture de la licence est geree gracieusement.""" + mock_st = MagicMock() + with patch.dict("sys.modules", {"streamlit": mock_st}): + md_text = """--- +indice: Test +indice_court: T +--- +## Section +Contenu +""" + # Chemin de licence inexistant + resultat = render_fiche_markdown(md_text, {}, "/chemin/inexistant/licence.md") + + # Le rendu doit quand meme fonctionner + assert "Contenu" in resultat + # st.error doit avoir ete appele + mock_st.error.assert_called_once() + + def test_rendu_avec_seuils_jinja(self, tmp_path): + """Test que les seuils sont accessibles dans le template Jinja2.""" + licence = tmp_path / "licence.md" + licence.write_text("", encoding="utf-8") + + md_text = """--- +indice: Test +indice_court: T +--- +## Seuils +Vert max: {{ seuils.ISG.vert.max }} +""" + seuils = {"ISG": {"vert": {"max": 40}}} + resultat = render_fiche_markdown(md_text, seuils, str(licence)) + + assert "Vert max: 40" in resultat + + def test_rendu_titre_fallback_sur_titre(self, tmp_path): + """Test le fallback sur la cle 'titre' si 'indice' est absent.""" + licence = tmp_path / "licence.md" + licence.write_text("", encoding="utf-8") + + md_text = """--- +titre: Mon Titre +indice_court: MT +--- +## Section +Contenu +""" + resultat = render_fiche_markdown(md_text, {}, str(licence)) + + assert "# Mon Titre (MT)" in resultat + + +# ============================================================================= +# Tests pour fichier_plus_recent +# ============================================================================= + + +class TestFichierPlusRecent: + """Tests pour la fonction fichier_plus_recent.""" + + def test_fichier_plus_recent_que_reference(self, tmp_path): + """Test avec un fichier plus recent que la reference.""" + fichier = tmp_path / "recent.txt" + fichier.write_text("contenu", encoding="utf-8") + + # Reference bien dans le passe + reference = datetime(2020, 1, 1, tzinfo=timezone.utc) + + assert fichier_plus_recent(str(fichier), reference) is True + + def test_fichier_plus_ancien_que_reference(self, tmp_path): + """Test avec un fichier plus ancien que la reference.""" + fichier = tmp_path / "ancien.txt" + fichier.write_text("contenu", encoding="utf-8") + + # Reference bien dans le futur + reference = datetime(2099, 12, 31, tzinfo=timezone.utc) + + assert fichier_plus_recent(str(fichier), reference) is False + + def test_fichier_inexistant(self): + """Test avec un chemin vers un fichier inexistant.""" + assert fichier_plus_recent("/chemin/inexistant.txt", datetime.now(tz=timezone.utc)) is False + + def test_chemin_none(self): + """Test avec un chemin None.""" + assert fichier_plus_recent(None, datetime.now(tz=timezone.utc)) is False + + +# ============================================================================= +# Tests pour doit_regenerer_fiche +# ============================================================================= + + +def _ivc_recent(chemin, _ref): + """Retourne True si le chemin correspond au fichier IVC.""" + return chemin == "/chemin/ivc.csv" + + +def _ics_recent(chemin, _ref): + """Retourne True si le chemin correspond au fichier ICS.""" + return chemin == "/chemin/ics.csv" + + +class TestDoitRegenererFiche: + """Tests pour la fonction doit_regenerer_fiche.""" + + @patch("app.fiches.utils.fiche_utils.recuperer_date_dernier_commit") + def test_fichier_html_inexistant(self, mock_commit): + """Test que la regeneration est requise si le fichier HTML n'existe pas.""" + resultat = doit_regenerer_fiche( + "/chemin/inexistant.html", + "composant", + "Processeur", + "https://gitea.example.com/commits", + {}, + ) + assert resultat is True + # recuperer_date_dernier_commit ne doit pas etre appele + mock_commit.assert_not_called() + + @patch("app.fiches.utils.fiche_utils.recuperer_date_dernier_commit") + def test_commit_distant_plus_recent(self, mock_commit, tmp_path): + """Test la regeneration quand le commit distant est plus recent.""" + html = tmp_path / "fiche.html" + html.write_text("", encoding="utf-8") + + # Commit distant dans le futur + mock_commit.return_value = datetime(2099, 12, 31, tzinfo=timezone.utc) + + resultat = doit_regenerer_fiche( + str(html), "composant", "Processeur", + "https://gitea.example.com/commits", {}, + ) + assert resultat is True + + @patch("app.fiches.utils.fiche_utils.recuperer_date_dernier_commit") + def test_commit_distant_none(self, mock_commit, tmp_path): + """Test la regeneration quand remote_mtime est None (erreur reseau).""" + html = tmp_path / "fiche.html" + html.write_text("", encoding="utf-8") + + mock_commit.return_value = None + + resultat = doit_regenerer_fiche( + str(html), "composant", "Processeur", + "https://gitea.example.com/commits", {}, + ) + assert resultat is True + + @patch("app.fiches.utils.fiche_utils.fichier_plus_recent") + @patch("app.fiches.utils.fiche_utils.recuperer_date_dernier_commit") + def test_ihh_plus_recent(self, mock_commit, mock_recent, tmp_path): + """Test la regeneration quand le fichier IHH est plus recent.""" + html = tmp_path / "fiche.html" + html.write_text("", encoding="utf-8") + + # Commit distant plus ancien + mock_commit.return_value = datetime(2020, 1, 1, tzinfo=timezone.utc) + # IHH plus recent + mock_recent.return_value = True + + fichiers_criticite = {"IHH": "/chemin/ihh.csv"} + resultat = doit_regenerer_fiche( + str(html), "composant", "Processeur", + "https://gitea.example.com/commits", fichiers_criticite, + ) + assert resultat is True + + @patch("app.fiches.utils.fiche_utils.fichier_plus_recent") + @patch("app.fiches.utils.fiche_utils.recuperer_date_dernier_commit") + def test_minerai_ivc_plus_recent(self, mock_commit, mock_recent, tmp_path): + """Test la regeneration pour un minerai quand IVC est plus recent.""" + html = tmp_path / "fiche.html" + html.write_text("", encoding="utf-8") + + mock_commit.return_value = datetime(2020, 1, 1, tzinfo=timezone.utc) + # IHH pas plus recent, mais IVC oui + mock_recent.side_effect = _ivc_recent + + fichiers_criticite = {"IHH": "/chemin/ihh.csv", "IVC": "/chemin/ivc.csv"} + resultat = doit_regenerer_fiche( + str(html), "minerai", "Lithium", + "https://gitea.example.com/commits", fichiers_criticite, + ) + assert resultat is True + + @patch("app.fiches.utils.fiche_utils.fichier_plus_recent") + @patch("app.fiches.utils.fiche_utils.recuperer_date_dernier_commit") + def test_minerai_ics_plus_recent(self, mock_commit, mock_recent, tmp_path): + """Test la regeneration pour un minerai quand ICS est plus recent.""" + html = tmp_path / "fiche.html" + html.write_text("", encoding="utf-8") + + mock_commit.return_value = datetime(2020, 1, 1, tzinfo=timezone.utc) + # IHH et IVC pas plus recents, mais ICS oui + mock_recent.side_effect = _ics_recent + + fichiers_criticite = { + "IHH": "/chemin/ihh.csv", + "IVC": "/chemin/ivc.csv", + "ICS": "/chemin/ics.csv", + } + resultat = doit_regenerer_fiche( + str(html), "minerai", "Cobalt", + "https://gitea.example.com/commits", fichiers_criticite, + ) + assert resultat is True + + @patch("app.fiches.utils.fiche_utils.fichier_plus_recent") + @patch("app.fiches.utils.fiche_utils.recuperer_date_dernier_commit") + def test_minerai_dans_nom_fiche(self, mock_commit, mock_recent, tmp_path): + """Test la detection de 'minerai' dans le nom de la fiche choisie.""" + html = tmp_path / "fiche.html" + html.write_text("", encoding="utf-8") + + mock_commit.return_value = datetime(2020, 1, 1, tzinfo=timezone.utc) + # IHH pas recent, IVC oui + mock_recent.side_effect = _ivc_recent + + fichiers_criticite = {"IHH": "/chemin/ihh.csv", "IVC": "/chemin/ivc.csv"} + # fiche_type n'est pas "minerai" mais le nom contient "minerai" + resultat = doit_regenerer_fiche( + str(html), "autre", "Fiche_Minerai_Lithium", + "https://gitea.example.com/commits", fichiers_criticite, + ) + assert resultat is True + + @patch("app.fiches.utils.fiche_utils.fichier_plus_recent") + @patch("app.fiches.utils.fiche_utils.recuperer_date_dernier_commit") + def test_aucune_regeneration_necessaire(self, mock_commit, mock_recent, tmp_path): + """Test qu'aucune regeneration n'est requise si tout est a jour.""" + html = tmp_path / "fiche.html" + html.write_text("", encoding="utf-8") + + # Commit distant plus ancien que le fichier HTML + mock_commit.return_value = datetime(2020, 1, 1, tzinfo=timezone.utc) + # Aucun fichier de criticite plus recent + mock_recent.return_value = False + + fichiers_criticite = {"IHH": "/chemin/ihh.csv"} + resultat = doit_regenerer_fiche( + str(html), "composant", "Processeur", + "https://gitea.example.com/commits", fichiers_criticite, + ) + assert resultat is False + + @patch("app.fiches.utils.fiche_utils.fichier_plus_recent") + @patch("app.fiches.utils.fiche_utils.recuperer_date_dernier_commit") + def test_composant_ignore_ivc_ics(self, mock_commit, mock_recent, tmp_path): + """Test qu'un composant (non minerai) n'utilise pas IVC/ICS.""" + html = tmp_path / "fiche.html" + html.write_text("", encoding="utf-8") + + mock_commit.return_value = datetime(2020, 1, 1, tzinfo=timezone.utc) + # IHH pas recent + mock_recent.return_value = False + + fichiers_criticite = { + "IHH": "/chemin/ihh.csv", + "IVC": "/chemin/ivc.csv", + "ICS": "/chemin/ics.csv", + } + resultat = doit_regenerer_fiche( + str(html), "composant", "Processeur", + "https://gitea.example.com/commits", fichiers_criticite, + ) + assert resultat is False + # fichier_plus_recent ne doit etre appele qu'une fois (pour IHH) + assert mock_recent.call_count == 1 diff --git a/tests/unit/test_fiches_tickets.py b/tests/unit/test_fiches_tickets.py index b112608..b83f637 100644 --- a/tests/unit/test_fiches_tickets.py +++ b/tests/unit/test_fiches_tickets.py @@ -3,12 +3,8 @@ Ces tests vérifient les fonctions de gestion des tickets Gitea. """ -import os from unittest.mock import Mock, mock_open, patch -import pytest -import requests - from app.fiches.utils.tickets.core import ( charger_fiches_et_labels, construire_corps_ticket_markdown, @@ -66,8 +62,8 @@ Lithium,Extraction / Traitement,Minerai csv_file = assets_dir / "fiches_labels.csv" csv_file.write_text(csv_content, encoding="utf-8") - with patch("os.path.join", return_value=str(csv_file)): - with patch("builtins.open", mock_open(read_data=csv_content)): + with patch("os.path.join", return_value=str(csv_file)), \ + patch("builtins.open", mock_open(read_data=csv_content)): resultat = charger_fiches_et_labels() assert "Processeur" in resultat diff --git a/tests/unit/test_generer.py b/tests/unit/test_generer.py new file mode 100644 index 0000000..ac688a3 --- /dev/null +++ b/tests/unit/test_generer.py @@ -0,0 +1,548 @@ +"""Tests unitaires pour le module app.fiches.generer. + +Ces tests vérifient les fonctions de transformation Markdown/HTML : +- remplacer_latex_par_mathml +- markdown_to_html_rgaa +- rendu_html +- render_fiche_markdown (via app.fiches.utils.fiche_utils) +""" + +import re +from unittest.mock import patch + +from bs4 import BeautifulSoup + +from app.fiches.generer import ( + markdown_to_html_rgaa, + remplacer_latex_par_mathml, + rendu_html, +) +from app.fiches.utils.fiche_utils import render_fiche_markdown + + +# ────────────────────────────────────────────── +# remplacer_latex_par_mathml +# ────────────────────────────────────────────── +class TestRemplacerLatexParMathml: + """Tests pour la fonction remplacer_latex_par_mathml.""" + + def test_formule_inline_simple(self): + """Test la conversion d'une formule LaTeX inline simple.""" + texte = "La valeur est $x^2$ dans le calcul." + resultat = remplacer_latex_par_mathml(texte) + + assert "$" not in resultat + assert '' in resultat + assert "' in resultat + assert "' in resultat + assert "' in resultat + + def test_texte_sans_formule(self): + """Test qu'un texte sans LaTeX n'est pas modifie.""" + texte = "Un texte normal sans formule." + resultat = remplacer_latex_par_mathml(texte) + + assert resultat == texte + + def test_formules_multiples_inline(self): + """Test la conversion de plusieurs formules inline.""" + texte = "Soit $a$ et $b$ deux variables." + resultat = remplacer_latex_par_mathml(texte) + + assert resultat.count('') == 2 + assert "$" not in resultat + + def test_formule_display_et_inline_combinees(self): + """Test la combinaison de formules display et inline.""" + texte = "Inline $x$ et display :\n$$y = x + 1$$\nFin." + resultat = remplacer_latex_par_mathml(texte) + + assert '' in resultat + assert '
' in resultat + assert "$" not in resultat + + def test_formule_display_multilignes(self): + """Test une formule display sur plusieurs lignes.""" + texte = "$$\na^2 +\nb^2 = c^2\n$$" + resultat = remplacer_latex_par_mathml(texte) + + assert "$$" not in resultat + assert '
' in resultat + + def test_dollar_simple_non_latex(self): + """Test que le texte entre doubles dollars est traite en display et non en inline.""" + texte = "Le prix est de 100 dollars." + resultat = remplacer_latex_par_mathml(texte) + + # Pas de dollar LaTeX, pas de conversion + assert resultat == texte + + def test_formule_latex_invalide_inline(self): + """Test qu'une formule LaTeX invalide renvoie un message d'erreur inline.""" + texte = "Formule $\\invalid_command_xyz{}$ ici." + resultat = remplacer_latex_par_mathml(texte) + + # Soit converti, soit message d'erreur encapsule dans + assert "$" not in resultat or "Erreur LaTeX" in resultat + + def test_formule_latex_invalide_display(self): + """Test qu'une formule LaTeX display invalide renvoie un message d'erreur.""" + texte = "$$\\invalid_command_xyz{}$$" + resultat = remplacer_latex_par_mathml(texte) + + # Soit converti, soit message d'erreur encapsule dans
+        assert "$$" not in resultat or "Erreur LaTeX" in resultat
+
+    def test_indice_et_exposant(self):
+        """Test les indices et exposants LaTeX."""
+        texte = "La formule $x_i^2$ est simple."
+        resultat = remplacer_latex_par_mathml(texte)
+
+        assert '' in resultat
+        assert "" in resultat
+        assert "Un paragraphe simple." in resultat
+
+    def test_tableau_avec_caption(self):
+        """Test qu'un tableau reçoit un caption RGAA."""
+        md = "| Col1 | Col2 |\n| --- | --- |\n| A | B |"
+        resultat = markdown_to_html_rgaa(md, "Mon tableau")
+
+        soup = BeautifulSoup(resultat, "html.parser")
+        table = soup.find("table")
+
+        assert table is not None
+        assert table.get("role") == "table"
+        assert table.get("summary") == "Mon tableau"
+
+        caption = table.find("caption")
+        assert caption is not None
+        assert caption.string == "Mon tableau"
+
+    def test_tableau_scope_col_sur_th(self):
+        """Test que les en-tetes de tableau ont scope=col."""
+        md = "| Nom | Valeur |\n| --- | --- |\n| A | 1 |"
+        resultat = markdown_to_html_rgaa(md, "Donnees")
+
+        soup = BeautifulSoup(resultat, "html.parser")
+        for th in soup.find_all("th"):
+            assert th.get("scope") == "col"
+
+    def test_tableau_sans_caption(self):
+        """Test un tableau sans caption (caption_text=None)."""
+        md = "| X | Y |\n| --- | --- |\n| 1 | 2 |"
+        resultat = markdown_to_html_rgaa(md, None)
+
+        soup = BeautifulSoup(resultat, "html.parser")
+        table = soup.find("table")
+
+        assert table is not None
+        assert table.get("role") == "table"
+        # Pas de caption quand caption_text est None
+        caption = table.find("caption")
+        assert caption is None
+
+    def test_tableau_summary_avec_caption_none(self):
+        """Test que summary est vide quand caption_text est None."""
+        md = "| A | B |\n| --- | --- |\n| 1 | 2 |"
+        resultat = markdown_to_html_rgaa(md, None)
+
+        soup = BeautifulSoup(resultat, "html.parser")
+        table = soup.find("table")
+        assert table.get("summary") == ""
+
+    def test_titre_h2(self):
+        """Test la conversion d'un titre de niveau 2."""
+        md = "## Titre niveau 2"
+        resultat = markdown_to_html_rgaa(md, None)
+
+        assert "

" in resultat + assert "Titre niveau 2" in resultat + + def test_liste_a_puces(self): + """Test la conversion d'une liste a puces.""" + md = "- item 1\n- item 2\n- item 3" + resultat = markdown_to_html_rgaa(md, None) + + assert "
    " in resultat + assert "
  • " in resultat + assert resultat.count("
  • ") == 3 + + def test_texte_en_gras_et_italique(self): + """Test le formatage gras et italique.""" + md = "Du texte **gras** et *italique*." + resultat = markdown_to_html_rgaa(md, None) + + assert "" in resultat + assert "" in resultat + + def test_lien_html(self): + """Test la conversion d'un lien Markdown.""" + md = "[FabNum](https://fabnum.peccini.fr)" + resultat = markdown_to_html_rgaa(md, None) + + soup = BeautifulSoup(resultat, "html.parser") + lien = soup.find("a") + assert lien is not None + assert lien.get("href") == "https://fabnum.peccini.fr" + assert lien.string == "FabNum" + + def test_tableaux_multiples(self): + """Test avec plusieurs tableaux dans le meme contenu.""" + md = ( + "| A | B |\n| --- | --- |\n| 1 | 2 |\n\n" + "| C | D |\n| --- | --- |\n| 3 | 4 |" + ) + resultat = markdown_to_html_rgaa(md, "Donnees") + + soup = BeautifulSoup(resultat, "html.parser") + tables = soup.find_all("table") + + assert len(tables) == 2 + for table in tables: + assert table.get("role") == "table" + + def test_texte_vide(self): + """Test avec un texte vide.""" + resultat = markdown_to_html_rgaa("", None) + assert resultat == "" + + def test_contenu_mixte_texte_et_tableau(self): + """Test avec du texte et un tableau melanges.""" + md = "Paragraphe avant.\n\n| X | Y |\n| --- | --- |\n| 1 | 2 |\n\nParagraphe apres." + resultat = markdown_to_html_rgaa(md, "Tableau") + + assert "

    " in resultat + assert "" in html + + def test_titre_h1_genere(self): + """Test que le titre h1 est genere correctement.""" + md = "# Mon titre\n\nContenu." + resultat = rendu_html(md) + + html = "\n".join(resultat) + assert "" in html + assert "

    " in html + assert "Sous-section" in html + + def test_intro_avant_sections(self): + """Test que le texte d'introduction est place avant les sous-sections.""" + md = "# Titre\n\nIntroduction texte.\n\n## Section\n\nContenu." + resultat = rendu_html(md) + + html = "\n".join(resultat) + idx_intro = html.find("Introduction texte") + idx_details = html.find("
    ") + + assert idx_intro != -1 + assert idx_details != -1 + assert idx_intro < idx_details + + def test_sections_multiples_n2(self): + """Test le rendu de plusieurs sous-sections h2.""" + md = "# Titre\n\n## Section A\n\nContenu A.\n\n## Section B\n\nContenu B." + resultat = rendu_html(md) + + html = "\n".join(resultat) + assert html.count("
    ") == 2 + assert "Section A" in html + assert "Section B" in html + + def test_retour_type_liste(self): + """Test que la fonction retourne bien une liste de chaines.""" + md = "# Titre\n\nContenu." + resultat = rendu_html(md) + + assert isinstance(resultat, list) + assert all(isinstance(item, str) for item in resultat) + + def test_premier_element_est_section(self): + """Test que le premier element est la balise section ouvrante.""" + md = "# Titre\n\nContenu." + resultat = rendu_html(md) + + assert resultat[0].startswith('
    " + + def test_section_h1_multiples(self): + """Test avec plusieurs titres h1 (sections de niveau 1).""" + md = "# Titre 1\n\nContenu 1.\n\n# Titre 2\n\nContenu 2." + resultat = rendu_html(md) + + html = "\n".join(resultat) + # Le premier h1 est dans la balise

    , les suivants en

    + assert "" in html + assert "Titre 2" in html + + def test_contenu_sans_titre_h1(self): + """Test avec un contenu sans titre h1 explicite.""" + md = "Juste du contenu sans titre." + resultat = rendu_html(md) + + html = "\n".join(resultat) + # Doit utiliser 'fiche' comme titre par defaut + assert '

    fiche

    ' in html + + def test_latex_dans_intro_converti(self): + """Test que le LaTeX dans l'introduction est converti en MathML.""" + md = "# Titre\n\nFormule : $x^2$ dans le texte." + resultat = rendu_html(md) + + html = "\n".join(resultat) + assert "$" not in html or " str: + """Construit un document Markdown avec frontmatter YAML.""" + lines = ["---"] + for k, v in meta.items(): + lines.append(f"{k}: {v}") + lines.append("---") + lines.append(body) + return "\n".join(lines) + + def test_titre_auto_insere(self, tmp_path): + """Test que le titre h1 est insere automatiquement si absent.""" + licence = tmp_path / "licence.md" + licence.write_text("Licence test", encoding="utf-8") + + md = self._make_md( + {"indice": "Mon Indice", "indice_court": "MI"}, + "Contenu de la fiche." + ) + resultat = render_fiche_markdown(md, {}, license_path=str(licence)) + + assert resultat.startswith("# Mon Indice (MI)") + + def test_titre_existant_non_duplique(self, tmp_path): + """Test que le titre n'est pas duplique s'il est deja present.""" + licence = tmp_path / "licence.md" + licence.write_text("Licence test", encoding="utf-8") + + md = self._make_md( + {"indice": "Mon Indice", "indice_court": "MI"}, + "# Titre existant\n\nContenu." + ) + resultat = render_fiche_markdown(md, {}, license_path=str(licence)) + + # Ne doit pas avoir deux titres h1 + h1_count = len(re.findall(r"^# ", resultat, re.M)) + assert h1_count == 1 + + def test_licence_inseree_avant_h2(self, tmp_path): + """Test que la licence est inseree avant le premier h2.""" + licence = tmp_path / "licence.md" + licence.write_text("LICENCE_MARKER", encoding="utf-8") + + md = self._make_md( + {"titre": "Fiche test"}, + "# Fiche test\n\n## Section 1\n\nContenu." + ) + resultat = render_fiche_markdown(md, {}, license_path=str(licence)) + + idx_licence = resultat.find("LICENCE_MARKER") + idx_h2 = resultat.find("## Section 1") + + assert idx_licence != -1 + assert idx_h2 != -1 + assert idx_licence < idx_h2 + + def test_licence_en_fin_sans_h2(self, tmp_path): + """Test que la licence est ajoutee a la fin s'il n'y a pas de h2.""" + licence = tmp_path / "licence.md" + licence.write_text("LICENCE_FIN", encoding="utf-8") + + md = self._make_md( + {"titre": "Fiche simple"}, + "# Fiche simple\n\nContenu sans sous-sections." + ) + resultat = render_fiche_markdown(md, {}, license_path=str(licence)) + + assert resultat.rstrip().endswith("LICENCE_FIN") + + def test_placeholders_jinja_remplaces(self, tmp_path): + """Test que les placeholders Jinja2 sont remplaces.""" + licence = tmp_path / "licence.md" + licence.write_text("", encoding="utf-8") + + md = self._make_md( + {"titre": "Test", "valeur": "42"}, + "# Test\n\nLa valeur est {{ valeur }}." + ) + resultat = render_fiche_markdown(md, {}, license_path=str(licence)) + + assert "42" in resultat + assert "{{ valeur }}" not in resultat + + def test_seuils_accessibles_dans_template(self, tmp_path): + """Test que les seuils sont accessibles dans le template Jinja2.""" + licence = tmp_path / "licence.md" + licence.write_text("", encoding="utf-8") + + seuils = {"ISG": {"vert": {"max": 40}}} + md = self._make_md( + {"titre": "Test"}, + "# Test\n\nSeuil ISG vert max = {{ seuils.ISG.vert.max }}." + ) + resultat = render_fiche_markdown(md, seuils, license_path=str(licence)) + + assert "40" in resultat + + @patch("streamlit.error") + def test_licence_manquante_ne_plante_pas(self, mock_st_error): + """Test que l'absence du fichier de licence ne provoque pas d'erreur.""" + md = self._make_md( + {"titre": "Test"}, + "# Test\n\nContenu." + ) + # Chemin inexistant pour la licence + resultat = render_fiche_markdown(md, {}, license_path="/inexistant/licence.md") + + # La fonction doit retourner un resultat meme sans licence + assert "Test" in resultat + assert "Contenu." in resultat + # streamlit.error doit avoir ete appele + mock_st_error.assert_called_once() + + def test_migration_metadata_sheet_type(self, tmp_path): + """Test la migration des anciennes cles YAML (sheet_type -> type_fiche).""" + licence = tmp_path / "licence.md" + licence.write_text("", encoding="utf-8") + + md = self._make_md( + {"sheet_type": "indice", "titre": "Test Migration"}, + "# Test Migration\n\nType = {{ type_fiche }}." + ) + resultat = render_fiche_markdown(md, {}, license_path=str(licence)) + + assert "indice" in resultat + + def test_retour_type_str(self, tmp_path): + """Test que la fonction retourne bien une chaine.""" + licence = tmp_path / "licence.md" + licence.write_text("", encoding="utf-8") + + md = self._make_md({"titre": "Test"}, "# Test\n\nContenu.") + resultat = render_fiche_markdown(md, {}, license_path=str(licence)) + + assert isinstance(resultat, str) diff --git a/tests/unit/test_gitea.py b/tests/unit/test_gitea.py index ddd6462..5a4ba4e 100644 --- a/tests/unit/test_gitea.py +++ b/tests/unit/test_gitea.py @@ -4,9 +4,8 @@ Ces tests vérifient les fonctions d'interaction avec l'API Gitea. """ import base64 -import os from datetime import datetime, timezone -from unittest.mock import MagicMock, Mock, mock_open, patch +from unittest.mock import Mock, mock_open, patch import pytest import requests @@ -113,13 +112,8 @@ class TestChargerInstructionsDepuisGitea: @patch("utils.gitea.requests.get") @patch("utils.gitea.recuperer_date_dernier_commit") - @patch("os.path.exists") - @patch("os.path.getmtime") - def test_telechargement_fichier_inexistant(self, mock_getmtime, mock_exists, mock_date_commit, mock_get): + def test_telechargement_fichier_inexistant(self, mock_date_commit, mock_get): """Test le téléchargement quand le fichier local n'existe pas.""" - # Fichier local n'existe pas - mock_exists.return_value = False - # Commit distant disponible mock_date_commit.return_value = datetime(2025, 1, 15, tzinfo=timezone.utc) @@ -132,31 +126,31 @@ class TestChargerInstructionsDepuisGitea: mock_response.json.return_value = {"content": contenu_base64} mock_get.return_value = mock_response - with patch("builtins.open", mock_open()) as mock_file: + m_open = mock_open() + with patch("pathlib.Path.exists", return_value=False), \ + patch("pathlib.Path.open", m_open): resultat = charger_instructions_depuis_gitea("Instructions.md") # Vérifie que le fichier a été écrit - mock_file.assert_called_once_with("Instructions.md", "w", encoding="utf-8") + m_open.assert_called_once_with("w", encoding="utf-8") assert resultat == contenu_md @patch("utils.gitea.requests.get") @patch("utils.gitea.recuperer_date_dernier_commit") - @patch("os.path.exists") - @patch("os.path.getmtime") - @patch("builtins.open", new_callable=mock_open, read_data="# Local Instructions") - def test_utilisation_cache_local_recent(self, mock_file, mock_getmtime, mock_exists, mock_date_commit, mock_get): + def test_utilisation_cache_local_recent(self, mock_date_commit, mock_get): """Test l'utilisation du cache local si plus récent.""" - # Fichier local existe - mock_exists.return_value = True - # Date fichier local plus récent local_time = datetime(2025, 1, 20, tzinfo=timezone.utc) - mock_getmtime.return_value = local_time.timestamp() + mock_stat = Mock() + mock_stat.st_mtime = local_time.timestamp() # Date commit distant plus ancien mock_date_commit.return_value = datetime(2025, 1, 15, tzinfo=timezone.utc) - resultat = charger_instructions_depuis_gitea("Instructions.md") + with patch("pathlib.Path.exists", return_value=True), \ + patch("pathlib.Path.stat", return_value=mock_stat), \ + patch("pathlib.Path.open", mock_open(read_data="# Local Instructions")): + resultat = charger_instructions_depuis_gitea("Instructions.md") # Doit lire le fichier local sans appeler l'API assert mock_get.call_count == 0 @@ -164,25 +158,24 @@ class TestChargerInstructionsDepuisGitea: @patch("utils.gitea.requests.get") @patch("utils.gitea.recuperer_date_dernier_commit") - def test_erreur_reseau_avec_cache(self, mock_date_commit, mock_get): + def test_erreur_reseau_avec_cache(self, mock_date_commit, _mock_get): """Test le fallback sur le cache en cas d'erreur réseau.""" mock_date_commit.side_effect = requests.RequestException("Network error") - with patch("os.path.exists", return_value=True): - with patch("builtins.open", mock_open(read_data="# Cached content")): - resultat = charger_instructions_depuis_gitea("Instructions.md") + with patch("pathlib.Path.exists", return_value=True), \ + patch("pathlib.Path.open", mock_open(read_data="# Cached content")): + resultat = charger_instructions_depuis_gitea("Instructions.md") - assert resultat == "# Cached content" + assert resultat == "# Cached content" @patch("utils.gitea.requests.get") @patch("utils.gitea.recuperer_date_dernier_commit") - @patch("os.path.exists") - def test_erreur_reseau_sans_cache(self, mock_exists, mock_date_commit, mock_get): + def test_erreur_reseau_sans_cache(self, mock_date_commit, _mock_get): """Test le retour None si erreur et pas de cache.""" - mock_exists.return_value = False mock_date_commit.side_effect = requests.RequestException("Network error") - resultat = charger_instructions_depuis_gitea("Instructions.md") + with patch("pathlib.Path.exists", return_value=False): + resultat = charger_instructions_depuis_gitea("Instructions.md") assert resultat is None @@ -192,13 +185,8 @@ class TestChargerSchemaDepuisGitea: @patch("utils.gitea.requests.get") @patch("utils.gitea.recuperer_date_dernier_commit") - @patch("os.path.exists") - @patch("os.path.getmtime") - def test_telechargement_schema_file(self, mock_getmtime, mock_exists, mock_date_commit, mock_get): + def test_telechargement_schema_file(self, mock_date_commit, mock_get): """Test le téléchargement d'un fichier schema depuis Gitea.""" - # Fichier local n'existe pas - mock_exists.return_value = False - # Commit distant disponible mock_date_commit.return_value = datetime(2025, 1, 15, tzinfo=timezone.utc) @@ -211,36 +199,37 @@ class TestChargerSchemaDepuisGitea: mock_response.json.return_value = {"content": contenu_base64} mock_get.return_value = mock_response - with patch("builtins.open", mock_open()) as mock_file: + m_open = mock_open() + with patch("pathlib.Path.exists", return_value=False), \ + patch("pathlib.Path.open", m_open): resultat = charger_schema_depuis_gitea("test_schema.txt") # Vérifie que le fichier a été écrit - assert mock_file.called + assert m_open.called assert resultat == "OK" @patch("utils.gitea.requests.get") @patch("utils.gitea.recuperer_date_dernier_commit") - @patch("os.path.exists") - @patch("os.path.getmtime") - @patch("builtins.open", new_callable=mock_open) - def test_cache_schema_file(self, mock_file, mock_getmtime, mock_exists, mock_date_commit, mock_get): + def test_cache_schema_file(self, mock_date_commit, mock_get): """Test l'utilisation du cache pour le fichier schema.""" - # Fichier local existe et plus récent - mock_exists.return_value = True + # Date fichier local plus récent local_time = datetime(2025, 1, 20, tzinfo=timezone.utc) - mock_getmtime.return_value = local_time.timestamp() + mock_stat = Mock() + mock_stat.st_mtime = local_time.timestamp() # Date commit distant plus ancien mock_date_commit.return_value = datetime(2025, 1, 15, tzinfo=timezone.utc) # Mock response pour le premier appel (get file info) - contenu_base64 = base64.b64encode("digraph G { cached }".encode("utf-8")).decode("utf-8") + contenu_base64 = base64.b64encode(b"digraph G { cached }").decode("utf-8") mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {"content": contenu_base64} mock_get.return_value = mock_response - resultat = charger_schema_depuis_gitea("test_schema.txt") + with patch("pathlib.Path.exists", return_value=True), \ + patch("pathlib.Path.stat", return_value=mock_stat): + resultat = charger_schema_depuis_gitea("test_schema.txt") # Doit retourner OK sans réécrire (fichier déjà à jour) assert resultat == "OK" diff --git a/tests/unit/test_graph_utils.py b/tests/unit/test_graph_utils.py index 73e3fcb..f0e634d 100644 --- a/tests/unit/test_graph_utils.py +++ b/tests/unit/test_graph_utils.py @@ -1,17 +1,23 @@ -""" -Tests unitaires pour le module utils.graph_utils. +"""Tests unitaires pour le module utils.graph_utils. Ces tests vérifient les fonctions d'extraction et de traitement des graphes. """ -import pytest +from unittest.mock import MagicMock, patch + import networkx as nx import pandas as pd +import pytest + from utils.graph_utils import ( + charger_graphe, + couleur_noeud, + determiner_couleur_par_seuil, extraire_chemins_depuis, extraire_chemins_vers, + load_seuils_config, recuperer_donnees, - recuperer_donnees_2 + recuperer_donnees_2, ) @@ -47,8 +53,8 @@ class TestExtraireCheminsDepuis: chemins = extraire_chemins_depuis(G, "A") - # Ne doit pas boucler infiniment - assert len(chemins) >= 0 + # Ne doit pas boucler infiniment, le résultat doit être une liste + assert isinstance(chemins, list) # Vérifier qu'aucun chemin ne contient de doublons for chemin in chemins: assert len(chemin) == len(set(chemin)) @@ -57,7 +63,7 @@ class TestExtraireCheminsDepuis: """Test avec un graphe vide.""" G = nx.DiGraph() - with pytest.raises(Exception): + with pytest.raises(nx.NetworkXError): extraire_chemins_depuis(G, "noeud_inexistant") @@ -70,7 +76,7 @@ class TestExtraireCheminsVers: # Doit trouver au moins un chemin depuis ProduitA (niveau 0) assert len(chemins) > 0 - assert all("Chine_geographique" == chemin[-1] for chemin in chemins) + assert all(chemin[-1] == "Chine_geographique" for chemin in chemins) def test_chemins_vers_avec_niveau_filtre(self, complex_graph): """Test que seuls les chemins avec le niveau demandé sont retournés.""" @@ -175,3 +181,675 @@ class TestRecupererDonnees2: assert donnees[0]["ivc"] == 50 assert donnees[0]["ihh_extraction"] == 40 assert donnees[0]["ihh_reserves"] == 60 + + def test_minerai_extraction_manquante(self): + """Test avec un minerai dont le nœud Extraction_ est manquant.""" + G = nx.DiGraph() + G.add_node("MineraiTest", niveau=2, ivc=50) + G.add_node("Reserves_MineraiTest", niveau=10, ihh_pays=60) + + minerais = ["MineraiTest"] + donnees = recuperer_donnees_2(G, minerais) + + # Le minerai doit être ignoré car Extraction_ manque + assert len(donnees) == 0 + + def test_minerai_reserves_manquante(self): + """Test avec un minerai dont le nœud Reserves_ est manquant.""" + G = nx.DiGraph() + G.add_node("MineraiTest", niveau=2, ivc=50) + G.add_node("Extraction_MineraiTest", niveau=10, ihh_pays=40) + + minerais = ["MineraiTest"] + donnees = recuperer_donnees_2(G, minerais) + + # Le minerai doit être ignoré car Reserves_ manque + assert len(donnees) == 0 + + def test_erreur_exception_dans_boucle(self): + """Test la gestion d'erreur dans la boucle de recuperer_donnees_2.""" + G = nx.DiGraph() + G.add_node("MineraiTest", niveau=2, ivc="invalide") + G.add_node("Extraction_MineraiTest", niveau=10, ihh_pays=40) + G.add_node("Reserves_MineraiTest", niveau=10, ihh_pays=60) + + minerais = ["MineraiTest"] + donnees = recuperer_donnees_2(G, minerais) + + # ivc="invalide" va provoquer une ValueError dans int(), capturée par le except + assert isinstance(donnees, list) + + +class TestRecupererDonneesIcsCalcul: + """Tests complémentaires pour le calcul ICS dans recuperer_donnees.""" + + def test_ics_calcul_avec_valeurs_non_vides(self): + """Test le calcul ICS quand des valeurs existent sur les arêtes.""" + G = nx.MultiDiGraph() + G.add_node("Traitement_Cuivre", ihh_pays=0, ihh_acteurs=0) + G.add_node("Cuivre", niveau=2) + G.add_node("FabA", niveau=1) + G.add_node("FabB", niveau=1) + G.add_edge("FabA", "Cuivre", ics=0.4) + G.add_edge("FabB", "Cuivre", ics=0.6) + + noeuds = ["Traitement_Cuivre"] + df = recuperer_donnees(G, noeuds) + + assert not df.empty + # ICS moyen = (40 + 60) / 2 = 50 + assert df.iloc[0]["ics_minerai"] == 50 + assert df.iloc[0]["ics_cat"] == 2 # 50 > 33 et 50 <= 66 + + def test_ics_cat_faible(self): + """Test la catégorisation ICS pour une valeur faible (<=33).""" + G = nx.MultiDiGraph() + G.add_node("Traitement_Nickel", ihh_pays=10, ihh_acteurs=5) + G.add_node("Nickel", niveau=2) + G.add_node("Fab1", niveau=1) + G.add_edge("Fab1", "Nickel", ics=0.2) + + noeuds = ["Traitement_Nickel"] + df = recuperer_donnees(G, noeuds) + + assert not df.empty + # ICS = 20, catégorie 1 (<=33) + assert df.iloc[0]["ics_minerai"] == 20 + assert df.iloc[0]["ics_cat"] == 1 + + def test_ics_cat_elevee(self): + """Test la catégorisation ICS pour une valeur élevée (>66).""" + G = nx.MultiDiGraph() + G.add_node("Traitement_Or", ihh_pays=80, ihh_acteurs=60) + G.add_node("Or", niveau=2) + G.add_node("Fab1", niveau=1) + G.add_edge("Fab1", "Or", ics=0.9) + + noeuds = ["Traitement_Or"] + df = recuperer_donnees(G, noeuds) + + assert not df.empty + # ICS = 90, catégorie 3 (>66) + assert df.iloc[0]["ics_minerai"] == 90 + assert df.iloc[0]["ics_cat"] == 3 + + def test_ics_sans_predecesseurs(self): + """Test le calcul ICS quand il n'y a pas de prédécesseurs pour le minerai.""" + G = nx.MultiDiGraph() + G.add_node("Traitement_Fer", ihh_pays=30, ihh_acteurs=20) + G.add_node("Fer", niveau=2) + # Pas de prédécesseurs pour Fer + + noeuds = ["Traitement_Fer"] + df = recuperer_donnees(G, noeuds) + + assert not df.empty + # ICS par défaut = 50 (pas de valeurs calculées) + assert df.iloc[0]["ics_minerai"] == 50 + + def test_ics_exception_criticite(self): + """Test la gestion d'erreur lors du calcul de criticité.""" + G = nx.MultiDiGraph() + G.add_node("Traitement_Zinc", ihh_pays=25, ihh_acteurs=15) + # Le nœud Zinc n'existe PAS dans le graphe, donc graph.predecessors() va échouer + # Mais le minerai est quand même extrait dans la 2ème boucle + + noeuds = ["Traitement_Zinc"] + df = recuperer_donnees(G, noeuds) + + # L'exception est attrapée, ics par défaut = 50 + assert isinstance(df, pd.DataFrame) + + +class TestLoadSeuilsConfig: + """Tests pour la fonction load_seuils_config.""" + + def test_chargement_fichier_valide(self, sample_config_yaml): + """Test le chargement d'un fichier YAML valide.""" + seuils = load_seuils_config(str(sample_config_yaml)) + + assert "ISG" in seuils + assert "IHH" in seuils + assert "IVC" in seuils + assert seuils["ISG"]["vert"]["max"] == 40 + assert seuils["IHH"]["rouge"]["min"] == 25 + + def test_chargement_fichier_inexistant(self): + """Test le fallback quand le fichier n'existe pas.""" + seuils = load_seuils_config("/chemin/inexistant/config.yaml") + + # Doit retourner les valeurs par défaut + assert "ISG" in seuils + assert "IHH" in seuils + assert "IVC" in seuils + assert seuils["ISG"]["vert"]["max"] == 40 + assert seuils["IHH"]["vert"]["max"] == 15 + assert seuils["IVC"]["rouge"]["min"] == 60 + + def test_chargement_fichier_yaml_invalide(self, tmp_path): + """Test le fallback quand le fichier YAML est mal formé.""" + bad_yaml = tmp_path / "bad_config.yaml" + bad_yaml.write_text("{{{{invalide yaml!!!!}", encoding="utf-8") + + seuils = load_seuils_config(str(bad_yaml)) + + # Doit retourner les valeurs par défaut + assert "ISG" in seuils + assert "IHH" in seuils + + def test_chargement_fichier_sans_cle_seuils(self, tmp_path): + """Test avec un YAML valide mais sans la clé 'seuils'.""" + yaml_sans_seuils = tmp_path / "no_seuils.yaml" + yaml_sans_seuils.write_text("autre_cle: valeur\n", encoding="utf-8") + + seuils = load_seuils_config(str(yaml_sans_seuils)) + + # data.get("seuils", {}) retourne {} car la clé n'existe pas + assert seuils == {} + + +class TestDeterminerCouleurParSeuil: + """Tests pour la fonction determiner_couleur_par_seuil.""" + + @pytest.fixture + def seuils_isg(self): + """Seuils ISG standards pour les tests.""" + return { + "vert": {"max": 40}, + "orange": {"min": 40, "max": 70}, + "rouge": {"min": 70} + } + + @pytest.fixture + def seuils_ihh(self): + """Seuils IHH standards pour les tests.""" + return { + "vert": {"max": 15}, + "orange": {"min": 15, "max": 25}, + "rouge": {"min": 25} + } + + def test_valeur_negative_retourne_gris(self, seuils_isg): + """Test qu'une valeur négative retourne 'gray'.""" + assert determiner_couleur_par_seuil(-1, seuils_isg) == "gray" + assert determiner_couleur_par_seuil(-100, seuils_isg) == "gray" + + def test_valeur_zone_verte(self, seuils_isg): + """Test qu'une valeur dans la zone verte retourne 'darkgreen'.""" + assert determiner_couleur_par_seuil(0, seuils_isg) == "darkgreen" + assert determiner_couleur_par_seuil(20, seuils_isg) == "darkgreen" + assert determiner_couleur_par_seuil(39, seuils_isg) == "darkgreen" + + def test_valeur_zone_orange(self, seuils_isg): + """Test qu'une valeur dans la zone orange retourne 'orange'.""" + assert determiner_couleur_par_seuil(40, seuils_isg) == "orange" + assert determiner_couleur_par_seuil(55, seuils_isg) == "orange" + assert determiner_couleur_par_seuil(69, seuils_isg) == "orange" + + def test_valeur_zone_rouge(self, seuils_isg): + """Test qu'une valeur dans la zone rouge retourne 'darkred'.""" + assert determiner_couleur_par_seuil(70, seuils_isg) == "darkred" + assert determiner_couleur_par_seuil(100, seuils_isg) == "darkred" + + def test_valeur_limite_vert_orange(self, seuils_ihh): + """Test la limite exacte entre vert et orange.""" + # Valeur 14 juste sous le seuil orange, donc vert + assert determiner_couleur_par_seuil(14, seuils_ihh) == "darkgreen" + # Valeur 15 au seuil orange, donc orange + assert determiner_couleur_par_seuil(15, seuils_ihh) == "orange" + + def test_valeur_limite_orange_rouge(self, seuils_ihh): + """Test la limite exacte entre orange et rouge.""" + # Valeur 24 juste sous le seuil rouge, donc orange + assert determiner_couleur_par_seuil(24, seuils_ihh) == "orange" + # Valeur 25 au seuil rouge, donc rouge + assert determiner_couleur_par_seuil(25, seuils_ihh) == "darkred" + + def test_seuils_incomplets_sans_vert(self): + """Test avec des seuils sans la clé 'vert'.""" + seuils = { + "orange": {"min": 40, "max": 70}, + "rouge": {"min": 70} + } + # Valeur en zone rouge + assert determiner_couleur_par_seuil(80, seuils) == "darkred" + # Valeur en zone orange + assert determiner_couleur_par_seuil(50, seuils) == "orange" + # Valeur basse : pas de vert défini, tombe dans le défaut orange + assert determiner_couleur_par_seuil(10, seuils) == "orange" + + def test_seuils_incomplets_sans_rouge(self): + """Test avec des seuils sans la clé 'rouge'.""" + seuils = { + "vert": {"max": 40}, + "orange": {"min": 40, "max": 70}, + } + assert determiner_couleur_par_seuil(10, seuils) == "darkgreen" + assert determiner_couleur_par_seuil(50, seuils) == "orange" + # Valeur haute sans seuil rouge : tombe dans le défaut orange + assert determiner_couleur_par_seuil(80, seuils) == "orange" + + def test_seuils_vides(self): + """Test avec un dictionnaire de seuils vide.""" + assert determiner_couleur_par_seuil(50, {}) == "orange" + assert determiner_couleur_par_seuil(0, {}) == "orange" + + def test_seuils_sans_min_dans_orange(self): + """Test avec 'orange' présent mais sans clé 'min'.""" + seuils = { + "vert": {"max": 40}, + "orange": {"max": 70}, + "rouge": {"min": 70} + } + # La condition orange a besoin de min ET max, donc skip orange + # Valeur 50 : pas rouge (sous 70), pas orange (manque min), pas vert (au dessus de 40) + # Tombe dans le défaut orange + assert determiner_couleur_par_seuil(50, seuils) == "orange" + # Valeur 10 : sous le seuil vert (40), donc darkgreen + assert determiner_couleur_par_seuil(10, seuils) == "darkgreen" + + def test_valeur_zero(self, seuils_isg): + """Test avec la valeur zéro.""" + assert determiner_couleur_par_seuil(0, seuils_isg) == "darkgreen" + + +class TestCouleurNoeud: + """Tests pour la fonction couleur_noeud.""" + + @pytest.fixture + def config_yaml(self): + """Retourne les seuils de configuration pour les tests.""" + return { + "ISG": {"vert": {"max": 40}, "orange": {"min": 40, "max": 70}, "rouge": {"min": 70}}, + "IHH": {"vert": {"max": 15}, "orange": {"min": 15, "max": 25}, "rouge": {"min": 25}}, + "IVC": {"vert": {"max": 15}, "orange": {"min": 15, "max": 60}, "rouge": {"min": 60}} + } + + def test_pays_geographique_isg_vert(self, config_yaml): + """Test couleur d'un pays géographique avec ISG faible (vert).""" + G = nx.DiGraph() + G.add_node("Australie_geographique", niveau=99, isg=25) + niveaux = {"Australie_geographique": 99} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Australie_geographique", niveaux, G) + + assert couleur == "darkgreen" + + def test_pays_geographique_isg_orange(self, config_yaml): + """Test couleur d'un pays géographique avec ISG moyen (orange).""" + G = nx.DiGraph() + G.add_node("Chine_geographique", niveau=99, isg=54) + niveaux = {"Chine_geographique": 99} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Chine_geographique", niveaux, G) + + assert couleur == "orange" + + def test_pays_geographique_isg_rouge(self, config_yaml): + """Test couleur d'un pays géographique avec ISG élevé (rouge).""" + G = nx.DiGraph() + G.add_node("RDC_geographique", niveau=99, isg=85) + niveaux = {"RDC_geographique": 99} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("RDC_geographique", niveaux, G) + + assert couleur == "darkred" + + def test_pays_geographique_sans_isg(self, config_yaml): + """Test couleur d'un pays géographique sans attribut ISG.""" + G = nx.DiGraph() + G.add_node("Inconnu_geographique", niveau=99) + niveaux = {"Inconnu_geographique": 99} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Inconnu_geographique", niveaux, G) + + # isg = -1 par défaut, donc gray + assert couleur == "gray" + + def test_pays_operation_niveau_11_connecte_pays(self, config_yaml): + """Test couleur d'un nœud niveau 11 connecté à un pays géographique.""" + G = nx.DiGraph() + G.add_node("Chine_Fabrication_Batterie", niveau=11) + G.add_node("Chine_geographique", niveau=99, isg=54) + G.add_edge("Chine_Fabrication_Batterie", "Chine_geographique") + niveaux = {"Chine_Fabrication_Batterie": 11, "Chine_geographique": 99} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Chine_Fabrication_Batterie", niveaux, G) + + assert couleur == "orange" + + def test_pays_operation_niveau_12(self, config_yaml): + """Test couleur d'un nœud niveau 12 connecté à un pays géographique.""" + G = nx.DiGraph() + G.add_node("Australie_Reserves_Lithium", niveau=12) + G.add_node("Australie_geographique", niveau=99, isg=25) + G.add_edge("Australie_Reserves_Lithium", "Australie_geographique") + niveaux = {"Australie_Reserves_Lithium": 12, "Australie_geographique": 99} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Australie_Reserves_Lithium", niveaux, G) + + assert couleur == "darkgreen" + + def test_pays_operation_niveau_1011(self, config_yaml): + """Test couleur d'un nœud niveau 1011 connecté à un pays géographique.""" + G = nx.DiGraph() + G.add_node("Noeud_1011", niveau=1011) + G.add_node("Pays_geographique", niveau=99, isg=80) + G.add_edge("Noeud_1011", "Pays_geographique") + niveaux = {"Noeud_1011": 1011, "Pays_geographique": 99} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Noeud_1011", niveaux, G) + + assert couleur == "darkred" + + def test_pays_operation_niveau_1012(self, config_yaml): + """Test couleur d'un nœud niveau 1012 connecté à un pays géographique.""" + G = nx.DiGraph() + G.add_node("Noeud_1012", niveau=1012) + G.add_node("Pays_geo", niveau=99, isg=10) + G.add_edge("Noeud_1012", "Pays_geo") + niveaux = {"Noeud_1012": 1012, "Pays_geo": 99} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Noeud_1012", niveaux, G) + + assert couleur == "darkgreen" + + def test_pays_operation_niveau_11_sans_pays_geo(self, config_yaml): + """Test couleur d'un nœud niveau 11 sans successeur pays géographique.""" + G = nx.DiGraph() + G.add_node("Chine_Fabrication", niveau=11) + G.add_node("Autre_Noeud", niveau=10) + G.add_edge("Chine_Fabrication", "Autre_Noeud") + niveaux = {"Chine_Fabrication": 11, "Autre_Noeud": 10} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Chine_Fabrication", niveaux, G) + + # Pas de successeur niveau 99, tombe dans lightblue + assert couleur == "lightblue" + + def test_pays_operation_niveau_11_pays_geo_sans_isg(self, config_yaml): + """Test couleur d'un nœud niveau 11 connecté à un pays sans ISG.""" + G = nx.DiGraph() + G.add_node("Noeud_11", niveau=11) + G.add_node("Pays_geo", niveau=99) # Pas d'attribut isg + G.add_edge("Noeud_11", "Pays_geo") + niveaux = {"Noeud_11": 11, "Pays_geo": 99} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Noeud_11", niveaux, G) + + # isg = -1, donc gray + assert couleur == "gray" + + def test_operation_niveau_10_ihh_vert(self, config_yaml): + """Test couleur d'une opération niveau 10 avec IHH faible.""" + G = nx.DiGraph() + G.add_node("Fabrication_Test", niveau=10, ihh_pays=10) + niveaux = {"Fabrication_Test": 10} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Fabrication_Test", niveaux, G) + + assert couleur == "darkgreen" + + def test_operation_niveau_10_ihh_orange(self, config_yaml): + """Test couleur d'une opération niveau 10 avec IHH moyen.""" + G = nx.DiGraph() + G.add_node("Fabrication_Test", niveau=10, ihh_pays=20) + niveaux = {"Fabrication_Test": 10} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Fabrication_Test", niveaux, G) + + assert couleur == "orange" + + def test_operation_niveau_10_ihh_rouge(self, config_yaml): + """Test couleur d'une opération niveau 10 avec IHH élevé.""" + G = nx.DiGraph() + G.add_node("Fabrication_Test", niveau=10, ihh_pays=30) + niveaux = {"Fabrication_Test": 10} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Fabrication_Test", niveaux, G) + + assert couleur == "darkred" + + def test_operation_niveau_1010(self, config_yaml): + """Test couleur d'une opération niveau 1010 avec IHH.""" + G = nx.DiGraph() + G.add_node("Operation_1010", niveau=1010, ihh_pays=20) + niveaux = {"Operation_1010": 1010} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Operation_1010", niveaux, G) + + assert couleur == "orange" + + def test_operation_niveau_10_sans_ihh(self, config_yaml): + """Test couleur d'une opération niveau 10 sans attribut ihh_pays.""" + G = nx.DiGraph() + G.add_node("Fabrication_Test", niveau=10) + niveaux = {"Fabrication_Test": 10} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Fabrication_Test", niveaux, G) + + # Pas d'ihh_pays, tombe dans lightblue + assert couleur == "lightblue" + + def test_minerai_niveau_2_ivc_vert(self, config_yaml): + """Test couleur d'un minerai niveau 2 avec IVC faible.""" + G = nx.DiGraph() + G.add_node("Lithium", niveau=2, ivc=10) + niveaux = {"Lithium": 2} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Lithium", niveaux, G) + + assert couleur == "darkgreen" + + def test_minerai_niveau_2_ivc_orange(self, config_yaml): + """Test couleur d'un minerai niveau 2 avec IVC moyen.""" + G = nx.DiGraph() + G.add_node("Cobalt", niveau=2, ivc=30) + niveaux = {"Cobalt": 2} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Cobalt", niveaux, G) + + assert couleur == "orange" + + def test_minerai_niveau_2_ivc_rouge(self, config_yaml): + """Test couleur d'un minerai niveau 2 avec IVC élevé.""" + G = nx.DiGraph() + G.add_node("Indium", niveau=2, ivc=65) + niveaux = {"Indium": 2} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("Indium", niveaux, G) + + assert couleur == "darkred" + + def test_minerai_niveau_2_sans_ivc(self, config_yaml): + """Test couleur d'un minerai niveau 2 sans attribut IVC.""" + G = nx.DiGraph() + G.add_node("MineraiSansIvc", niveau=2) + niveaux = {"MineraiSansIvc": 2} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("MineraiSansIvc", niveaux, G) + + # Pas d'ivc, tombe dans lightblue + assert couleur == "lightblue" + + def test_noeud_sans_niveau_connu(self, config_yaml): + """Test couleur d'un nœud avec un niveau inconnu.""" + G = nx.DiGraph() + G.add_node("NoeudInconnu", niveau=5) + niveaux = {"NoeudInconnu": 5} + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("NoeudInconnu", niveaux, G) + + assert couleur == "lightblue" + + def test_noeud_absent_du_dictionnaire_niveaux(self, config_yaml): + """Test couleur d'un nœud absent du dictionnaire des niveaux.""" + G = nx.DiGraph() + G.add_node("NoeudOrphelin", isg=50) + niveaux = {} # Pas de niveau défini + + with patch("utils.graph_utils.load_seuils_config", return_value=config_yaml): + couleur = couleur_noeud("NoeudOrphelin", niveaux, G) + + # niveau par défaut = 99, isg=50 -> orange + assert couleur == "orange" + + def test_operation_niveau_10_ihh_fallback_sans_cle_ihh(self): + """Test le fallback IHH quand la clé IHH n'est pas dans les seuils.""" + G = nx.DiGraph() + G.add_node("Fabrication_Test", niveau=10, ihh_pays=20) + niveaux = {"Fabrication_Test": 10} + + seuils_sans_ihh = { + "ISG": {"vert": {"max": 40}, "orange": {"min": 40, "max": 70}, "rouge": {"min": 70}}, + "IVC": {"vert": {"max": 15}, "orange": {"min": 15, "max": 60}, "rouge": {"min": 60}} + } + + with patch("utils.graph_utils.load_seuils_config", return_value=seuils_sans_ihh): + couleur = couleur_noeud("Fabrication_Test", niveaux, G) + + # Fallback: 20 <= 25 -> orange + assert couleur == "orange" + + def test_operation_niveau_10_ihh_fallback_vert(self): + """Test le fallback IHH pour une valeur faible (<=15).""" + G = nx.DiGraph() + G.add_node("Fabrication_Test", niveau=10, ihh_pays=10) + niveaux = {"Fabrication_Test": 10} + + with patch("utils.graph_utils.load_seuils_config", return_value={}): + couleur = couleur_noeud("Fabrication_Test", niveaux, G) + + assert couleur == "darkgreen" + + def test_operation_niveau_10_ihh_fallback_rouge(self): + """Test le fallback IHH pour une valeur élevée (>25).""" + G = nx.DiGraph() + G.add_node("Fabrication_Test", niveau=10, ihh_pays=30) + niveaux = {"Fabrication_Test": 10} + + with patch("utils.graph_utils.load_seuils_config", return_value={}): + couleur = couleur_noeud("Fabrication_Test", niveaux, G) + + assert couleur == "darkred" + + def test_minerai_niveau_2_ivc_fallback_sans_cle_ivc(self): + """Test le fallback IVC quand la clé IVC n'est pas dans les seuils.""" + G = nx.DiGraph() + G.add_node("Lithium", niveau=2, ivc=20) + niveaux = {"Lithium": 2} + + seuils_sans_ivc = { + "ISG": {"vert": {"max": 40}, "orange": {"min": 40, "max": 70}, "rouge": {"min": 70}}, + } + + with patch("utils.graph_utils.load_seuils_config", return_value=seuils_sans_ivc): + couleur = couleur_noeud("Lithium", niveaux, G) + + # Fallback IVC: 20 > 15 et <= 30 -> orange + assert couleur == "orange" + + def test_minerai_niveau_2_ivc_fallback_vert(self): + """Test le fallback IVC pour une valeur faible (<=15).""" + G = nx.DiGraph() + G.add_node("Lithium", niveau=2, ivc=10) + niveaux = {"Lithium": 2} + + with patch("utils.graph_utils.load_seuils_config", return_value={}): + couleur = couleur_noeud("Lithium", niveaux, G) + + assert couleur == "darkgreen" + + def test_minerai_niveau_2_ivc_fallback_rouge(self): + """Test le fallback IVC pour une valeur élevée (>30).""" + G = nx.DiGraph() + G.add_node("Lithium", niveau=2, ivc=50) + niveaux = {"Lithium": 2} + + with patch("utils.graph_utils.load_seuils_config", return_value={}): + couleur = couleur_noeud("Lithium", niveaux, G) + + assert couleur == "darkred" + + +class TestChargerGraphe: + """Tests pour la fonction charger_graphe.""" + + def test_charger_graphe_deja_en_session(self): + """Test que le graphe n'est pas rechargé s'il est déjà en session.""" + mock_graph = nx.DiGraph() + mock_session = {"G_temp": mock_graph, "G_temp_ivc": mock_graph.copy()} + + with patch("utils.graph_utils.st") as mock_st: + mock_st.session_state = mock_session + resultat = charger_graphe() + + assert resultat is True + + def test_charger_graphe_succes(self, tmp_path): + """Test le chargement réussi d'un graphe DOT.""" + dot_file = tmp_path / "test.dot" + dot_file.write_text('digraph { A -> B; }', encoding="utf-8") + + mock_session = {} + mock_graph = nx.MultiDiGraph() + mock_graph.add_edge("A", "B") + + with patch("utils.graph_utils.st") as mock_st, \ + patch("utils.graph_utils.charger_schema_depuis_gitea", return_value=True), \ + patch("utils.graph_utils.read_dot", return_value=mock_graph), \ + patch("utils.graph_utils.DOT_FILE", str(dot_file)): + mock_st.session_state = mock_session + resultat = charger_graphe() + + assert resultat is True + assert "G_temp" in mock_session + assert "G_temp_ivc" in mock_session + + def test_charger_graphe_gitea_echoue(self): + """Test quand le chargement depuis Gitea échoue.""" + mock_session = {} + + with patch("utils.graph_utils.st") as mock_st, \ + patch("utils.graph_utils.charger_schema_depuis_gitea", return_value=False): + mock_st.session_state = mock_session + mock_st.error = MagicMock() + resultat = charger_graphe() + + assert resultat is False + mock_st.error.assert_called() + + def test_charger_graphe_exception_lecture_dot(self): + """Test quand la lecture du fichier DOT lève une exception.""" + mock_session = {} + + with patch("utils.graph_utils.st") as mock_st, \ + patch("utils.graph_utils.charger_schema_depuis_gitea", return_value=True), \ + patch("utils.graph_utils.read_dot", side_effect=Exception("Fichier corrompu")): + mock_st.session_state = mock_session + mock_st.error = MagicMock() + resultat = charger_graphe() + + assert resultat is False + # Vérifie que st.error a été appelé au moins une fois + assert mock_st.error.call_count >= 1 diff --git a/tests/unit/test_ics.py b/tests/unit/test_ics.py new file mode 100644 index 0000000..b0ba0a3 --- /dev/null +++ b/tests/unit/test_ics.py @@ -0,0 +1,577 @@ +"""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 diff --git a/tests/unit/test_ihh.py b/tests/unit/test_ihh.py new file mode 100644 index 0000000..01b32a1 --- /dev/null +++ b/tests/unit/test_ihh.py @@ -0,0 +1,785 @@ +"""Tests unitaires pour le module app.fiches.utils.dynamic.indice.ihh. + +Ces tests verifient les fonctions de traitement des indices IHH : +- _extraire_donnees_operations : extraction et organisation des donnees +- _generer_tableau_produits : generation de tableau markdown produits +- _generer_tableau_composants : generation de tableau markdown composants +- _generer_tableau_minerais : generation de tableau markdown minerais +- _synth_ihh : synthese des tableaux IHH +- build_ihh_sections : construction des sections dynamiques markdown +""" + +from unittest.mock import patch + +import pytest + +from app.fiches.utils.dynamic.indice.ihh import ( + IHH_RE, + _extraire_donnees_operations, + _generer_tableau_composants, + _generer_tableau_minerais, + _generer_tableau_produits, + _synth_ihh, + build_ihh_sections, +) + +# ────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────── + +@pytest.fixture +def operation_minerai(): + """Operation type minerai avec extraction, reserves et traitement.""" + return { + "minerai": "Lithium", + "extraction": {"ihh_pays": 1500, "ihh_acteurs": 2000}, + "reserves": {"ihh_pays": 1800}, + "traitement": {"ihh_pays": 900, "ihh_acteurs": 1100}, + } + + +@pytest.fixture +def operation_produit(): + """Operation type produit avec assemblage.""" + return { + "produit": "Batterie", + "assemblage": {"ihh_pays": 3000, "ihh_acteurs": 2500}, + } + + +@pytest.fixture +def operation_composant(): + """Operation type composant avec fabrication.""" + return { + "composant": "Cathode", + "fabrication": {"ihh_pays": 1200, "ihh_acteurs": 800}, + } + + +@pytest.fixture +def mock_pastille(): + """Mock la fonction pastille pour retourner une valeur previsible.""" + with patch("app.fiches.utils.dynamic.indice.ihh.pastille", side_effect=lambda indice, valeur: f"[{indice}:{valeur}]") as m: + yield m + + +# ────────────────────────────────────────────── +# IHH_RE (regex) +# ────────────────────────────────────────────── + +class TestIhhRegex: + """Tests pour la regex IHH_RE.""" + + def test_match_bloc_yaml_simple(self): + """Test la detection d'un bloc YAML operation basique.""" + # Le pattern cherche "opération:" (avec accent) + texte = "```yaml\n op\u00e9ration:\n minerai: Lithium\n```" + matches = list(IHH_RE.finditer(texte)) + assert len(matches) == 1 + + def test_match_insensible_casse(self): + """Test que la regex est insensible a la casse du mot YAML.""" + texte = "```YAML\n op\u00e9ration:\n minerai: Cobalt\n```" + matches = list(IHH_RE.finditer(texte)) + assert len(matches) == 1 + + def test_pas_de_match_sans_operation(self): + """Test qu'un bloc YAML sans 'operation' n'est pas capture.""" + texte = "```yaml\n autre_cle: valeur\n```" + matches = list(IHH_RE.finditer(texte)) + assert len(matches) == 0 + + def test_match_multiple_blocs(self): + """Test la detection de plusieurs blocs YAML operation.""" + texte = ( + "Intro\n" + "```yaml\n op\u00e9ration:\n minerai: Lithium\n```\n" + "texte entre\n" + "```yaml\n op\u00e9ration:\n produit: Batterie\n```\n" + ) + matches = list(IHH_RE.finditer(texte)) + assert len(matches) == 2 + + +# ────────────────────────────────────────────── +# _extraire_donnees_operations +# ────────────────────────────────────────────── + +class TestExtraireDonneesOperations: + """Tests pour la fonction _extraire_donnees_operations.""" + + def test_operation_minerai(self, operation_minerai): + """Test l'extraction des donnees pour une operation minerai.""" + resultat = _extraire_donnees_operations([operation_minerai]) + + assert "Lithium" in resultat + data = resultat["Lithium"] + assert data["type"] == "minerai" + assert data["extraction_ihh_pays"] == 1500 + assert data["extraction_ihh_acteurs"] == 2000 + assert data["reserves_ihh_pays"] == 1800 + assert data["traitement_ihh_pays"] == 900 + assert data["traitement_ihh_acteurs"] == 1100 + + def test_operation_produit(self, operation_produit): + """Test l'extraction des donnees pour une operation produit.""" + resultat = _extraire_donnees_operations([operation_produit]) + + assert "Batterie" in resultat + data = resultat["Batterie"] + assert data["type"] == "produit" + assert data["assemblage_ihh_pays"] == 3000 + assert data["assemblage_ihh_acteurs"] == 2500 + + def test_operation_composant(self, operation_composant): + """Test l'extraction des donnees pour une operation composant.""" + resultat = _extraire_donnees_operations([operation_composant]) + + assert "Cathode" in resultat + data = resultat["Cathode"] + assert data["type"] == "composant" + assert data["fabrication_ihh_pays"] == 1200 + assert data["fabrication_ihh_acteurs"] == 800 + + def test_operations_multiples_types(self, operation_minerai, operation_produit, operation_composant): + """Test l'extraction avec des operations de types differents.""" + resultat = _extraire_donnees_operations([operation_minerai, operation_produit, operation_composant]) + + assert len(resultat) == 3 + assert resultat["Lithium"]["type"] == "minerai" + assert resultat["Batterie"]["type"] == "produit" + assert resultat["Cathode"]["type"] == "composant" + + def test_liste_vide(self): + """Test avec une liste d'operations vide.""" + resultat = _extraire_donnees_operations([]) + + assert resultat == {} + + def test_operation_sans_identifiant(self): + """Test qu'une operation sans minerai, produit ou composant est ignoree.""" + operation = {"autre_cle": "valeur"} + resultat = _extraire_donnees_operations([operation]) + + assert resultat == {} + + def test_operation_identifiant_vide(self): + """Test qu'une operation avec identifiant vide est ignoree.""" + operation = {"minerai": "", "extraction": {"ihh_pays": 100}} + resultat = _extraire_donnees_operations([operation]) + + assert resultat == {} + + def test_valeurs_par_defaut_minerai(self): + """Test que les valeurs par defaut sont '-' pour un nouveau minerai.""" + operation = {"minerai": "Cobalt", "extraction": {"ihh_pays": 500, "ihh_acteurs": 600}, "reserves": {"ihh_pays": 700}, "traitement": {"ihh_pays": 400, "ihh_acteurs": 300}} + resultat = _extraire_donnees_operations([operation]) + + data = resultat["Cobalt"] + # Les champs non lies a l'extraction doivent rester a '-' + assert data["assemblage_ihh_pays"] == "-" + assert data["assemblage_ihh_acteurs"] == "-" + assert data["fabrication_ihh_pays"] == "-" + assert data["fabrication_ihh_acteurs"] == "-" + + def test_valeurs_par_defaut_produit(self, operation_produit): + """Test que les valeurs par defaut sont '-' pour un nouveau produit.""" + resultat = _extraire_donnees_operations([operation_produit]) + + data = resultat["Batterie"] + assert data["extraction_ihh_pays"] == "-" + assert data["extraction_ihh_acteurs"] == "-" + assert data["reserves_ihh_pays"] == "-" + assert data["traitement_ihh_pays"] == "-" + assert data["traitement_ihh_acteurs"] == "-" + + def test_valeurs_par_defaut_composant(self, operation_composant): + """Test que les valeurs par defaut sont '-' pour un nouveau composant.""" + resultat = _extraire_donnees_operations([operation_composant]) + + data = resultat["Cathode"] + assert data["extraction_ihh_pays"] == "-" + assert data["assemblage_ihh_pays"] == "-" + + def test_detection_type_minerai_par_extraction(self): + """Test que le type 'minerai' est detecte par la cle 'extraction'.""" + operation = {"minerai": "Fer", "extraction": {"ihh_pays": 100}, "reserves": {}, "traitement": {}} + resultat = _extraire_donnees_operations([operation]) + + assert resultat["Fer"]["type"] == "minerai" + + def test_detection_type_minerai_par_reserves(self): + """Test que le type 'minerai' est detecte par la cle 'reserves'.""" + operation = {"minerai": "Cuivre", "reserves": {"ihh_pays": 200}} + resultat = _extraire_donnees_operations([operation]) + + assert resultat["Cuivre"]["type"] == "minerai" + + def test_detection_type_minerai_par_traitement(self): + """Test que le type 'minerai' est detecte par la cle 'traitement'.""" + operation = {"minerai": "Zinc", "traitement": {"ihh_pays": 300}} + resultat = _extraire_donnees_operations([operation]) + + assert resultat["Zinc"]["type"] == "minerai" + + def test_detection_type_produit_par_assemblage(self): + """Test que le type 'produit' est detecte par la cle 'assemblage'.""" + operation = {"produit": "Ecran", "assemblage": {"ihh_pays": 100}} + resultat = _extraire_donnees_operations([operation]) + + assert resultat["Ecran"]["type"] == "produit" + + def test_detection_type_composant_par_defaut(self): + """Test que le type 'composant' est attribue par defaut sans cles specifiques.""" + operation = {"composant": "Puce", "fabrication": {"ihh_pays": 500}} + resultat = _extraire_donnees_operations([operation]) + + assert resultat["Puce"]["type"] == "composant" + + def test_extraction_valeurs_manquantes_dans_sous_dict(self): + """Test avec des cles manquantes dans les sous-dictionnaires d'extraction.""" + operation = { + "minerai": "Titane", + "extraction": {}, # Pas de ihh_pays ni ihh_acteurs + "reserves": {}, + "traitement": {}, + } + resultat = _extraire_donnees_operations([operation]) + + data = resultat["Titane"] + assert data["extraction_ihh_pays"] == "-" + assert data["extraction_ihh_acteurs"] == "-" + assert data["reserves_ihh_pays"] == "-" + assert data["traitement_ihh_pays"] == "-" + assert data["traitement_ihh_acteurs"] == "-" + + def test_assemblage_valeurs_manquantes(self): + """Test avec des cles manquantes dans le sous-dictionnaire assemblage.""" + operation = {"produit": "Smartphone", "assemblage": {}} + resultat = _extraire_donnees_operations([operation]) + + data = resultat["Smartphone"] + assert data["assemblage_ihh_pays"] == "-" + assert data["assemblage_ihh_acteurs"] == "-" + + def test_fabrication_valeurs_manquantes(self): + """Test avec des cles manquantes dans le sous-dictionnaire fabrication.""" + operation = {"composant": "Resistance", "fabrication": {}} + resultat = _extraire_donnees_operations([operation]) + + data = resultat["Resistance"] + assert data["fabrication_ihh_pays"] == "-" + assert data["fabrication_ihh_acteurs"] == "-" + + def test_meme_minerai_deux_operations(self): + """Test que deux operations sur le meme minerai fusionnent les donnees.""" + op1 = {"minerai": "Lithium", "extraction": {"ihh_pays": 100, "ihh_acteurs": 200}, "reserves": {"ihh_pays": 300}, "traitement": {"ihh_pays": 400, "ihh_acteurs": 500}} + # Deuxieme operation avec fabrication sur le meme identifiant + # (cas improbable mais le code le gere) + op2 = {"minerai": "Lithium", "fabrication": {"ihh_pays": 600, "ihh_acteurs": 700}} + + resultat = _extraire_donnees_operations([op1, op2]) + + assert len(resultat) == 1 + data = resultat["Lithium"] + # Les donnees extraction de la premiere operation sont conservees + assert data["extraction_ihh_pays"] == 100 + # Les donnees fabrication de la seconde operation sont ajoutees + assert data["fabrication_ihh_pays"] == 600 + assert data["fabrication_ihh_acteurs"] == 700 + + def test_priorite_identifiant_minerai_sur_produit(self): + """Test que l'identifiant minerai est prioritaire sur produit et composant.""" + operation = {"minerai": "Fer", "produit": "Acier", "composant": "Plaque"} + resultat = _extraire_donnees_operations([operation]) + + # minerai est evalue en premier dans le get chain + assert "Fer" in resultat + assert "Acier" not in resultat + assert "Plaque" not in resultat + + def test_priorite_identifiant_produit_sur_composant(self): + """Test que l'identifiant produit est prioritaire sur composant.""" + operation = {"produit": "Acier", "composant": "Plaque"} + resultat = _extraire_donnees_operations([operation]) + + assert "Acier" in resultat + assert "Plaque" not in resultat + + +# ────────────────────────────────────────────── +# _generer_tableau_produits +# ────────────────────────────────────────────── + +class TestGenererTableauProduits: + """Tests pour la fonction _generer_tableau_produits.""" + + def test_dict_vide(self): + """Test qu'un dictionnaire vide retourne une chaine vide.""" + assert _generer_tableau_produits({}) == "" + + def test_un_produit(self, mock_pastille): + """Test la generation d'un tableau avec un seul produit.""" + produits = { + "Batterie": { + "type": "produit", + "assemblage_ihh_pays": 3000, + "assemblage_ihh_acteurs": 2500, + } + } + + resultat = _generer_tableau_produits(produits) + + assert "## Assemblage des produits" in resultat + assert "| Batterie |" in resultat + assert "| Produit | Assemblage IHH Pays | Assemblage IHH Acteurs |" in resultat + assert "| :-- | :--: | :--: |" in resultat + assert "[IHH:3000]" in resultat + assert "[IHH:2500]" in resultat + + def test_plusieurs_produits_tries(self, mock_pastille): + """Test que les produits sont tries par ordre alphabetique.""" + produits = { + "Smartphone": {"type": "produit", "assemblage_ihh_pays": 100, "assemblage_ihh_acteurs": 200}, + "Batterie": {"type": "produit", "assemblage_ihh_pays": 300, "assemblage_ihh_acteurs": 400}, + "Ecran": {"type": "produit", "assemblage_ihh_pays": 500, "assemblage_ihh_acteurs": 600}, + } + + resultat = _generer_tableau_produits(produits) + + # Verifier l'ordre : Batterie < Ecran < Smartphone + idx_batterie = resultat.index("Batterie") + idx_ecran = resultat.index("Ecran") + idx_smartphone = resultat.index("Smartphone") + assert idx_batterie < idx_ecran < idx_smartphone + + def test_valeur_tiret(self, mock_pastille): + """Test avec des valeurs '-' (donnees manquantes).""" + produits = { + "Produit": {"type": "produit", "assemblage_ihh_pays": "-", "assemblage_ihh_acteurs": "-"}, + } + + resultat = _generer_tableau_produits(produits) + + assert "[IHH:-]" in resultat + + +# ────────────────────────────────────────────── +# _generer_tableau_composants +# ────────────────────────────────────────────── + +class TestGenererTableauComposants: + """Tests pour la fonction _generer_tableau_composants.""" + + def test_dict_vide(self): + """Test qu'un dictionnaire vide retourne une chaine vide.""" + assert _generer_tableau_composants({}) == "" + + def test_un_composant(self, mock_pastille): + """Test la generation d'un tableau avec un seul composant.""" + composants = { + "Cathode": { + "type": "composant", + "fabrication_ihh_pays": 1200, + "fabrication_ihh_acteurs": 800, + } + } + + resultat = _generer_tableau_composants(composants) + + assert "## Fabrication des composants" in resultat + assert "| Cathode |" in resultat + assert "| Composant | Fabrication IHH Pays | Fabrication IHH Acteurs |" in resultat + assert "| :-- | :--: | :--: |" in resultat + assert "[IHH:1200]" in resultat + assert "[IHH:800]" in resultat + + def test_plusieurs_composants_tries(self, mock_pastille): + """Test que les composants sont tries par ordre alphabetique.""" + composants = { + "Puce": {"type": "composant", "fabrication_ihh_pays": 100, "fabrication_ihh_acteurs": 200}, + "Anode": {"type": "composant", "fabrication_ihh_pays": 300, "fabrication_ihh_acteurs": 400}, + "Cathode": {"type": "composant", "fabrication_ihh_pays": 500, "fabrication_ihh_acteurs": 600}, + } + + resultat = _generer_tableau_composants(composants) + + idx_anode = resultat.index("Anode") + idx_cathode = resultat.index("Cathode") + idx_puce = resultat.index("Puce") + assert idx_anode < idx_cathode < idx_puce + + +# ────────────────────────────────────────────── +# _generer_tableau_minerais +# ────────────────────────────────────────────── + +class TestGenererTableauMinerais: + """Tests pour la fonction _generer_tableau_minerais.""" + + def test_dict_vide(self): + """Test qu'un dictionnaire vide retourne une chaine vide.""" + assert _generer_tableau_minerais({}) == "" + + def test_un_minerai(self, mock_pastille): + """Test la generation d'un tableau avec un seul minerai.""" + minerais = { + "Lithium": { + "type": "minerai", + "extraction_ihh_pays": 1500, + "extraction_ihh_acteurs": 2000, + "reserves_ihh_pays": 1800, + "traitement_ihh_pays": 900, + "traitement_ihh_acteurs": 1100, + } + } + + resultat = _generer_tableau_minerais(minerais) + + assert "## Op\u00e9rations sur les minerais" in resultat + assert "| Lithium |" in resultat + assert "| Minerai | Extraction IHH Pays | Extraction IHH Acteurs | R\u00e9serves IHH Pays | Traitement IHH Pays | Traitement IHH Acteurs |" in resultat + assert "| :-- | :--: | :--: | :--: | :--: | :--: |" in resultat + assert "[IHH:1500]" in resultat + assert "[IHH:2000]" in resultat + assert "[IHH:1800]" in resultat + assert "[IHH:900]" in resultat + assert "[IHH:1100]" in resultat + + def test_plusieurs_minerais_tries(self, mock_pastille): + """Test que les minerais sont tries par ordre alphabetique.""" + minerais = { + "Zinc": {"type": "minerai", "extraction_ihh_pays": 1, "extraction_ihh_acteurs": 2, "reserves_ihh_pays": 3, "traitement_ihh_pays": 4, "traitement_ihh_acteurs": 5}, + "Cobalt": {"type": "minerai", "extraction_ihh_pays": 6, "extraction_ihh_acteurs": 7, "reserves_ihh_pays": 8, "traitement_ihh_pays": 9, "traitement_ihh_acteurs": 10}, + "Lithium": {"type": "minerai", "extraction_ihh_pays": 11, "extraction_ihh_acteurs": 12, "reserves_ihh_pays": 13, "traitement_ihh_pays": 14, "traitement_ihh_acteurs": 15}, + } + + resultat = _generer_tableau_minerais(minerais) + + idx_cobalt = resultat.index("Cobalt") + idx_lithium = resultat.index("Lithium") + idx_zinc = resultat.index("Zinc") + assert idx_cobalt < idx_lithium < idx_zinc + + def test_minerai_avec_tirets(self, mock_pastille): + """Test avec des valeurs '-' (donnees manquantes).""" + minerais = { + "Fer": { + "type": "minerai", + "extraction_ihh_pays": "-", + "extraction_ihh_acteurs": "-", + "reserves_ihh_pays": "-", + "traitement_ihh_pays": "-", + "traitement_ihh_acteurs": "-", + } + } + + resultat = _generer_tableau_minerais(minerais) + + assert "| Fer |" in resultat + # 5 pastilles avec valeur "-" + assert resultat.count("[IHH:-]") == 5 + + +# ────────────────────────────────────────────── +# _synth_ihh +# ────────────────────────────────────────────── + +class TestSynthIhh: + """Tests pour la fonction _synth_ihh.""" + + def test_liste_vide(self, mock_pastille): + """Test avec une liste d'operations vide.""" + resultat = _synth_ihh([]) + + assert resultat == "" + + def test_une_operation_minerai(self, operation_minerai, mock_pastille): + """Test avec une seule operation minerai.""" + resultat = _synth_ihh([operation_minerai]) + + assert "## Op\u00e9rations sur les minerais" in resultat + assert "Lithium" in resultat + # Pas de tableau produits ni composants + assert "## Assemblage des produits" not in resultat + assert "## Fabrication des composants" not in resultat + + def test_une_operation_produit(self, operation_produit, mock_pastille): + """Test avec une seule operation produit.""" + resultat = _synth_ihh([operation_produit]) + + assert "## Assemblage des produits" in resultat + assert "Batterie" in resultat + assert "## Op\u00e9rations sur les minerais" not in resultat + + def test_une_operation_composant(self, operation_composant, mock_pastille): + """Test avec une seule operation composant.""" + resultat = _synth_ihh([operation_composant]) + + assert "## Fabrication des composants" in resultat + assert "Cathode" in resultat + + def test_toutes_categories(self, operation_minerai, operation_produit, operation_composant, mock_pastille): + """Test avec les trois categories d'operations.""" + resultat = _synth_ihh([operation_minerai, operation_produit, operation_composant]) + + assert "## Assemblage des produits" in resultat + assert "## Fabrication des composants" in resultat + assert "## Op\u00e9rations sur les minerais" in resultat + + def test_operations_sans_identifiant(self, mock_pastille): + """Test que les operations sans identifiant sont ignorees.""" + operations = [{"autre_cle": "valeur"}] + resultat = _synth_ihh(operations) + + assert resultat == "" + + +# ────────────────────────────────────────────── +# build_ihh_sections +# ────────────────────────────────────────────── + +class TestBuildIhhSections: + """Tests pour la fonction build_ihh_sections.""" + + def test_markdown_sans_bloc_yaml(self): + """Test qu'un markdown sans bloc YAML est retourne tel quel.""" + md = "# Titre\n\nContenu normal sans bloc YAML." + resultat = build_ihh_sections(md) + + assert resultat == md + + def test_markdown_vide(self): + """Test avec un markdown vide.""" + resultat = build_ihh_sections("") + + assert resultat == "" + + def test_un_bloc_yaml_simple(self, mock_pastille): + """Test avec un seul bloc YAML operation.""" + md = ( + "Introduction\n\n" + "```yaml\n" + "op\u00e9ration:\n" + " minerai: Lithium\n" + " extraction:\n" + " ihh_pays: 1500\n" + " ihh_acteurs: 2000\n" + " reserves:\n" + " ihh_pays: 1800\n" + " traitement:\n" + " ihh_pays: 900\n" + " ihh_acteurs: 1100\n" + "```\n" + "Section apres le bloc." + ) + + resultat = build_ihh_sections(md) + + assert "Introduction" in resultat + assert "Section apres le bloc." in resultat + # Le bloc YAML brut ne doit plus etre present + assert "```yaml" not in resultat + + def test_jinja_template_dans_section(self, mock_pastille): + """Test que les templates Jinja2 sont rendus avec les donnees de l'operation.""" + md = ( + "Intro\n\n" + "```yaml\n" + "op\u00e9ration:\n" + " minerai: Lithium\n" + " extraction:\n" + " ihh_pays: 1500\n" + " reserves:\n" + " ihh_pays: 1800\n" + " traitement:\n" + " ihh_pays: 900\n" + "```\n" + "Le minerai est {{ minerai }}." + ) + + resultat = build_ihh_sections(md) + + assert "Le minerai est Lithium." in resultat + + def test_plusieurs_blocs_yaml(self, mock_pastille): + """Test avec plusieurs blocs YAML operations.""" + md = ( + "Introduction generale\n\n" + "```yaml\n" + "op\u00e9ration:\n" + " minerai: Lithium\n" + " extraction:\n" + " ihh_pays: 1500\n" + " reserves:\n" + " ihh_pays: 1800\n" + " traitement:\n" + " ihh_pays: 900\n" + "```\n" + "Section Lithium : {{ minerai }}\n\n" + "```yaml\n" + "op\u00e9ration:\n" + " produit: Batterie\n" + " assemblage:\n" + " ihh_pays: 3000\n" + "```\n" + "Section Batterie : {{ produit }}" + ) + + resultat = build_ihh_sections(md) + + assert "Introduction generale" in resultat + assert "Section Lithium : Lithium" in resultat + assert "Section Batterie : Batterie" in resultat + + def test_avec_tableau_synthese(self, mock_pastille): + """Test la generation du tableau de synthese quand le marqueur est present.""" + md = ( + "Introduction\n\n" + "```yaml\n" + "op\u00e9ration:\n" + " minerai: Lithium\n" + " extraction:\n" + " ihh_pays: 1500\n" + " reserves:\n" + " ihh_pays: 1800\n" + " traitement:\n" + " ihh_pays: 900\n" + "```\n" + "Texte apres\n\n" + "# Tableaux de synth\u00e8se\n" + "\n" + "ancien contenu\n" + "" + ) + + resultat = build_ihh_sections(md) + + assert "# Tableaux de synth\u00e8se" in resultat + assert "" in resultat + assert "" in resultat + # L'ancien contenu doit etre remplace + assert "ancien contenu" not in resultat + # Le tableau minerais doit etre genere + assert "Lithium" in resultat + + def test_sans_tableau_synthese(self, mock_pastille): + """Test sans marqueur de tableau de synthese.""" + md = ( + "Introduction\n\n" + "```yaml\n" + "op\u00e9ration:\n" + " produit: Batterie\n" + " assemblage:\n" + " ihh_pays: 3000\n" + "```\n" + "Fin du document." + ) + + resultat = build_ihh_sections(md) + + assert "# Tableaux de synth\u00e8se" not in resultat + assert "Introduction" in resultat + assert "Fin du document." in resultat + + def test_intro_vide_avant_bloc(self, mock_pastille): + """Test quand le bloc YAML est au tout debut du markdown.""" + md = ( + "```yaml\n" + "op\u00e9ration:\n" + " produit: Batterie\n" + " assemblage:\n" + " ihh_pays: 3000\n" + "```\n" + "Section apres." + ) + + resultat = build_ihh_sections(md) + + assert "Section apres." in resultat + + def test_synthese_remplace_marqueurs_differents_niveaux_titre(self, mock_pastille): + """Test que la regex de remplacement gere differents niveaux de titre (#, ##, ###).""" + md = ( + "Introduction\n\n" + "```yaml\n" + "op\u00e9ration:\n" + " minerai: Cobalt\n" + " extraction:\n" + " ihh_pays: 800\n" + " reserves:\n" + " ihh_pays: 600\n" + " traitement:\n" + " ihh_pays: 500\n" + "```\n" + "Texte\n\n" + "## Tableaux de synth\u00e8se\n" + "\n" + "contenu a remplacer\n" + "" + ) + + resultat = build_ihh_sections(md) + + # Le titre doit etre normalise en h1 + assert "# Tableaux de synth\u00e8se" in resultat + assert "contenu a remplacer" not in resultat + + def test_retour_type_str(self): + """Test que la fonction retourne toujours une chaine.""" + assert isinstance(build_ihh_sections(""), str) + assert isinstance(build_ihh_sections("Texte simple"), str) + + def test_integration_complete(self, mock_pastille): + """Test d'integration avec minerai, produit, composant et synthese.""" + md = ( + "# Analyse IHH\n\n" + "```yaml\n" + "op\u00e9ration:\n" + " minerai: Lithium\n" + " extraction:\n" + " ihh_pays: 1500\n" + " ihh_acteurs: 2000\n" + " reserves:\n" + " ihh_pays: 1800\n" + " traitement:\n" + " ihh_pays: 900\n" + " ihh_acteurs: 1100\n" + "```\n" + "Extraction de {{ minerai }}\n\n" + "```yaml\n" + "op\u00e9ration:\n" + " produit: Batterie\n" + " assemblage:\n" + " ihh_pays: 3000\n" + " ihh_acteurs: 2500\n" + "```\n" + "Assemblage de {{ produit }}\n\n" + "```yaml\n" + "op\u00e9ration:\n" + " composant: Cathode\n" + " fabrication:\n" + " ihh_pays: 1200\n" + " ihh_acteurs: 800\n" + "```\n" + "Fabrication de {{ composant }}\n\n" + "# Tableaux de synth\u00e8se\n" + "\n" + "placeholder\n" + "" + ) + + resultat = build_ihh_sections(md) + + # Introduction preservee + assert "# Analyse IHH" in resultat + # Templates Jinja rendus + assert "Extraction de Lithium" in resultat + assert "Assemblage de Batterie" in resultat + assert "Fabrication de Cathode" in resultat + # Tableau de synthese genere + assert "## Assemblage des produits" in resultat + assert "## Fabrication des composants" in resultat + assert "## Op\u00e9rations sur les minerais" in resultat + # Placeholder remplace + assert "placeholder" not in resultat diff --git a/tests/unit/test_isg.py b/tests/unit/test_isg.py new file mode 100644 index 0000000..ef911f8 --- /dev/null +++ b/tests/unit/test_isg.py @@ -0,0 +1,285 @@ +"""Tests unitaires pour le module app.fiches.utils.dynamic.indice.isg. + +Ces tests verifient les fonctions de traitement Markdown pour l'indice ISG : +- _synth_isg +- build_isg_sections +""" + +import textwrap +from unittest.mock import patch + +import pytest + +from app.fiches.utils.dynamic.indice.isg import ( + _synth_isg, + build_isg_sections, +) + +# ────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────── + +def _yaml_pays_bloc(pays: list[dict]) -> str: + """Construit un bloc YAML markdown avec des donnees de pays ISG.""" + lignes = ["```yaml"] + for p in pays: + lignes.append(f"{p['id']}:") + lignes.append(f" pays: {p['pays']}") + lignes.append(f" wgi_ps: {p['wgi_ps']}") + lignes.append(f" fsi: {p['fsi']}") + lignes.append(f" ndgain: {p['ndgain']}") + lignes.append(f" isg: {p['isg']}") + lignes.append("```") + return "\n".join(lignes) + + +def _sample_pays(**overrides) -> dict: + """Retourne un dictionnaire pays avec des valeurs par defaut.""" + base = { + "id": "chine", + "pays": "Chine", + "wgi_ps": 35, + "fsi": 72, + "ndgain": 48, + "isg": 54, + } + base.update(overrides) + return base + + +def _make_isg_md(pays: list[dict], with_front_matter: bool = True, + indice_court: str = "ISG") -> str: + """Construit un markdown complet pour ISG.""" + parts = [] + if with_front_matter: + parts.append( + f"---\nindice: Indice de Stabilit\u00e9 G\u00e9opolitique\n" + f"indice_court: {indice_court}\n---\n" + ) + parts.append("# Indice de Stabilit\u00e9 G\u00e9opolitique (ISG)\n") + parts.append("Introduction de la fiche.\n") + parts.append("# Criticit\u00e9 par pays\n") + parts.append(_yaml_pays_bloc(pays)) + parts.append("\n## Tableau de synth\u00e8se\n") + parts.append("\nancien tableau\n") + return "\n".join(parts) + + +@pytest.fixture(autouse=True) +def _mock_pastille(): + """Mock la fonction pastille pour retourner une valeur previsible.""" + with patch( + "app.fiches.utils.dynamic.indice.isg.pastille", + side_effect=lambda indice, valeur: f"[{indice}:{valeur}]", + ): + yield + + +# ────────────────────────────────────────────── +# _synth_isg +# ────────────────────────────────────────────── +class TestSynthIsg: + """Tests pour la generation du tableau de synthese ISG.""" + + def test_un_pays(self): + """Test la synthese avec un seul pays.""" + pays = [_sample_pays()] + md = _yaml_pays_bloc(pays) + result = _synth_isg(md) + + assert "| Pays | WGI | FSI | NDGAIN | ISG |" in result + assert "| :-- | :-- | :-- | :-- | :-- |" in result + assert "Chine" in result + assert "54" in result + + def test_plusieurs_pays_tries_alpha(self): + """Test que les pays sont tries alphabetiquement par nom.""" + pays = [ + _sample_pays(id="chine", pays="Chine", isg=54), + _sample_pays(id="australie", pays="Australie", isg=25), + _sample_pays(id="rdc", pays="RDC", isg=82), + ] + md = _yaml_pays_bloc(pays) + result = _synth_isg(md) + + idx_australie = result.find("Australie") + idx_chine = result.find("Chine") + idx_rdc = result.find("RDC") + assert idx_australie < idx_chine < idx_rdc + + def test_toutes_colonnes_presentes(self): + """Test que toutes les colonnes de donnees sont presentes.""" + pays = [_sample_pays(wgi_ps=35, fsi=72, ndgain=48, isg=54)] + md = _yaml_pays_bloc(pays) + result = _synth_isg(md) + + assert "35" in result + assert "72" in result + assert "48" in result + assert "54" in result + + def test_pas_de_bloc_yaml(self): + """Test avec un texte sans bloc yaml retourne un message par defaut.""" + result = _synth_isg("Texte sans donnees YAML.") + assert "aucune donnee de pays trouvee" in result.lower() or "aucune donn" in result + + def test_texte_vide(self): + """Test avec un texte vide.""" + result = _synth_isg("") + assert "aucune donn" in result + + def test_nombre_lignes(self): + """Test que le nombre de lignes correspond aux donnees.""" + pays = [ + _sample_pays(id="a", pays="Alpha", isg=10), + _sample_pays(id="b", pays="Beta", isg=20), + _sample_pays(id="c", pays="Gamma", isg=30), + ] + md = _yaml_pays_bloc(pays) + result = _synth_isg(md) + lignes = result.strip().split("\n") + # 2 en-tetes + 3 donnees = 5 + assert len(lignes) == 5 + + def test_pastille_incluse(self): + """Test que la pastille est incluse dans la colonne ISG.""" + pays = [_sample_pays(isg=25)] + md = _yaml_pays_bloc(pays) + result = _synth_isg(md) + + # La pastille mockee retourne [ISG:valeur] avant la valeur + assert "[ISG:25]" in result + assert "25" in result + + +# ────────────────────────────────────────────── +# build_isg_sections +# ────────────────────────────────────────────── +class TestBuildIsgSections: + """Tests pour la fonction principale de construction des sections dynamiques ISG.""" + + def test_remplacement_tableau_final(self): + """Test que le tableau final est remplace entre les marqueurs.""" + pays = [_sample_pays()] + md = _make_isg_md(pays) + result = build_isg_sections(md) + + assert "" in result + assert "" in result + assert "ancien tableau" not in result + + def test_tableau_synthese_genere(self): + """Test que le tableau de synthese est genere correctement.""" + pays = [_sample_pays()] + md = _make_isg_md(pays) + result = build_isg_sections(md) + + assert "| Pays | WGI | FSI | NDGAIN | ISG |" in result + assert "Chine" in result + + def test_bloc_yaml_pays_supprime(self): + """Test que le bloc YAML des pays est supprime de la section Criticite.""" + pays = [_sample_pays()] + md = _make_isg_md(pays) + result = build_isg_sections(md) + + assert "# Criticit\u00e9 par pays" in result + # Le bloc YAML doit etre supprime + assert "```yaml" not in result + + def test_front_matter_isg_valide(self): + """Test que la fiche est traitee quand indice_court est ISG.""" + pays = [_sample_pays()] + md = _make_isg_md(pays, indice_court="ISG") + result = build_isg_sections(md) + + # La fiche doit etre traitee + assert "| Pays | WGI | FSI | NDGAIN | ISG |" in result + + def test_front_matter_non_isg_retourne_original(self): + """Test que la fiche n'est pas traitee si indice_court != ISG.""" + pays = [_sample_pays()] + md = _make_isg_md(pays, indice_court="ICS") + result = build_isg_sections(md) + + # Le markdown original doit etre retourne sans modification + assert "ancien tableau" in result + + def test_sans_front_matter(self): + """Test le traitement sans front-matter YAML.""" + pays = [_sample_pays()] + md = _make_isg_md(pays, with_front_matter=False) + result = build_isg_sections(md) + + # Sans front-matter, la fiche est quand meme traitee (pas de filtre) + assert "| Pays | WGI | FSI | NDGAIN | ISG |" in result + + def test_plusieurs_pays(self): + """Test avec plusieurs pays dans le bloc YAML.""" + pays = [ + _sample_pays(id="australie", pays="Australie", wgi_ps=85, fsi=28, ndgain=72, isg=25), + _sample_pays(id="chine", pays="Chine", wgi_ps=35, fsi=72, ndgain=48, isg=54), + _sample_pays(id="rdc", pays="RDC", wgi_ps=15, fsi=102, ndgain=33, isg=82), + ] + md = _make_isg_md(pays) + result = build_isg_sections(md) + + assert "Australie" in result + assert "Chine" in result + assert "RDC" in result + + def test_texte_sans_marqueurs(self): + """Test avec un markdown sans marqueurs AUTO-BEGIN/END.""" + md = textwrap.dedent("""\ + --- + indice_court: ISG + --- + + # ISG + + # Criticit\u00e9 par pays + ```yaml + fr: + pays: France + wgi_ps: 80 + fsi: 30 + ndgain: 65 + isg: 28 + ``` + + ## Tableau de synth\u00e8se + + Pas de marqueurs ici. + """) + result = build_isg_sections(md) + # Sans marqueurs, le sub ne match pas, le tableau n'est pas remplace + assert isinstance(result, str) + + def test_md_vide_avec_front_matter_isg(self): + """Test avec un markdown minimal contenant seulement le front-matter ISG.""" + md = "---\nindice_court: ISG\n---\n\nContenu minimal." + result = build_isg_sections(md) + # Pas de bloc YAML de pays, pas de marqueurs, retourne le texte en l'etat + assert "Contenu minimal." in result + + def test_preservation_contenu_hors_sections(self): + """Test que le contenu hors des sections dynamiques est preserve.""" + pays = [_sample_pays()] + md = _make_isg_md(pays) + result = build_isg_sections(md) + + assert "# Indice de Stabilit\u00e9 G\u00e9opolitique (ISG)" in result + assert "Introduction de la fiche." in result + + def test_tri_alphabetique_dans_resultat(self): + """Test que les pays sont tries alphabetiquement dans le resultat final.""" + pays = [ + _sample_pays(id="z", pays="Zambie", isg=60), + _sample_pays(id="a", pays="Albanie", isg=30), + ] + md = _make_isg_md(pays) + result = build_isg_sections(md) + + idx_albanie = result.find("Albanie") + idx_zambie = result.find("Zambie") + assert idx_albanie < idx_zambie diff --git a/tests/unit/test_ivc.py b/tests/unit/test_ivc.py new file mode 100644 index 0000000..b3548f9 --- /dev/null +++ b/tests/unit/test_ivc.py @@ -0,0 +1,342 @@ +"""Tests unitaires pour le module app.fiches.utils.dynamic.indice.ivc. + +Ces tests verifient les fonctions de traitement Markdown pour l'indice IVC : +- _synth_ivc +- _ivc_segments +- build_ivc_sections +""" + + + +from app.fiches.utils.dynamic.indice.ivc import ( + IVC_RE, + _ivc_segments, + _synth_ivc, + build_ivc_sections, +) + +# ────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────── + +def _yaml_ivc_bloc(minerai: dict) -> str: + """Construit un bloc YAML markdown pour un minerai IVC.""" + lignes = ["```yaml"] + lignes.append("minerai:") + for k, v in minerai.items(): + lignes.append(f" {k}: {v}") + lignes.append("```") + return "\n".join(lignes) + + +def _sample_minerai(**overrides) -> dict: + """Retourne un dictionnaire minerai avec des valeurs par defaut.""" + base = { + "nom": "Lithium", + "ivc": 45, + "vulnerabilite": "Moyenne", + } + base.update(overrides) + return base + + +def _make_ivc_md(minerais: list[dict], with_markers: bool = True) -> str: + """Construit un markdown complet pour IVC avec des blocs YAML et des templates Jinja2.""" + parts = ["# Indice de Vulnerabilite Complete (IVC)\n"] + parts.append("Introduction de la fiche.\n") + for minerai in minerais: + parts.append(_yaml_ivc_bloc(minerai)) + parts.append("\n## Analyse de {{ nom }}\n") + parts.append("L'IVC de {{ nom }} est de {{ ivc }} avec une vulnerabilite {{ vulnerabilite }}.\n") + if with_markers: + parts.append("\n## Tableau de synth\u00e8se\n") + parts.append("\nancien tableau\n") + return "\n".join(parts) + + +# ────────────────────────────────────────────── +# IVC_RE (regex) +# ────────────────────────────────────────────── +class TestIvcRegex: + """Tests pour l'expression reguliere IVC_RE.""" + + def test_match_bloc_ivc_simple(self): + """Test la detection d'un bloc yaml IVC simple.""" + md = "```yaml\nminerai:\n nom: Lithium\n ivc: 45\n```" + matches = list(IVC_RE.finditer(md)) + assert len(matches) == 1 + + def test_match_blocs_ivc_multiples(self): + """Test la detection de plusieurs blocs yaml IVC.""" + md = ( + "```yaml\nminerai:\n nom: Lithium\n ivc: 45\n```\n" + "texte\n" + "```yaml\nminerai:\n nom: Cobalt\n ivc: 62\n```" + ) + matches = list(IVC_RE.finditer(md)) + assert len(matches) == 2 + + def test_pas_de_match_sans_minerai(self): + """Test qu'un bloc yaml sans 'minerai:' ne matche pas.""" + md = "```yaml\nautres:\n x: 1\n```" + matches = list(IVC_RE.finditer(md)) + assert len(matches) == 0 + + def test_match_insensible_casse(self): + """Test que YAML en majuscules est aussi detecte.""" + md = "```YAML\nminerai:\n nom: Test\n```" + matches = list(IVC_RE.finditer(md)) + assert len(matches) == 1 + + def test_espaces_entre_yaml_et_minerai(self): + """Test avec des espaces entre le tag yaml et minerai.""" + md = "```yaml\n minerai:\n nom: Test\n```" + matches = list(IVC_RE.finditer(md)) + assert len(matches) == 1 + + +# ────────────────────────────────────────────── +# _synth_ivc +# ────────────────────────────────────────────── +class TestSynthIvc: + """Tests pour la generation du tableau de synthese IVC.""" + + def test_un_minerai(self): + """Test la synthese avec un seul minerai.""" + minerais = [_sample_minerai()] + result = _synth_ivc(minerais) + + assert "| Minerai | IVC | Vuln\u00e9rabilit\u00e9 |" in result + assert "| :-- | :-- | :-- |" in result + assert "Lithium" in result + assert "45" in result + assert "Moyenne" in result + + def test_plusieurs_minerais(self): + """Test la synthese avec plusieurs minerais.""" + minerais = [ + _sample_minerai(nom="Lithium", ivc=45, vulnerabilite="Moyenne"), + _sample_minerai(nom="Cobalt", ivc=72, vulnerabilite="Elevee"), + _sample_minerai(nom="Cuivre", ivc=18, vulnerabilite="Faible"), + ] + result = _synth_ivc(minerais) + + assert "Lithium" in result + assert "Cobalt" in result + assert "Cuivre" in result + + def test_nombre_lignes(self): + """Test que le nombre de lignes correspond aux donnees.""" + minerais = [ + _sample_minerai(nom="A", ivc=10, vulnerabilite="X"), + _sample_minerai(nom="B", ivc=20, vulnerabilite="Y"), + ] + result = _synth_ivc(minerais) + lignes = result.strip().split("\n") + # 2 en-tetes + 2 donnees = 4 + assert len(lignes) == 4 + + def test_en_tetes_tableau(self): + """Test que les en-tetes sont corrects.""" + minerais = [_sample_minerai()] + result = _synth_ivc(minerais) + + lignes = result.strip().split("\n") + assert lignes[0] == "| Minerai | IVC | Vuln\u00e9rabilit\u00e9 |" + assert lignes[1] == "| :-- | :-- | :-- |" + + def test_liste_vide(self): + """Test avec une liste vide de minerais.""" + result = _synth_ivc([]) + lignes = result.strip().split("\n") + # Seulement les 2 lignes d'en-tete + assert len(lignes) == 2 + + +# ────────────────────────────────────────────── +# _ivc_segments +# ────────────────────────────────────────────── +class TestIvcSegments: + """Tests pour l'extraction des segments entre blocs YAML IVC.""" + + def test_un_segment(self): + """Test l'extraction d'un seul segment IVC.""" + minerai = _sample_minerai() + md = _yaml_ivc_bloc(minerai) + "\nSegment apres le bloc." + segments = list(_ivc_segments(md)) + + # 1 segment + 1 reste eventuel + assert len(segments) == 2 + data, seg = segments[0] + assert data["nom"] == "Lithium" + assert "Segment apres le bloc." in seg + + def test_deux_segments(self): + """Test l'extraction de deux segments entre blocs IVC.""" + m1 = _sample_minerai(nom="Lithium", ivc=45, vulnerabilite="Moyenne") + m2 = _sample_minerai(nom="Cobalt", ivc=72, vulnerabilite="Elevee") + md = _yaml_ivc_bloc(m1) + "\nSegment 1\n" + _yaml_ivc_bloc(m2) + "\nSegment 2" + segments = list(_ivc_segments(md)) + + # 2 segments + 1 reste eventuel + assert len(segments) == 3 + assert segments[0][0]["nom"] == "Lithium" + assert "Segment 1" in segments[0][1] + assert segments[1][0]["nom"] == "Cobalt" + assert "Segment 2" in segments[1][1] + + def test_reste_final(self): + """Test que le reste final (apres le dernier bloc) est capture.""" + minerai = _sample_minerai() + md = _yaml_ivc_bloc(minerai) + "\nContenu.\nTexte final." + segments = list(_ivc_segments(md)) + + # Le dernier segment doit etre (None, reste) + dernier = segments[-1] + assert dernier[0] is None + + def test_pas_de_bloc_yaml(self): + """Test avec un markdown sans bloc yaml IVC.""" + md = "Texte sans bloc yaml." + segments = list(_ivc_segments(md)) + + # Seulement le reste (None, texte_entier) + assert len(segments) == 1 + assert segments[0][0] is None + assert "Texte sans bloc yaml." in segments[0][1] + + +# ────────────────────────────────────────────── +# build_ivc_sections +# ────────────────────────────────────────────── +class TestBuildIvcSections: + """Tests pour la fonction principale de construction des sections dynamiques IVC.""" + + def test_remplacement_jinja2(self): + """Test que les templates Jinja2 sont rendus correctement.""" + minerais = [_sample_minerai()] + md = _make_ivc_md(minerais) + result = build_ivc_sections(md) + + assert "Lithium" in result + assert "45" in result + assert "{{ nom }}" not in result + + def test_tableau_synthese_genere(self): + """Test que le tableau de synthese est genere.""" + minerais = [_sample_minerai()] + md = _make_ivc_md(minerais) + result = build_ivc_sections(md) + + assert "| Minerai | IVC | Vuln\u00e9rabilit\u00e9 |" in result + + def test_marqueurs_preserves(self): + """Test que les marqueurs AUTO-BEGIN/END sont preserves.""" + minerais = [_sample_minerai()] + md = _make_ivc_md(minerais) + result = build_ivc_sections(md) + + assert "" in result + assert "" in result + + def test_ancien_contenu_remplace(self): + """Test que l'ancien contenu entre les marqueurs est remplace.""" + minerais = [_sample_minerai()] + md = _make_ivc_md(minerais) + result = build_ivc_sections(md) + + assert "ancien tableau" not in result + + def test_pas_de_bloc_yaml_retourne_original(self): + """Test qu'un markdown sans bloc yaml IVC est retourne tel quel.""" + md = "# Titre\n\nTexte sans bloc yaml IVC." + result = build_ivc_sections(md) + assert "Texte sans bloc yaml IVC." in result + + def test_intro_preservee(self): + """Test que l'introduction est preservee dans le resultat.""" + minerais = [_sample_minerai()] + md = _make_ivc_md(minerais) + result = build_ivc_sections(md) + + assert "# Indice de Vulnerabilite Complete (IVC)" in result + assert "Introduction de la fiche." in result + + def test_plusieurs_minerais(self): + """Test avec plusieurs minerais genere un document complet.""" + minerais = [ + _sample_minerai(nom="Lithium", ivc=45, vulnerabilite="Moyenne"), + _sample_minerai(nom="Cobalt", ivc=72, vulnerabilite="Elevee"), + ] + md = _make_ivc_md(minerais) + result = build_ivc_sections(md) + + assert "Lithium" in result + assert "Cobalt" in result + assert "45" in result + assert "72" in result + + def test_template_jinja2_avec_toutes_variables(self): + """Test que toutes les variables Jinja2 du minerai sont accessibles.""" + minerai = _sample_minerai(nom="Indium", ivc=58, vulnerabilite="Haute") + md = _make_ivc_md([minerai]) + result = build_ivc_sections(md) + + assert "Indium" in result + assert "58" in result + assert "Haute" in result + + def test_md_vide(self): + """Test avec un markdown vide.""" + result = build_ivc_sections("") + assert result == "" + + def test_bloc_yaml_sans_marqueurs(self): + """Test avec un bloc yaml IVC mais sans marqueurs AUTO-BEGIN/END.""" + minerais = [_sample_minerai()] + md = _make_ivc_md(minerais, with_markers=False) + result = build_ivc_sections(md) + + # Le template doit etre rendu meme sans marqueurs + assert "Lithium" in result + assert "{{ nom }}" not in result + + def test_separations_segments(self): + """Test que les segments sont separes par des doubles retours a la ligne.""" + minerais = [ + _sample_minerai(nom="A", ivc=10, vulnerabilite="Faible"), + _sample_minerai(nom="B", ivc=20, vulnerabilite="Moyenne"), + ] + md = _make_ivc_md(minerais) + result = build_ivc_sections(md) + + # Les segments doivent etre joints par "\n\n" + assert "\n\n" in result + + def test_ordre_minerais_preserve(self): + """Test que l'ordre des minerais dans le document est preserve.""" + minerais = [ + _sample_minerai(nom="Premier", ivc=10, vulnerabilite="X"), + _sample_minerai(nom="Second", ivc=20, vulnerabilite="Y"), + ] + md = _make_ivc_md(minerais) + result = build_ivc_sections(md) + + idx_premier = result.find("Premier") + idx_second = result.find("Second") + assert idx_premier < idx_second + + def test_synthese_contient_tous_minerais(self): + """Test que le tableau de synthese contient tous les minerais.""" + minerais = [ + _sample_minerai(nom="Lithium", ivc=45, vulnerabilite="Moyenne"), + _sample_minerai(nom="Cobalt", ivc=72, vulnerabilite="Elevee"), + _sample_minerai(nom="Cuivre", ivc=18, vulnerabilite="Faible"), + ] + md = _make_ivc_md(minerais) + result = build_ivc_sections(md) + + # Verifier dans la section tableau final + assert "Lithium" in result + assert "Cobalt" in result + assert "Cuivre" in result diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py index cf7d34e..35faf82 100644 --- a/tests/unit/test_logger.py +++ b/tests/unit/test_logger.py @@ -1,13 +1,11 @@ -""" -Tests unitaires pour le module utils.logger. +"""Tests unitaires pour le module utils.logger. Ces tests vérifient que le système de logging fonctionne correctement. """ -import pytest import logging -from pathlib import Path -from utils.logger import setup_logger, get_logger + +from utils.logger import get_logger, setup_logger class TestSetupLogger: diff --git a/tests/unit/test_pastille.py b/tests/unit/test_pastille.py new file mode 100644 index 0000000..0ac1a4f --- /dev/null +++ b/tests/unit/test_pastille.py @@ -0,0 +1,213 @@ +"""Tests unitaires pour le module pastille. + +Ces tests verifient la fonction pastille() qui renvoie une icone +en fonction de la valeur d'un indicateur par rapport aux seuils definis. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +# -- Donnees de test partagees ------------------------------------------------ + +SEUILS_EXEMPLE = { + "criticite": { + "vert": {"max": 30}, + "rouge": {"min": 70}, + }, + "confort": { + "vert": {"max": 10}, + "rouge": {"min": 50}, + }, +} + + +# -- Fixture commune pour le mock Streamlit ----------------------------------- + +@pytest.fixture(autouse=True) +def _mock_streamlit(): + """Remplace le module streamlit par un mock dans sys.modules. + + Le mock expose session_state.get() qui renvoie SEUILS_EXEMPLE par defaut. + """ + mock_st = MagicMock() + mock_st.session_state.get.return_value = SEUILS_EXEMPLE + with patch.dict("sys.modules", {"streamlit": mock_st}): + yield mock_st + + +# -- Import apres le mock (au niveau fonction) -------------------------------- + +def _import_pastille(): + """Importe la fonction pastille en contexte de mock.""" + from app.fiches.utils.dynamic.utils.pastille import pastille + return pastille + + +def _import_icons(): + """Importe le dictionnaire PASTILLE_ICONS.""" + from app.fiches.utils.dynamic.utils.pastille import PASTILLE_ICONS + return PASTILLE_ICONS + + +# ============================================================================= +# Tests +# ============================================================================= + + +class TestPastilleIcons: + """Verifie que le dictionnaire PASTILLE_ICONS contient les bonnes icones.""" + + def test_contient_trois_couleurs(self): + """Verifie la presence des trois couleurs (vert, orange, rouge).""" + icons = _import_icons() + assert set(icons.keys()) == {"vert", "orange", "rouge"} + + def test_icones_non_vides(self): + """Verifie que chaque icone est une chaine non vide.""" + icons = _import_icons() + for couleur, icone in icons.items(): + assert icone, f"L'icone pour '{couleur}' ne doit pas etre vide" + + +class TestPastilleVert: + """Verifie que les valeurs sous le seuil vert renvoient l'icone verte.""" + + def test_valeur_zero(self): + """Verifie que la valeur 0 renvoie l'icone verte.""" + pastille = _import_pastille() + assert pastille("criticite", "0") == "\u2705" + + def test_valeur_sous_seuil_vert(self): + """Verifie qu'une valeur sous le seuil vert renvoie l'icone verte.""" + pastille = _import_pastille() + assert pastille("criticite", "10") == "\u2705" + + def test_valeur_juste_sous_seuil_vert(self): + """Verifie qu'une valeur juste sous vert_max renvoie l'icone verte.""" + pastille = _import_pastille() + assert pastille("criticite", "29.9") == "\u2705" + + def test_valeur_negative(self): + """Verifie qu'une valeur negative renvoie l'icone verte.""" + pastille = _import_pastille() + assert pastille("criticite", "-5") == "\u2705" + + +class TestPastilleRouge: + """Verifie que les valeurs au-dessus du seuil rouge renvoient l'icone rouge.""" + + def test_valeur_au_dessus_seuil_rouge(self): + """Verifie qu'une valeur bien au-dessus du seuil rouge renvoie le rouge.""" + pastille = _import_pastille() + assert pastille("criticite", "80") == "\U0001f534" + + def test_valeur_juste_au_dessus_seuil_rouge(self): + """Verifie qu'une valeur juste au-dessus de rouge_min renvoie le rouge.""" + pastille = _import_pastille() + assert pastille("criticite", "70.1") == "\U0001f534" + + def test_valeur_tres_elevee(self): + """Verifie qu'une valeur tres elevee renvoie l'icone rouge.""" + pastille = _import_pastille() + assert pastille("criticite", "999") == "\U0001f534" + + +class TestPastilleOrange: + """Verifie que les valeurs entre vert et rouge renvoient l'icone orange.""" + + def test_valeur_exacte_seuil_vert(self): + """Quand val == vert_max, val n'est PAS < vert_max donc orange.""" + pastille = _import_pastille() + assert pastille("criticite", "30") == "\U0001f536" + + def test_valeur_entre_seuils(self): + """Verifie qu'une valeur entre les deux seuils renvoie l'icone orange.""" + pastille = _import_pastille() + assert pastille("criticite", "50") == "\U0001f536" + + def test_valeur_exacte_seuil_rouge(self): + """Quand val == rouge_min, val n'est PAS > rouge_min donc orange.""" + pastille = _import_pastille() + assert pastille("criticite", "70") == "\U0001f536" + + +class TestPastilleAutreIndice: + """Verifie le fonctionnement avec un indice different (confort).""" + + def test_vert_confort(self): + """Verifie la pastille verte pour l'indice confort.""" + pastille = _import_pastille() + assert pastille("confort", "5") == "\u2705" + + def test_orange_confort(self): + """Verifie la pastille orange pour l'indice confort.""" + pastille = _import_pastille() + assert pastille("confort", "25") == "\U0001f536" + + def test_rouge_confort(self): + """Verifie la pastille rouge pour l'indice confort.""" + pastille = _import_pastille() + assert pastille("confort", "60") == "\U0001f534" + + +class TestPastilleIndiceInconnu: + """Verifie que la fonction renvoie '' pour un indice non defini dans les seuils.""" + + def test_indice_absent(self): + """Verifie que la fonction renvoie '' pour un indice inexistant.""" + pastille = _import_pastille() + assert pastille("indice_inexistant", "10") == "" + + +class TestPastilleErreurs: + """Verifie que les erreurs sont gerees silencieusement (retour '').""" + + def test_valeur_non_numerique(self): + """Une valeur non convertible en float declenche ValueError.""" + pastille = _import_pastille() + assert pastille("criticite", "abc") == "" + + def test_valeur_none(self): + """None comme valeur declenche TypeError lors de float(None).""" + pastille = _import_pastille() + assert pastille("criticite", None) == "" + + def test_valeur_vide(self): + """Chaine vide declenche ValueError lors de float('').""" + pastille = _import_pastille() + assert pastille("criticite", "") == "" + + +class TestPastilleSansSeuilsEnSession: + """Verifie le comportement quand session_state ne contient pas de seuils.""" + + def test_seuils_vides(self, _mock_streamlit): + """Seuils = {} : la ligne seuils[indice] leve KeyError -> retour ''.""" + _mock_streamlit.session_state.get.return_value = {} + pastille = _import_pastille() + assert pastille("criticite", "10") == "" + + def test_seuils_none(self, _mock_streamlit): + """Seuils = None : le test 'indice not in seuils' leve TypeError -> retour ''.""" + _mock_streamlit.session_state.get.return_value = None + pastille = _import_pastille() + assert pastille("criticite", "10") == "" + + def test_seuil_incomplet_sans_vert(self, _mock_streamlit): + """Un seuil sans la cle 'vert' leve KeyError -> retour ''.""" + _mock_streamlit.session_state.get.return_value = { + "criticite": {"rouge": {"min": 70}} + } + pastille = _import_pastille() + assert pastille("criticite", "10") == "" + + def test_seuil_incomplet_sans_rouge(self, _mock_streamlit): + """Un seuil sans la cle 'rouge' leve KeyError -> retour ''.""" + _mock_streamlit.session_state.get.return_value = { + "criticite": {"vert": {"max": 30}} + } + pastille = _import_pastille() + # rouge_min est lu avant le test val < vert_max, donc KeyError -> '' + assert pastille("criticite", "10") == "" + assert pastille("criticite", "50") == "" diff --git a/tests/unit/test_persistance.py b/tests/unit/test_persistance.py new file mode 100644 index 0000000..09cfdc2 --- /dev/null +++ b/tests/unit/test_persistance.py @@ -0,0 +1,795 @@ +"""Tests unitaires pour le module utils.persistance. + +Ces tests verifient les fonctions de persistance JSON utilisees pour +sauvegarder et recuperer l'etat des sessions Streamlit. +""" + +import json +import sys +from datetime import date +from pathlib import Path +from unittest.mock import MagicMock, patch + +# On mock les dependances externes AVANT d'importer le module sous test. +# persistance.py fait au niveau module : +# - import streamlit as st +# - from utils.translations import _ +# - from dotenv import load_dotenv ; load_dotenv(".env") +# Ces mocks garantissent que les tests fonctionnent meme si ces paquets +# ne sont pas installes dans l'environnement de test. + +if "streamlit" not in sys.modules: + sys.modules["streamlit"] = MagicMock() + +if "dotenv" not in sys.modules: + sys.modules["dotenv"] = MagicMock() + +if "utils.translations" not in sys.modules: + _mock_translations = MagicMock() + _mock_translations._ = lambda key: key + sys.modules["utils.translations"] = _mock_translations + +# Maintenant on peut importer le module sous test +from utils.persistance import ( + _get_champ, + _maj_champ, + _supprime_champ, + get_full_structure, + get_session_id, + update_session_paths, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _creer_fichier_json(chemin: Path, contenu: dict) -> Path: + """Cree un fichier JSON avec le contenu donne.""" + chemin.write_text(json.dumps(contenu, ensure_ascii=False, indent=2), encoding="utf-8") + return chemin + + +# --------------------------------------------------------------------------- +# Tests pour get_session_id +# --------------------------------------------------------------------------- + + +class TestGetSessionId: + """Tests pour la fonction get_session_id.""" + + @patch("utils.persistance.st") + def test_retourne_session_id_depuis_headers(self, mock_st): + """Test que le session ID est recupere depuis les headers HTTP.""" + mock_st.context.headers.get.return_value = "abc-123-session" + + resultat = get_session_id() + + assert resultat == "abc-123-session" + mock_st.context.headers.get.assert_called_once_with("x-session-id", "anonymous") + + @patch("utils.persistance.st") + def test_retourne_anonymous_si_header_absent(self, mock_st): + """Test le fallback sur 'anonymous' quand le header est absent.""" + mock_st.context.headers.get.return_value = "anonymous" + + resultat = get_session_id() + + assert resultat == "anonymous" + + +# --------------------------------------------------------------------------- +# Tests pour update_session_paths +# --------------------------------------------------------------------------- + + +class TestUpdateSessionPaths: + """Tests pour la fonction update_session_paths.""" + + @patch("utils.persistance.get_session_id", return_value="test-session-42") + @patch("utils.persistance.os.getenv", return_value="statut_general.json") + @patch("utils.persistance.Path.mkdir") + def test_initialise_chemins_session(self, mock_mkdir, mock_getenv, mock_get_sid): + """Test que les variables globales sont correctement initialisees.""" + import utils.persistance as mod + + update_session_paths() + + assert mod.SAVE_STATUT == "statut_general.json" + assert mod.SAVE_SESSIONS_PATH == Path("tmp/sessions/test-session-42") + assert mod.SAVE_STATUT_PATH == Path("tmp/sessions/test-session-42/statut_general.json") + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) + + @patch("utils.persistance.get_session_id", return_value="sess-xyz") + @patch("utils.persistance.os.getenv", return_value="custom_statut.json") + @patch("utils.persistance.Path.mkdir") + def test_utilise_nom_fichier_personnalise(self, mock_mkdir, mock_getenv, mock_get_sid): + """Test avec un nom de fichier de statut personnalise via variable d'environnement.""" + import utils.persistance as mod + + update_session_paths() + + assert mod.SAVE_STATUT == "custom_statut.json" + assert mod.SAVE_STATUT_PATH == Path("tmp/sessions/sess-xyz/custom_statut.json") + + +# --------------------------------------------------------------------------- +# Tests pour _maj_champ +# --------------------------------------------------------------------------- + + +class TestMajChamp: + """Tests pour la fonction _maj_champ (mise a jour d'un champ JSON).""" + + @patch("utils.persistance.st") + def test_creation_fichier_inexistant(self, mock_st, tmp_path): + """Test la creation d'un nouveau fichier JSON si inexistant.""" + fichier = tmp_path / "nouveau.json" + + resultat = _maj_champ(fichier, "cle_simple", "valeur") + + assert resultat is True + assert fichier.exists() + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu == {"cle_simple": "valeur"} + + @patch("utils.persistance.st") + def test_mise_a_jour_fichier_existant(self, mock_st, tmp_path): + """Test la mise a jour d'un champ dans un fichier existant.""" + fichier = _creer_fichier_json(tmp_path / "existant.json", {"ancien": "données"}) + + resultat = _maj_champ(fichier, "nouveau", "ajouté") + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu["ancien"] == "données" + assert contenu["nouveau"] == "ajouté" + + @patch("utils.persistance.st") + def test_cle_imbriquee_avec_points(self, mock_st, tmp_path): + """Test l'insertion d'une valeur via une cle hierarchique 'a.b.c'.""" + fichier = tmp_path / "imbrique.json" + + resultat = _maj_champ(fichier, "niveau1.niveau2.niveau3", "profonde") + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu["niveau1"]["niveau2"]["niveau3"] == "profonde" + + @patch("utils.persistance.st") + def test_cle_imbriquee_fichier_existant(self, mock_st, tmp_path): + """Test l'ajout d'une cle imbriquee dans un fichier existant.""" + fichier = _creer_fichier_json( + tmp_path / "existant2.json", + {"config": {"theme": "sombre"}} + ) + + resultat = _maj_champ(fichier, "config.langue", "fr") + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu["config"]["theme"] == "sombre" + assert contenu["config"]["langue"] == "fr" + + @patch("utils.persistance.st") + def test_ecrasement_valeur_existante(self, mock_st, tmp_path): + """Test que la mise a jour ecrase la valeur existante.""" + fichier = _creer_fichier_json(tmp_path / "ecrase.json", {"cle": "ancienne"}) + + resultat = _maj_champ(fichier, "cle", "nouvelle") + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu["cle"] == "nouvelle" + + @patch("utils.persistance.st") + def test_serialisation_date(self, mock_st, tmp_path): + """Test que les objets date sont serialises en format ISO.""" + fichier = tmp_path / "date.json" + date_test = date(2025, 6, 15) + + resultat = _maj_champ(fichier, "derniere_mise_a_jour", date_test) + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu["derniere_mise_a_jour"] == "2025-06-15" + + @patch("utils.persistance.st") + def test_contenu_vide_par_defaut(self, mock_st, tmp_path): + """Test que le contenu par defaut est une chaine vide.""" + fichier = tmp_path / "vide.json" + + resultat = _maj_champ(fichier, "statut") + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu["statut"] == "" + + @patch("utils.persistance.st") + def test_contenu_numerique(self, mock_st, tmp_path): + """Test avec une valeur numerique.""" + fichier = tmp_path / "num.json" + + resultat = _maj_champ(fichier, "score", 42) + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu["score"] == 42 + + @patch("utils.persistance.st") + def test_contenu_liste(self, mock_st, tmp_path): + """Test avec une valeur de type liste.""" + fichier = tmp_path / "liste.json" + + resultat = _maj_champ(fichier, "elements", ["a", "b", "c"]) + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu["elements"] == ["a", "b", "c"] + + @patch("utils.persistance.st") + def test_contenu_boolean(self, mock_st, tmp_path): + """Test avec une valeur booleenne.""" + fichier = tmp_path / "bool.json" + + resultat = _maj_champ(fichier, "actif", True) + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu["actif"] is True + + @patch("utils.persistance.st") + def test_fichier_json_corrompu(self, mock_st, tmp_path): + """Test la gestion d'un fichier JSON corrompu (syntaxe invalide).""" + fichier = tmp_path / "corrompu.json" + fichier.write_text("{ceci n'est pas du json valide", encoding="utf-8") + + resultat = _maj_champ(fichier, "cle", "valeur") + + assert resultat is False + mock_st.error.assert_called_once() + + @patch("utils.persistance.st") + def test_erreur_ecriture(self, mock_st, tmp_path): + """Test la gestion d'erreur lors de l'ecriture du fichier.""" + fichier = tmp_path / "readonly.json" + + # On patch l'ouverture en ecriture pour lever une exception + with patch.object(Path, "open", side_effect=PermissionError("Permission denied")), \ + patch.object(Path, "exists", return_value=False): + resultat = _maj_champ(fichier, "cle", "valeur") + + assert resultat is False + mock_st.error.assert_called_once() + + @patch("utils.persistance.st") + def test_valeur_none(self, mock_st, tmp_path): + """Test avec une valeur None.""" + fichier = tmp_path / "none.json" + + resultat = _maj_champ(fichier, "cle", None) + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu["cle"] is None + + @patch("utils.persistance.st") + def test_caracteres_unicode(self, mock_st, tmp_path): + """Test avec des caracteres speciaux et accents.""" + fichier = tmp_path / "unicode.json" + + resultat = _maj_champ(fichier, "texte", "Ceci est un texte avec des accents: eacu") + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert "accents" in contenu["texte"] + + @patch("utils.persistance.st") + def test_cle_profonde_trois_niveaux(self, mock_st, tmp_path): + """Test l'insertion sur une structure existante profonde.""" + fichier = _creer_fichier_json( + tmp_path / "profond.json", + {"a": {"b": {"c": "existant"}}} + ) + + resultat = _maj_champ(fichier, "a.b.d", "nouveau") + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu["a"]["b"]["c"] == "existant" + assert contenu["a"]["b"]["d"] == "nouveau" + + +# --------------------------------------------------------------------------- +# Tests pour _get_champ +# --------------------------------------------------------------------------- + + +class TestGetChamp: + """Tests pour la fonction _get_champ (lecture d'un champ JSON).""" + + @patch("utils.persistance.st") + def test_lecture_cle_simple(self, mock_st, tmp_path): + """Test la lecture d'une cle de premier niveau.""" + fichier = _creer_fichier_json(tmp_path / "simple.json", {"nom": "FabNum"}) + + resultat = _get_champ(fichier, "nom") + + assert resultat == "FabNum" + + @patch("utils.persistance.st") + def test_lecture_cle_imbriquee(self, mock_st, tmp_path): + """Test la lecture d'une cle hierarchique 'a.b.c'.""" + fichier = _creer_fichier_json( + tmp_path / "imbrique.json", + {"config": {"affichage": {"theme": "clair"}}} + ) + + resultat = _get_champ(fichier, "config.affichage.theme") + + assert resultat == "clair" + + @patch("utils.persistance.st") + def test_cle_inexistante_retourne_vide(self, mock_st, tmp_path): + """Test qu'une cle inexistante retourne une chaine vide.""" + fichier = _creer_fichier_json(tmp_path / "data.json", {"a": 1}) + + resultat = _get_champ(fichier, "cle_absente") + + assert resultat == "" + + @patch("utils.persistance.st") + def test_cle_imbriquee_inexistante(self, mock_st, tmp_path): + """Test qu'une cle imbriquee manquante retourne une chaine vide.""" + fichier = _creer_fichier_json(tmp_path / "data2.json", {"a": {"b": 1}}) + + resultat = _get_champ(fichier, "a.c.d") + + assert resultat == "" + + @patch("utils.persistance.st") + def test_fichier_inexistant_retourne_vide(self, mock_st, tmp_path): + """Test qu'un fichier inexistant retourne une chaine vide.""" + fichier = tmp_path / "inexistant.json" + + resultat = _get_champ(fichier, "cle") + + assert resultat == "" + + @patch("utils.persistance.st") + def test_lecture_valeur_numerique(self, mock_st, tmp_path): + """Test la lecture d'une valeur numerique.""" + fichier = _creer_fichier_json(tmp_path / "num.json", {"score": 95}) + + resultat = _get_champ(fichier, "score") + + assert resultat == 95 + + @patch("utils.persistance.st") + def test_lecture_valeur_liste(self, mock_st, tmp_path): + """Test la lecture d'une valeur de type liste.""" + fichier = _creer_fichier_json( + tmp_path / "liste.json", {"items": ["x", "y", "z"]} + ) + + resultat = _get_champ(fichier, "items") + + assert resultat == ["x", "y", "z"] + + @patch("utils.persistance.st") + def test_lecture_valeur_dict(self, mock_st, tmp_path): + """Test la lecture d'un sous-dictionnaire entier.""" + fichier = _creer_fichier_json( + tmp_path / "dict.json", {"meta": {"auteur": "test", "version": 1}} + ) + + resultat = _get_champ(fichier, "meta") + + assert resultat == {"auteur": "test", "version": 1} + + @patch("utils.persistance.st") + def test_lecture_valeur_booleenne(self, mock_st, tmp_path): + """Test la lecture d'une valeur booleenne.""" + fichier = _creer_fichier_json(tmp_path / "bool.json", {"actif": False}) + + resultat = _get_champ(fichier, "actif") + + assert resultat is False + + @patch("utils.persistance.st") + def test_fichier_json_corrompu(self, mock_st, tmp_path): + """Test la gestion d'un fichier JSON corrompu.""" + fichier = tmp_path / "corrompu.json" + fichier.write_text("pas du json!!!", encoding="utf-8") + + resultat = _get_champ(fichier, "cle") + + assert resultat == "" + mock_st.error.assert_called_once() + + @patch("utils.persistance.st") + def test_fichier_json_double_encode(self, mock_st, tmp_path): + """Test la lecture d'un JSON double-encode (chaine JSON dans une chaine).""" + # Un fichier dont le contenu est un JSON valide encode en chaine + contenu_interne = json.dumps({"cle": "valeur"}) + fichier = tmp_path / "double.json" + fichier.write_text(json.dumps(contenu_interne), encoding="utf-8") + + resultat = _get_champ(fichier, "cle") + + assert resultat == "valeur" + + @patch("utils.persistance.st") + def test_fichier_json_double_encode_invalide(self, mock_st, tmp_path): + """Test avec un fichier contenant une chaine qui n'est pas du JSON valide.""" + fichier = tmp_path / "double_invalide.json" + # Ecrire une chaine JSON (pas un dict) dont le contenu n'est pas parseable en JSON + fichier.write_text(json.dumps("ceci n'est pas du json"), encoding="utf-8") + + resultat = _get_champ(fichier, "cle") + + assert resultat == "" + mock_st.error.assert_called_once() + + @patch("utils.persistance.st") + def test_fichier_contenu_non_dict(self, mock_st, tmp_path): + """Test avec un fichier JSON contenant une liste au lieu d'un dict.""" + fichier = tmp_path / "liste_racine.json" + fichier.write_text(json.dumps([1, 2, 3]), encoding="utf-8") + + resultat = _get_champ(fichier, "cle") + + assert resultat == "" + mock_st.error.assert_called_once() + + @patch("utils.persistance.st") + def test_cle_un_seul_niveau(self, mock_st, tmp_path): + """Test avec une cle sans point (un seul niveau).""" + fichier = _creer_fichier_json(tmp_path / "flat.json", {"racine": "val"}) + + resultat = _get_champ(fichier, "racine") + + assert resultat == "val" + + @patch("utils.persistance.st") + def test_cle_intermediaire_non_dict(self, mock_st, tmp_path): + """Test avec une cle intermediaire qui n'est pas un dictionnaire.""" + fichier = _creer_fichier_json( + tmp_path / "type_err.json", {"a": "chaine_pas_dict"} + ) + + resultat = _get_champ(fichier, "a.b") + + assert resultat == "" + + +# --------------------------------------------------------------------------- +# Tests pour _supprime_champ +# --------------------------------------------------------------------------- + + +class TestSupprimeChamp: + """Tests pour la fonction _supprime_champ (suppression d'un champ JSON).""" + + @patch("utils.persistance.st") + def test_suppression_cle_simple(self, mock_st, tmp_path): + """Test la suppression d'une cle de premier niveau.""" + fichier = _creer_fichier_json( + tmp_path / "supp.json", {"a": 1, "b": 2} + ) + + resultat = _supprime_champ(fichier, "a") + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert "a" not in contenu + assert contenu["b"] == 2 + + @patch("utils.persistance.st") + def test_suppression_cle_imbriquee(self, mock_st, tmp_path): + """Test la suppression d'une cle imbriquee 'a.b.c'.""" + fichier = _creer_fichier_json( + tmp_path / "supp_imbrique.json", + {"x": {"y": {"z": "a_supprimer", "w": "garder"}}} + ) + + resultat = _supprime_champ(fichier, "x.y.z") + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert "z" not in contenu["x"]["y"] + assert contenu["x"]["y"]["w"] == "garder" + + @patch("utils.persistance.st") + def test_suppression_cle_inexistante(self, mock_st, tmp_path): + """Test la suppression d'une cle qui n'existe pas.""" + fichier = _creer_fichier_json(tmp_path / "data.json", {"a": 1}) + + resultat = _supprime_champ(fichier, "cle_absente") + + # La fonction retourne True meme si la cle n'existait pas + assert resultat is True + + @patch("utils.persistance.st") + def test_fichier_json_corrompu(self, mock_st, tmp_path): + """Test la gestion d'un fichier JSON corrompu.""" + fichier = tmp_path / "corrompu.json" + fichier.write_text("json invalide!!!", encoding="utf-8") + + resultat = _supprime_champ(fichier, "cle") + + assert resultat is False + mock_st.error.assert_called_once() + + @patch("utils.persistance.st") + def test_suppression_preserv_structure(self, mock_st, tmp_path): + """Test que la structure restante est preservee apres suppression.""" + fichier = _creer_fichier_json( + tmp_path / "preserve.json", + {"config": {"a": 1, "b": 2}, "data": {"x": 10}} + ) + + resultat = _supprime_champ(fichier, "config.a") + + assert resultat is True + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu == {"config": {"b": 2}, "data": {"x": 10}} + + @patch("utils.persistance.st") + def test_chemin_invalide_intermediaire(self, mock_st, tmp_path): + """Test avec un chemin dont un element intermediaire n'est pas un dict.""" + fichier = _creer_fichier_json( + tmp_path / "invalide.json", + {"a": "chaine"} + ) + + # La fonction ne crash pas, le supprimer_cle_profonde retourne False + resultat = _supprime_champ(fichier, "a.b.c") + + assert resultat is True # La fonction retourne True meme si rien n'a ete supprime + + +# --------------------------------------------------------------------------- +# Tests pour les wrappers (maj_champ_statut, get_champ_statut, supprime_champ_statut) +# --------------------------------------------------------------------------- + + +class TestWrappersStatut: + """Tests pour les wrappers qui utilisent SAVE_STATUT_PATH.""" + + @patch("utils.persistance.st") + def test_maj_champ_statut(self, mock_st, tmp_path): + """Test que maj_champ_statut delegue a _maj_champ avec le bon fichier.""" + import utils.persistance as mod + + fichier_statut = tmp_path / "statut.json" + mod.SAVE_STATUT_PATH = fichier_statut + + from utils.persistance import maj_champ_statut + + resultat = maj_champ_statut("test.cle", "valeur_test") + + assert resultat is True + contenu = json.loads(fichier_statut.read_text(encoding="utf-8")) + assert contenu["test"]["cle"] == "valeur_test" + + @patch("utils.persistance.st") + def test_get_champ_statut(self, mock_st, tmp_path): + """Test que get_champ_statut delegue a _get_champ avec le bon fichier.""" + import utils.persistance as mod + + fichier_statut = _creer_fichier_json( + tmp_path / "statut.json", {"resultat": {"score": 88}} + ) + mod.SAVE_STATUT_PATH = fichier_statut + + from utils.persistance import get_champ_statut + + resultat = get_champ_statut("resultat.score") + + assert resultat == 88 + + @patch("utils.persistance.st") + def test_supprime_champ_statut(self, mock_st, tmp_path): + """Test que supprime_champ_statut delegue a _supprime_champ avec le bon fichier.""" + import utils.persistance as mod + + fichier_statut = _creer_fichier_json( + tmp_path / "statut.json", {"a": 1, "b": 2} + ) + mod.SAVE_STATUT_PATH = fichier_statut + + from utils.persistance import supprime_champ_statut + + supprime_champ_statut("a") + + contenu = json.loads(fichier_statut.read_text(encoding="utf-8")) + assert "a" not in contenu + assert contenu["b"] == 2 + + @patch("utils.persistance.st") + def test_maj_puis_get_champ_statut(self, mock_st, tmp_path): + """Test le cycle complet: ecriture puis relecture.""" + import utils.persistance as mod + + fichier_statut = tmp_path / "statut_cycle.json" + mod.SAVE_STATUT_PATH = fichier_statut + + from utils.persistance import get_champ_statut, maj_champ_statut + + maj_champ_statut("analyse.resultat", "termine") + resultat = get_champ_statut("analyse.resultat") + + assert resultat == "termine" + + @patch("utils.persistance.st") + def test_maj_puis_supprime_puis_get(self, mock_st, tmp_path): + """Test le cycle: ecriture, suppression, relecture.""" + import utils.persistance as mod + + fichier_statut = tmp_path / "statut_cycle2.json" + mod.SAVE_STATUT_PATH = fichier_statut + + from utils.persistance import ( + get_champ_statut, + maj_champ_statut, + supprime_champ_statut, + ) + + maj_champ_statut("temporaire", "a_supprimer") + supprime_champ_statut("temporaire") + resultat = get_champ_statut("temporaire") + + assert resultat == "" + + +# --------------------------------------------------------------------------- +# Tests pour get_full_structure +# --------------------------------------------------------------------------- + + +class TestGetFullStructure: + """Tests pour la fonction get_full_structure.""" + + @patch("utils.persistance.st") + def test_lecture_structure_complete(self, mock_st, tmp_path): + """Test la lecture de la structure JSON complete.""" + import utils.persistance as mod + + structure = {"config": {"theme": "sombre"}, "data": [1, 2, 3]} + fichier = _creer_fichier_json(tmp_path / "full.json", structure) + mod.SAVE_STATUT_PATH = fichier + + resultat = get_full_structure() + + assert resultat == structure + + @patch("utils.persistance.st") + def test_fichier_inexistant_retourne_none(self, mock_st, tmp_path): + """Test qu'un fichier inexistant retourne None.""" + import utils.persistance as mod + + mod.SAVE_STATUT_PATH = tmp_path / "inexistant.json" + + resultat = get_full_structure() + + assert resultat is None + + @patch("utils.persistance.st") + def test_fichier_json_corrompu(self, mock_st, tmp_path): + """Test la gestion d'un fichier JSON corrompu.""" + import utils.persistance as mod + + fichier = tmp_path / "corrompu.json" + fichier.write_text("{json cassé", encoding="utf-8") + mod.SAVE_STATUT_PATH = fichier + + resultat = get_full_structure() + + assert resultat is None + mock_st.error.assert_called_once() + + @patch("utils.persistance.st") + def test_fichier_vide(self, mock_st, tmp_path): + """Test la gestion d'un fichier JSON vide.""" + import utils.persistance as mod + + fichier = tmp_path / "vide.json" + fichier.write_text("", encoding="utf-8") + mod.SAVE_STATUT_PATH = fichier + + resultat = get_full_structure() + + assert resultat is None + mock_st.error.assert_called_once() + + @patch("utils.persistance.st") + def test_structure_complexe(self, mock_st, tmp_path): + """Test avec une structure JSON riche et imbriquee.""" + import utils.persistance as mod + + structure = { + "session": { + "id": "abc-123", + "analyse": { + "graphe": "charge", + "indicateurs": [ + {"nom": "IHH", "valeur": 0.65}, + {"nom": "IVC", "valeur": 0.32}, + ], + }, + }, + "metadata": {"version": 2, "date": "2025-06-15"}, + } + fichier = _creer_fichier_json(tmp_path / "complexe.json", structure) + mod.SAVE_STATUT_PATH = fichier + + resultat = get_full_structure() + + assert resultat == structure + assert resultat["session"]["analyse"]["indicateurs"][0]["nom"] == "IHH" + + +# --------------------------------------------------------------------------- +# Tests d'integration (cycle complet) +# --------------------------------------------------------------------------- + + +class TestIntegrationCycleComplet: + """Tests d'integration verifiant les operations de bout en bout.""" + + @patch("utils.persistance.st") + def test_cycle_creation_lecture_suppression(self, mock_st, tmp_path): + """Test un cycle complet: creation, lecture, modification, suppression.""" + fichier = tmp_path / "integration.json" + + # 1. Creer un champ + assert _maj_champ(fichier, "projet.nom", "FabNum") is True + + # 2. Lire le champ + assert _get_champ(fichier, "projet.nom") == "FabNum" + + # 3. Ajouter un autre champ + assert _maj_champ(fichier, "projet.version", "1.0") is True + + # 4. Verifier les deux champs + assert _get_champ(fichier, "projet.nom") == "FabNum" + assert _get_champ(fichier, "projet.version") == "1.0" + + # 5. Supprimer un champ + assert _supprime_champ(fichier, "projet.version") is True + + # 6. Verifier que le champ est bien supprime + assert _get_champ(fichier, "projet.version") == "" + assert _get_champ(fichier, "projet.nom") == "FabNum" + + @patch("utils.persistance.st") + def test_multiple_cles_imbriquees(self, mock_st, tmp_path): + """Test avec plusieurs cles imbriquees ajoutees incrementalement.""" + fichier = tmp_path / "multi.json" + + _maj_champ(fichier, "a.b.c", "v1") + _maj_champ(fichier, "a.b.d", "v2") + _maj_champ(fichier, "a.e", "v3") + _maj_champ(fichier, "f", "v4") + + assert _get_champ(fichier, "a.b.c") == "v1" + assert _get_champ(fichier, "a.b.d") == "v2" + assert _get_champ(fichier, "a.e") == "v3" + assert _get_champ(fichier, "f") == "v4" + + contenu = json.loads(fichier.read_text(encoding="utf-8")) + assert contenu == { + "a": {"b": {"c": "v1", "d": "v2"}, "e": "v3"}, + "f": "v4", + } + + @patch("utils.persistance.st") + def test_serialisation_date_puis_relecture(self, mock_st, tmp_path): + """Test que la date serialisee est relue comme chaine ISO.""" + fichier = tmp_path / "date_cycle.json" + + _maj_champ(fichier, "date_debut", date(2025, 1, 1)) + + resultat = _get_champ(fichier, "date_debut") + + assert resultat == "2025-01-01" diff --git a/tests/unit/test_translations.py b/tests/unit/test_translations.py new file mode 100644 index 0000000..7c0631b --- /dev/null +++ b/tests/unit/test_translations.py @@ -0,0 +1,364 @@ +"""Tests unitaires pour le module utils.translations. + +Ces tests vérifient le chargement des traductions, la résolution +hiérarchique des clés et la gestion des erreurs. +""" + +import json +from unittest.mock import patch + +import pytest + + +class _SessionState(dict): + """Simule st.session_state en tant que dict avec accès par attribut.""" + + def __getattr__(self, key): + try: + return self[key] + except KeyError as err: + raise AttributeError(key) from err + + def __setattr__(self, key, value): + self[key] = value + + def __delattr__(self, key): + try: + del self[key] + except KeyError as err: + raise AttributeError(key) from err + + +class TestLoadTranslations: + """Tests pour la fonction load_translations.""" + + def test_load_translations_fr(self, tmp_path, monkeypatch): + """Test le chargement du fichier de traduction français.""" + locales_dir = tmp_path / "assets" / "locales" + locales_dir.mkdir(parents=True) + translations_data = {"header": {"title": "Titre test"}} + (locales_dir / "fr.json").write_text( + json.dumps(translations_data, ensure_ascii=False), encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + from utils.translations import load_translations + + result = load_translations("fr") + + assert result == translations_data + assert result["header"]["title"] == "Titre test" + + def test_load_translations_default_lang(self, tmp_path, monkeypatch): + """Test que la langue par défaut est le français.""" + locales_dir = tmp_path / "assets" / "locales" + locales_dir.mkdir(parents=True) + translations_data = {"app": {"title": "Mon app"}} + (locales_dir / "fr.json").write_text( + json.dumps(translations_data), encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + from utils.translations import load_translations + + result = load_translations() + + assert result == translations_data + + def test_load_translations_missing_file(self, tmp_path, monkeypatch): + """Test le retour d'un dict vide si le fichier n'existe pas.""" + locales_dir = tmp_path / "assets" / "locales" + locales_dir.mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + from utils.translations import load_translations + + result = load_translations("zz") + + assert result == {} + + def test_load_translations_invalid_json(self, tmp_path, monkeypatch): + """Test le retour d'un dict vide en cas de JSON invalide.""" + locales_dir = tmp_path / "assets" / "locales" + locales_dir.mkdir(parents=True) + (locales_dir / "broken.json").write_text("{invalid json!!}", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + from utils.translations import load_translations + + result = load_translations("broken") + + assert result == {} + + def test_load_translations_other_lang(self, tmp_path, monkeypatch): + """Test le chargement d'une langue autre que le français.""" + locales_dir = tmp_path / "assets" / "locales" + locales_dir.mkdir(parents=True) + en_data = {"header": {"title": "English title"}} + (locales_dir / "en.json").write_text( + json.dumps(en_data), encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + from utils.translations import load_translations + + result = load_translations("en") + + assert result == en_data + + def test_load_translations_unicode(self, tmp_path, monkeypatch): + """Test le chargement de traductions contenant des caractères Unicode.""" + locales_dir = tmp_path / "assets" / "locales" + locales_dir.mkdir(parents=True) + data = {"footer": {"eco_note": "Calculs CO\u2082 via"}} + (locales_dir / "fr.json").write_text( + json.dumps(data, ensure_ascii=False), encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + from utils.translations import load_translations + + result = load_translations("fr") + + assert "\u2082" in result["footer"]["eco_note"] + + +class TestGetTranslation: + """Tests pour la fonction get_translation.""" + + @pytest.fixture(autouse=True) + def _setup_session_state(self): + """Prépare un mock de st.session_state pour chaque test.""" + self.mock_state = _SessionState( + { + "translations": { + "header": {"title": "Titre", "subtitle": "Sous-titre"}, + "app": {"title": "Mon app", "description": "Description"}, + "simple_key": "Valeur simple", + }, + "lang": "fr", + } + ) + with patch("utils.translations.st") as mock_st: + mock_st.session_state = self.mock_state + yield + + def test_get_simple_key(self): + """Test la récupération d'une clé simple (non hiérarchique).""" + from utils.translations import get_translation + + result = get_translation("simple_key") + + assert result == "Valeur simple" + + def test_get_nested_key(self): + """Test la récupération d'une clé hiérarchique (dot-separated).""" + from utils.translations import get_translation + + result = get_translation("header.title") + + assert result == "Titre" + + def test_get_second_nested_key(self): + """Test la récupération d'une autre clé hiérarchique.""" + from utils.translations import get_translation + + result = get_translation("app.description") + + assert result == "Description" + + def test_get_deeply_nested_key(self): + """Test la récupération d'une clé avec trois niveaux de profondeur.""" + self.mock_state["translations"]["level1"] = { + "level2": {"level3": "Profond"} + } + + from utils.translations import get_translation + + result = get_translation("level1.level2.level3") + + assert result == "Profond" + + def test_get_missing_key_returns_fallback(self): + """Test qu'une clé manquante retourne la chaîne de repli.""" + from utils.translations import get_translation + + result = get_translation("inexistant.key") + + assert result == "\u2297\u2907 inexistant.key \u2906\u2297" + + def test_get_partial_path_returns_fallback(self): + """Test qu'un chemin partiel incorrect retourne la chaîne de repli.""" + from utils.translations import get_translation + + result = get_translation("header.nonexistent") + + assert result == "\u2297\u2907 header.nonexistent \u2906\u2297" + + def test_get_key_traverses_non_dict(self): + """Test le cas où la traversée atteint une valeur non-dict avant la fin.""" + from utils.translations import get_translation + + # "header.title" est une string, donc "header.title.extra" doit échouer + result = get_translation("header.title.extra") + + assert result == "\u2297\u2907 header.title.extra \u2906\u2297" + + def test_get_translation_empty_translations(self): + """Test le retour de la chaîne de repli quand les traductions sont vides.""" + self.mock_state["translations"] = {} + + from utils.translations import get_translation + + result = get_translation("header.title") + + assert result == "\u2297\u2907 header.title \u2906\u2297" + + def test_get_translation_returns_dict_for_partial_key(self): + """Test qu'une clé partielle retourne le sous-dict correspondant.""" + from utils.translations import get_translation + + result = get_translation("header") + + assert isinstance(result, dict) + assert result["title"] == "Titre" + + def test_underscore_alias(self): + """Test que _ est un alias pour get_translation.""" + from utils.translations import _, get_translation + + assert _ is get_translation + + +class TestGetTranslationAutoInit: + """Tests pour l'initialisation automatique dans get_translation.""" + + def test_auto_init_when_no_translations(self, tmp_path, monkeypatch): + """Test que get_translation initialise les traductions si absentes.""" + locales_dir = tmp_path / "assets" / "locales" + locales_dir.mkdir(parents=True) + data = {"auto": {"init": "Valeur auto"}} + (locales_dir / "fr.json").write_text( + json.dumps(data), encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + mock_state = _SessionState() + + with patch("utils.translations.st") as mock_st: + mock_st.session_state = mock_state + + from utils.translations import get_translation + + result = get_translation("auto.init") + + assert result == "Valeur auto" + assert mock_state["lang"] == "fr" + assert mock_state["translations"] == data + + +class TestSetLanguage: + """Tests pour la fonction set_language.""" + + def test_set_language_updates_state(self, tmp_path, monkeypatch): + """Test que set_language met à jour la langue et les traductions.""" + locales_dir = tmp_path / "assets" / "locales" + locales_dir.mkdir(parents=True) + en_data = {"greeting": "Hello"} + (locales_dir / "en.json").write_text( + json.dumps(en_data), encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + mock_state = _SessionState() + + with patch("utils.translations.st") as mock_st: + mock_st.session_state = mock_state + + from utils.translations import set_language + + set_language("en") + + assert mock_state["lang"] == "en" + assert mock_state["translations"] == en_data + + def test_set_language_default_fr(self, tmp_path, monkeypatch): + """Test que set_language utilise le français par défaut.""" + locales_dir = tmp_path / "assets" / "locales" + locales_dir.mkdir(parents=True) + fr_data = {"bonjour": "Salut"} + (locales_dir / "fr.json").write_text( + json.dumps(fr_data), encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + mock_state = _SessionState() + + with patch("utils.translations.st") as mock_st: + mock_st.session_state = mock_state + + from utils.translations import set_language + + set_language() + + assert mock_state["lang"] == "fr" + assert mock_state["translations"] == fr_data + + def test_set_language_missing_file(self, tmp_path, monkeypatch): + """Test que set_language gère un fichier manquant sans erreur.""" + locales_dir = tmp_path / "assets" / "locales" + locales_dir.mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + mock_state = _SessionState() + + with patch("utils.translations.st") as mock_st: + mock_st.session_state = mock_state + + from utils.translations import set_language + + set_language("xx") + + assert mock_state["lang"] == "xx" + assert mock_state["translations"] == {} + + +class TestInitTranslations: + """Tests pour la fonction init_translations.""" + + def test_init_when_no_translations(self, tmp_path, monkeypatch): + """Test que init_translations charge le français si pas de traductions.""" + locales_dir = tmp_path / "assets" / "locales" + locales_dir.mkdir(parents=True) + fr_data = {"init": "oui"} + (locales_dir / "fr.json").write_text( + json.dumps(fr_data), encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + mock_state = _SessionState() + + with patch("utils.translations.st") as mock_st: + mock_st.session_state = mock_state + + from utils.translations import init_translations + + init_translations() + + assert mock_state["lang"] == "fr" + assert mock_state["translations"] == fr_data + + def test_init_skips_if_already_loaded(self): + """Test que init_translations ne recharge pas si déjà présent.""" + existing = {"already": "loaded"} + mock_state = _SessionState({"translations": existing, "lang": "fr"}) + + with patch("utils.translations.st") as mock_st: + mock_st.session_state = mock_state + + from utils.translations import init_translations + + init_translations() + + # Les traductions ne doivent pas avoir été modifiées + assert mock_state["translations"] is existing diff --git a/tests/unit/test_widgets.py b/tests/unit/test_widgets.py index dbbd15a..83194bc 100644 --- a/tests/unit/test_widgets.py +++ b/tests/unit/test_widgets.py @@ -1,11 +1,10 @@ -""" -Tests unitaires pour le module utils.widgets. +"""Tests unitaires pour le module utils.widgets. Ces tests vérifient le fonctionnement des widgets HTML personnalisés. """ -import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch + from utils.widgets import html_expander @@ -104,7 +103,7 @@ class TestHtmlExpander: @patch('utils.widgets.st') @patch('utils.widgets.markdown') @patch('utils.widgets.logger') - def test_expander_markdown_other_error(self, mock_logger, mock_markdown, mock_st): + def test_expander_markdown_other_error(self, mock_logger, mock_markdown, _mock_st): """Test la gestion d'autres erreurs lors de la conversion markdown.""" # Simuler une autre exception mock_markdown.markdown.side_effect = ValueError("Invalid markdown")