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:
parent
6d2e877341
commit
8e2556c2b0
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
476
tests/unit/test_fiche_utils.py
Normal file
476
tests/unit/test_fiche_utils.py
Normal 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
|
||||
@ -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
548
tests/unit/test_generer.py
Normal 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)
|
||||
@ -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"
|
||||
|
||||
@ -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
577
tests/unit/test_ics.py
Normal 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
785
tests/unit/test_ihh.py
Normal 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
285
tests/unit/test_isg.py
Normal 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
342
tests/unit/test_ivc.py
Normal 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
|
||||
@ -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
213
tests/unit/test_pastille.py
Normal 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") == ""
|
||||
795
tests/unit/test_persistance.py
Normal file
795
tests/unit/test_persistance.py
Normal 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"
|
||||
364
tests/unit/test_translations.py
Normal file
364
tests/unit/test_translations.py
Normal 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
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user