test(unit): +381 tests unitaires — couverture 16%→35%

- 9 nouveaux fichiers de tests (persistance, translations, fiches, indices, IHH)
- Enrichissement des tests existants (graph_utils, gitea, widgets, tickets)
- 67→448 tests, tous passent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Stéphan Peccini 2026-03-02 11:52:21 +01:00
parent 6d2e877341
commit 8e2556c2b0
Signed by: stephan
GPG Key ID: 3A9774E9CCBF3501
16 changed files with 5121 additions and 80 deletions

View File

@ -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)

View File

@ -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

View File

@ -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("<html></html>", 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("<html></html>", 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("<html></html>", 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("<html></html>", 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("<html></html>", 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("<html></html>", 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("<html></html>", 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("<html></html>", 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

View File

@ -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

548
tests/unit/test_generer.py Normal file
View File

@ -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 '<span class="math-inline">' in resultat
assert "<math" in resultat
def test_formule_display_simple(self):
"""Test la conversion d'une formule LaTeX display (bloc)."""
texte = "Voici la formule :\n$$E = mc^2$$\nFin."
resultat = remplacer_latex_par_mathml(texte)
assert "$$" not in resultat
assert '<div class="math-block">' in resultat
assert "<math" in resultat
def test_formule_inline_fraction(self):
"""Test la conversion d'une fraction inline."""
texte = "Le ratio est $\\frac{a}{b}$ ici."
resultat = remplacer_latex_par_mathml(texte)
assert "$" not in resultat
assert '<span class="math-inline">' in resultat
assert "<math" in resultat
def test_formule_display_somme(self):
"""Test la conversion d'une somme en mode display."""
texte = "$$\\sum_{i=1}^{n} x_i$$"
resultat = remplacer_latex_par_mathml(texte)
assert "$$" not in resultat
assert '<div class="math-block">' 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('<span class="math-inline">') == 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 '<span class="math-inline">' in resultat
assert '<div class="math-block">' 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 '<div class="math-block">' 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 <code>
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 <pre>
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 '<span class="math-inline">' in resultat
assert "<math" in resultat
def test_texte_vide(self):
"""Test avec un texte vide."""
resultat = remplacer_latex_par_mathml("")
assert resultat == ""
# ──────────────────────────────────────────────
# markdown_to_html_rgaa
# ──────────────────────────────────────────────
class TestMarkdownToHtmlRgaa:
"""Tests pour la fonction markdown_to_html_rgaa."""
def test_paragraphe_simple(self):
"""Test la conversion d'un paragraphe simple."""
md = "Un paragraphe simple."
resultat = markdown_to_html_rgaa(md, None)
assert "<p>" 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 "<h2>" 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 "<ul>" in resultat
assert "<li>" in resultat
assert resultat.count("<li>") == 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 "<strong>" in resultat
assert "<em>" 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 "<p>" in resultat
assert "<table" in resultat
assert "Paragraphe avant." in resultat
assert "Paragraphe apres." in resultat
# ──────────────────────────────────────────────
# rendu_html
# ──────────────────────────────────────────────
class TestRenduHtml:
"""Tests pour la fonction rendu_html."""
def test_structure_section_region(self):
"""Test que le HTML genere contient une section avec role=region."""
md = "# Titre principal\n\nContenu intro."
resultat = rendu_html(md)
html = "\n".join(resultat)
assert '<section role="region"' in html
assert "</section>" 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 "<h1" in html
assert "Mon titre" in html
def test_aria_labelledby_sur_section(self):
"""Test que aria-labelledby est lie au titre."""
md = "# Titre Test\n\nContenu."
resultat = rendu_html(md)
html = "\n".join(resultat)
assert 'aria-labelledby="titre-test"' in html
assert 'id="titre-test"' in html
def test_titre_id_normalise(self):
"""Test que l'ID du titre est normalise (minuscules, tirets)."""
md = "# Mon Titre Complexe !\n\nContenu."
resultat = rendu_html(md)
html = "\n".join(resultat)
# L'ID doit etre en minuscules avec des tirets, ponctuation supprimee
assert 'id="mon-titre-complexe"' in html
def test_section_n2_dans_details(self):
"""Test que les sous-sections (h2) sont dans des balises details."""
md = "# Titre\n\n## Sous-section\n\nContenu sous-section."
resultat = rendu_html(md)
html = "\n".join(resultat)
assert "<details>" in html
assert "<summary>" 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("<details>")
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("<details>") == 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('<section role="region"')
def test_dernier_element_est_section_fermante(self):
"""Test que le dernier element est la balise section fermante."""
md = "# Titre\n\nContenu."
resultat = rendu_html(md)
assert resultat[-1] == "</section>"
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 <h1>, les suivants en <h2>
assert "<h1" in html
assert "<h2>" 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 '<h1 id="fiche">fiche</h1>' 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 "<math" in html
def test_latex_dans_section_n2_converti(self):
"""Test que le LaTeX dans une sous-section est converti en MathML."""
md = "# Titre\n\n## Calcul\n\nLa formule est $a + b$."
resultat = rendu_html(md)
html = "\n".join(resultat)
assert "<math" in html
def test_tableau_dans_section_n2(self):
"""Test qu'un tableau dans une sous-section est accessible."""
md = "# Titre\n\n## Donnees\n\n| A | B |\n| --- | --- |\n| 1 | 2 |"
resultat = rendu_html(md)
html = "\n".join(resultat)
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table")
assert table is not None
assert table.get("role") == "table"
# ──────────────────────────────────────────────
# render_fiche_markdown
# ──────────────────────────────────────────────
class TestRenderFicheMarkdown:
"""Tests pour la fonction render_fiche_markdown (fiche_utils)."""
def _make_md(self, meta: dict, body: str) -> 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)

View File

@ -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"

View File

@ -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

577
tests/unit/test_ics.py Normal file
View File

@ -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<!---- AUTO-BEGIN:PIVOT -->\nancien pivot\n<!---- AUTO-END:PIVOT -->\n")
parts.append("\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->\nancien tableau\n<!---- AUTO-END:TABLEAU-FINAL -->\n")
return "\n".join(parts)
def test_remplacement_complet(self):
"""Test que build_dynamic_sections remplace les sections dynamiques."""
pair = _sample_pair()
md = self._make_full_md([pair])
result = build_dynamic_sections(md)
# Le pivot et la synthese doivent etre generes
assert "## Lithium" in result # pivot
assert "| Composant | Minerai | ICS |" in result # synthese
def test_placeholders_remplaces(self):
"""Test que les placeholders sont remplaces dans les segments."""
pair = _sample_pair()
md = self._make_full_md([pair])
result = build_dynamic_sections(md)
assert "Batterie" in result
assert "Lithium" in result
assert "{{ composant }}" not in result
def test_ics_remplace_dans_segment(self):
"""Test que la valeur ICS est mise a jour dans le segment."""
pair = _sample_pair(ics=0.72)
md = self._make_full_md([pair])
result = build_dynamic_sections(md)
assert "ICS = 0.72" in result
assert "ICS = 0.00" not in result
def test_marqueurs_pivot_preserves(self):
"""Test que les marqueurs AUTO-BEGIN/END:PIVOT sont preserves."""
pair = _sample_pair()
md = self._make_full_md([pair])
result = build_dynamic_sections(md)
assert "<!---- AUTO-BEGIN:PIVOT -->" in result
assert "<!---- AUTO-END:PIVOT -->" in result
def test_marqueurs_tableau_final_preserves(self):
"""Test que les marqueurs AUTO-BEGIN/END:TABLEAU-FINAL sont preserves."""
pair = _sample_pair()
md = self._make_full_md([pair])
result = build_dynamic_sections(md)
assert "<!---- AUTO-BEGIN:TABLEAU-FINAL -->" in result
assert "<!---- AUTO-END:TABLEAU-FINAL -->" in result
def test_pas_de_bloc_yaml_retourne_original(self):
"""Test qu'un markdown sans bloc yaml est retourne tel quel."""
md = "# Titre\n\nTexte sans bloc yaml."
result = build_dynamic_sections(md)
assert "Texte sans bloc yaml." in result
def test_texte_vide(self):
"""Test avec un texte vide."""
result = build_dynamic_sections("")
assert result == ""
def test_plusieurs_paires(self):
"""Test avec plusieurs paires genere un tableau complet."""
pair1 = _sample_pair(composant="Batterie", minerai="Lithium", ics=0.65,
f_tech=0.80, delai=0.50, cout=0.70)
pair2 = _sample_pair(composant="Ecran", minerai="Indium", ics=0.80,
f_tech=0.90, delai=0.60, cout=0.85)
md = self._make_full_md([pair1, pair2])
result = build_dynamic_sections(md)
assert "Batterie" in result
assert "Ecran" in result
assert "Lithium" in result
assert "Indium" in result
def test_unicode_normalise(self):
"""Test que les caracteres Unicode sont normalises dans le resultat."""
pair = _sample_pair()
md = self._make_full_md([pair])
# Injecter un caractere Unicode non-normalise
md = md.replace("Batterie", "Batterie\u00b2")
result = build_dynamic_sections(md)
# Le resultat doit etre normalise (superscript 2 -> "2")
assert isinstance(result, str)
def test_ancien_contenu_pivot_remplace(self):
"""Test que l'ancien contenu entre les marqueurs PIVOT est remplace."""
pair = _sample_pair()
md = self._make_full_md([pair])
result = build_dynamic_sections(md)
assert "ancien pivot" not in result
def test_ancien_contenu_tableau_final_remplace(self):
"""Test que l'ancien contenu entre les marqueurs TABLEAU-FINAL est remplace."""
pair = _sample_pair()
md = self._make_full_md([pair])
result = build_dynamic_sections(md)
assert "ancien tableau" not in result
def test_bloc_yaml_sans_pair_ignore(self):
"""Test qu'un bloc yaml sans la cle 'pair' ne casse pas le traitement."""
md = textwrap.dedent("""\
# Presentation
# Criticite par couple Composant -> Minerai
```yaml
metadata:
version: 1
```
Texte d'analyse.
""")
result = build_dynamic_sections(md)
# Aucune paire trouvee, le texte original est retourne
assert "Texte d'analyse." in result

785
tests/unit/test_ihh.py Normal file
View File

@ -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"
"<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n"
"ancien contenu\n"
"<!---- AUTO-END:TABLEAU-FINAL -->"
)
resultat = build_ihh_sections(md)
assert "# Tableaux de synth\u00e8se" in resultat
assert "<!---- AUTO-BEGIN:TABLEAU-FINAL -->" in resultat
assert "<!---- AUTO-END:TABLEAU-FINAL -->" 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"
"<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n"
"contenu a remplacer\n"
"<!---- AUTO-END:TABLEAU-FINAL -->"
)
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"
"<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n"
"placeholder\n"
"<!---- AUTO-END:TABLEAU-FINAL -->"
)
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

285
tests/unit/test_isg.py Normal file
View File

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

342
tests/unit/test_ivc.py Normal file
View File

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

View File

@ -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:

213
tests/unit/test_pastille.py Normal file
View File

@ -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") == ""

View File

@ -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"

View File

@ -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

View File

@ -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")