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

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

477 lines
17 KiB
Python

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