Compare commits
3 Commits
b97bbfe0ed
...
cf91d0b69e
| Author | SHA1 | Date | |
|---|---|---|---|
| cf91d0b69e | |||
| 8e2556c2b0 | |||
| 6d2e877341 |
8
.gitignore
vendored
8
.gitignore
vendored
@ -46,6 +46,14 @@ htmlcov/
|
||||
.coverage
|
||||
bandit-report.json
|
||||
bandit-report.txt
|
||||
.bandit
|
||||
requirements-dev.txt
|
||||
SECURITY_AUDIT.md
|
||||
security_check.sh
|
||||
|
||||
# Fichiers temporaires batch_ia
|
||||
batch_ia/temp_sections/
|
||||
|
||||
# Artefacts de tests exploratoires
|
||||
test-fabnum-*.png
|
||||
test_schema.txt
|
||||
|
||||
65
CLAUDE.md
Normal file
65
CLAUDE.md
Normal file
@ -0,0 +1,65 @@
|
||||
# FabNum
|
||||
|
||||
## Description
|
||||
|
||||
Application Streamlit d'analyse de risques géopolitiques pour les chaînes d'approvisionnement numériques. Modélise les dépendances (produits, composants, minerais, pays) sous forme de graphe orienté et calcule des indices de criticité (IHH, ICS, IVC, ISG).
|
||||
|
||||
## Architecture
|
||||
|
||||
- `fabnum.py` — Point d'entrée Streamlit (page d'accueil, instructions)
|
||||
- `config.py` — Variables d'environnement (Gitea, fiches criticité, `.env` / `.env.local`)
|
||||
- `app/` — Pages Streamlit organisées par fonctionnalité :
|
||||
- `analyse/` — Analyse de criticité et visualisation Sankey
|
||||
- `fiches/` — Gestion des fiches de criticité (IHH, ICS, IVC, ISG)
|
||||
- `visualisations/` — Graphes et visualisations interactives
|
||||
- `ia_nalyse/` — Analyse assistée par IA
|
||||
- `plan_d_action/` — Plans d'action et recommandations
|
||||
- `personnalisation/` — Personnalisation de l'interface
|
||||
- `components/` — Composants UI partagés (header, footer, sidebar, connexion)
|
||||
- `utils/` — Utilitaires métier (graphe, persistance, Gitea, logs, traductions)
|
||||
- `scripts/` — Scripts d'ingestion et génération (auto_ingest, generer_analyse)
|
||||
- `tests/` — Tests pytest (unit, integration, fixtures)
|
||||
- `IA/`, `batch_ia/` — Modules IA (priorité basse, exclus du linting)
|
||||
|
||||
## Stack technique
|
||||
|
||||
- **Python** >= 3.10
|
||||
- **Streamlit** 1.45 — Interface web
|
||||
- **NetworkX** + **PyGraphviz** — Modélisation graphe (format DOT)
|
||||
- **Plotly** / **Altair** — Visualisations
|
||||
- **Pandas** / **NumPy** — Traitement de données
|
||||
- **Requests** — API Gitea (fiches, schéma)
|
||||
- **Jinja2** / **pypandoc** — Génération PDF
|
||||
- **pytest** — Tests (8 fichiers, tests/unit/)
|
||||
- **ruff** — Linter et formateur
|
||||
|
||||
## Conventions de code
|
||||
|
||||
- Linter : `ruff` (config complète dans `pyproject.toml`, line-length=120)
|
||||
- Tests : `pytest` avec markers `unit` et `integration`
|
||||
- Style docstrings : convention Google, en francais
|
||||
- Variable graphe : `G` (convention NetworkX, ignoree par N803/N806)
|
||||
- Exclusions ruff : `IA/`, `batch_ia/`, `pgpt/` (priorite basse)
|
||||
- Imports tries par isort (first-party : app, utils, batch_ia)
|
||||
- Quotes doubles, indentation espaces
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
```bash
|
||||
streamlit run fabnum.py # Lancer l'application
|
||||
python -m pytest tests/unit/ -v # Lancer les tests unitaires
|
||||
python -m pytest tests/ -v # Tous les tests
|
||||
ruff check . # Verifier le code
|
||||
ruff check --fix . # Corriger automatiquement
|
||||
ruff format . # Formater le code
|
||||
```
|
||||
|
||||
## Points d'attention
|
||||
|
||||
- `fabnum.py` doit appeler `st.set_page_config()` avant tout import de modules app (E402 ignore)
|
||||
- `utils.persistance.update_session_paths()` est appele en tout premier dans fabnum.py
|
||||
- Les variables d'environnement critiques (FICHE_IHH, ICS, IVC, ISG) sont obligatoires (OSError si absentes)
|
||||
- Le graphe est lu depuis un fichier DOT (Graphviz) et manipule via NetworkX DiGraph
|
||||
- Deux environnements : `dev` (defaut) et `public` (detecte via header Nginx X-Environment)
|
||||
- Donnees stockees sur Gitea (fiches criticite, schema de dependances)
|
||||
- Les tests utilisent des fixtures dans `tests/fixtures/` (sample_graph.dot, config YAML)
|
||||
@ -1,18 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
import requests
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
# Import des fonctions de génération
|
||||
from app.fiches.generer import (
|
||||
generer_fiche
|
||||
)
|
||||
from app.fiches.generer import generer_fiche
|
||||
from app.fiches.utils.fiche_utils import load_seuils
|
||||
from config import FICHES_CRITICITE, GITEA_TOKEN
|
||||
from utils.gitea import charger_arborescence_fiches
|
||||
from config import GITEA_TOKEN, FICHES_CRITICITE
|
||||
|
||||
# Configuration du logging
|
||||
logging.basicConfig(
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Script d'analyse de la structure du graphe DOT pour comprendre
|
||||
"""Script d'analyse de la structure du graphe DOT pour comprendre
|
||||
comment intégrer l'ISG dans le générateur de template.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from networkx.drawing.nx_agraph import read_dot
|
||||
|
||||
# Chemins
|
||||
@ -16,14 +14,14 @@ GRAPH_PATH = BASE_DIR / "graphe.dot"
|
||||
def analyze_graph_structure(dot_path):
|
||||
"""Analyse la structure du graphe et affiche ses caractéristiques."""
|
||||
print(f"Analyse du fichier: {dot_path}")
|
||||
|
||||
|
||||
# Lire le graphe
|
||||
G = read_dot(dot_path)
|
||||
|
||||
|
||||
# Informations de base
|
||||
print(f"Nombre total de nœuds: {len(G.nodes())}")
|
||||
print(f"Nombre total d'arêtes: {len(G.edges())}")
|
||||
|
||||
|
||||
# Analyse des attributs des nœuds
|
||||
node_attrs = {}
|
||||
for node, attrs in G.nodes(data=True):
|
||||
@ -31,13 +29,13 @@ def analyze_graph_structure(dot_path):
|
||||
if key not in node_attrs:
|
||||
node_attrs[key] = set()
|
||||
node_attrs[key].add(attrs[key])
|
||||
|
||||
|
||||
print("\nAttributs des nœuds:")
|
||||
for attr, values in node_attrs.items():
|
||||
print(f"- {attr}: {len(values)} valeurs différentes")
|
||||
if len(values) < 20: # Afficher seulement si le nombre de valeurs est raisonnable
|
||||
print(f" Valeurs: {', '.join(sorted(values))}")
|
||||
|
||||
|
||||
# Analyse des niveaux (si l'attribut existe)
|
||||
if 'level' in node_attrs:
|
||||
print("\nAnalyse par niveau:")
|
||||
@ -48,7 +46,7 @@ def analyze_graph_structure(dot_path):
|
||||
if level not in levels:
|
||||
levels[level] = []
|
||||
levels[level].append(node)
|
||||
|
||||
|
||||
for level, nodes in sorted(levels.items()):
|
||||
print(f"- Niveau {level}: {len(nodes)} nœuds")
|
||||
# Afficher quelques exemples
|
||||
@ -56,14 +54,14 @@ def analyze_graph_structure(dot_path):
|
||||
print(f" Exemples: {', '.join(nodes)}")
|
||||
else:
|
||||
print(f" Exemples: {', '.join(nodes[:3])}... (et {len(nodes)-3} autres)")
|
||||
|
||||
|
||||
# Analyse des attributs ISG
|
||||
print("\nRecherche des attributs ISG:")
|
||||
isg_nodes = []
|
||||
for node, attrs in G.nodes(data=True):
|
||||
if 'isg' in attrs:
|
||||
isg_nodes.append((node, attrs['isg']))
|
||||
|
||||
|
||||
if isg_nodes:
|
||||
print(f"- {len(isg_nodes)} nœuds avec attribut ISG")
|
||||
print(" Exemples:")
|
||||
@ -71,24 +69,24 @@ def analyze_graph_structure(dot_path):
|
||||
print(f" - {node}: ISG = {isg}")
|
||||
else:
|
||||
print("- Aucun nœud avec attribut ISG trouvé")
|
||||
|
||||
|
||||
# Analyse des connexions pour les nœuds critiques (IHH)
|
||||
print("\nAnalyse des nœuds avec IHH:")
|
||||
ihh_nodes = []
|
||||
for node, attrs in G.nodes(data=True):
|
||||
if 'ihh_pays' in attrs or 'ihh_acteurs' in attrs:
|
||||
ihh_value_pays = attrs.get('ihh_pays', 'N/A')
|
||||
ihh_value_pays = attrs.get('ihh_pays', 'N/A')
|
||||
ihh_value_acteurs = attrs.get('ihh_acteurs', 'N/A')
|
||||
ihh_nodes.append((node, ihh_value_pays, ihh_value_acteurs))
|
||||
|
||||
|
||||
if ihh_nodes:
|
||||
print(f"- {len(ihh_nodes)} nœuds avec attributs IHH")
|
||||
print(" Exemples:")
|
||||
for node, ihh_pays, ihh_acteurs in ihh_nodes[:5]:
|
||||
print(f" - {node}: IHH pays = {ihh_pays}, IHH acteurs = {ihh_acteurs}")
|
||||
|
||||
|
||||
# Analyser les connexions de ce nœud
|
||||
print(f" Connexions sortantes:")
|
||||
print(" Connexions sortantes:")
|
||||
out_edges = list(G.out_edges(node))
|
||||
if out_edges:
|
||||
for i, (_, target) in enumerate(out_edges[:3]):
|
||||
@ -97,8 +95,8 @@ def analyze_graph_structure(dot_path):
|
||||
print(f" - ... et {len(out_edges)-3} autres")
|
||||
else:
|
||||
print(" - Aucune connexion sortante")
|
||||
|
||||
print(f" Connexions entrantes:")
|
||||
|
||||
print(" Connexions entrantes:")
|
||||
in_edges = list(G.in_edges(node))
|
||||
if in_edges:
|
||||
for i, (source, _) in enumerate(in_edges[:3]):
|
||||
@ -116,14 +114,14 @@ def analyze_graph_structure(dot_path):
|
||||
for node, attrs in G.nodes(data=True):
|
||||
if attrs.get('level') == '99':
|
||||
level_99_nodes.append(node)
|
||||
|
||||
|
||||
if level_99_nodes:
|
||||
print(f"- {len(level_99_nodes)} nœuds de niveau 99")
|
||||
print(" Exemples:")
|
||||
for node in level_99_nodes[:5]:
|
||||
print(f" - {node}")
|
||||
# Analyser les connexions de ce nœud
|
||||
print(f" Connexions entrantes:")
|
||||
print(" Connexions entrantes:")
|
||||
in_edges = list(G.in_edges(node))
|
||||
if in_edges:
|
||||
for i, (source, _) in enumerate(in_edges[:3]):
|
||||
@ -134,14 +132,14 @@ def analyze_graph_structure(dot_path):
|
||||
print(" - Aucune connexion entrante")
|
||||
else:
|
||||
print("- Aucun nœud de niveau 99 trouvé")
|
||||
|
||||
|
||||
def check_isg_paths(dot_path):
|
||||
"""Vérifie les chemins entre les nœuds critiques (IHH) et les nœuds ISG."""
|
||||
print("\nAnalyse des chemins entre nœuds IHH et nœuds ISG:")
|
||||
|
||||
|
||||
# Lire le graphe
|
||||
G = read_dot(dot_path)
|
||||
|
||||
|
||||
# Identifier les nœuds avec IHH
|
||||
ihh_nodes = []
|
||||
for node, attrs in G.nodes(data=True):
|
||||
@ -154,43 +152,43 @@ def check_isg_paths(dot_path):
|
||||
ihh_nodes.append(node)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
if not ihh_nodes:
|
||||
print("- Aucun nœud IHH critique trouvé")
|
||||
return
|
||||
|
||||
|
||||
print(f"- {len(ihh_nodes)} nœuds IHH critiques identifiés")
|
||||
|
||||
|
||||
# Pour chaque nœud IHH critique, chercher des chemins vers des nœuds ISG
|
||||
for node in ihh_nodes[:5]: # Limiter à 5 exemples
|
||||
print(f"\n Analyse des chemins pour {node}:")
|
||||
|
||||
|
||||
# Analyser les voisins directs
|
||||
successors = list(G.successors(node))
|
||||
print(f" - {len(successors)} successeurs directs")
|
||||
|
||||
|
||||
if successors:
|
||||
for succ in successors[:3]:
|
||||
print(f" - Vers {succ}")
|
||||
|
||||
|
||||
# Vérifier les attributs de ce successeur
|
||||
succ_attrs = G.nodes[succ]
|
||||
print(f" Attributs: {', '.join(f'{k}={v}' for k, v in succ_attrs.items() if k in ['level', 'isg'])}")
|
||||
|
||||
|
||||
# Chercher les successeurs de niveau 2
|
||||
succ2 = list(G.successors(succ))
|
||||
print(f" {len(succ2)} successeurs de niveau 2")
|
||||
|
||||
|
||||
if succ2:
|
||||
for s2 in succ2[:2]:
|
||||
print(f" - Vers {s2}")
|
||||
s2_attrs = G.nodes[s2]
|
||||
print(f" Attributs: {', '.join(f'{k}={v}' for k, v in s2_attrs.items() if k in ['level', 'isg'])}")
|
||||
|
||||
|
||||
# Chercher encore plus loin si nécessaire
|
||||
succ3 = list(G.successors(s2))
|
||||
print(f" {len(succ3)} successeurs de niveau 3")
|
||||
|
||||
|
||||
if succ3:
|
||||
for s3 in succ3[:2]:
|
||||
print(f" - Vers {s3}")
|
||||
@ -201,9 +199,9 @@ def main():
|
||||
"""Fonction principale."""
|
||||
print("=== Analyse de la structure du graphe ===")
|
||||
analyze_graph_structure(GRAPH_PATH)
|
||||
|
||||
|
||||
print("\n=== Analyse des chemins entre nœuds critiques et ISG ===")
|
||||
check_isg_paths(GRAPH_PATH)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def extract_paths(file_path):
|
||||
"""Extrait tous les chemins du fichier rapport_template.md"""
|
||||
paths = []
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
with open(file_path, encoding='utf-8') as f:
|
||||
for line in f:
|
||||
# Extraire les lignes qui commencent par "Corpus/"
|
||||
if line.strip().startswith("Corpus/"):
|
||||
@ -18,7 +16,7 @@ def extract_paths(file_path):
|
||||
except Exception as e:
|
||||
print(f"Erreur lors de la lecture du fichier {file_path}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
return paths
|
||||
|
||||
def check_paths(paths, base_dir):
|
||||
@ -28,17 +26,17 @@ def check_paths(paths, base_dir):
|
||||
"missing": [],
|
||||
"problematic": [] # Chemins qui pourraient nécessiter des corrections
|
||||
}
|
||||
|
||||
|
||||
for path in paths:
|
||||
# Vérifier si le chemin est absolu ou relatif
|
||||
abs_path = os.path.join(base_dir, path)
|
||||
|
||||
|
||||
if os.path.exists(abs_path):
|
||||
results["existing"].append(path)
|
||||
else:
|
||||
# Essayer de détecter des problèmes potentiels
|
||||
problem_detected = False
|
||||
|
||||
|
||||
# Vérifier les chemins avec "Fiche minerai" ou "Fiche fabrication"
|
||||
if "Fiche minerai" in path or "Fiche fabrication" in path:
|
||||
# Problème courant: mauvaise casse ou absence du mot "minerai"
|
||||
@ -48,25 +46,25 @@ def check_paths(paths, base_dir):
|
||||
if os.path.exists(os.path.join(base_dir, corrected_path)):
|
||||
results["problematic"].append((path, corrected_path, "Mot 'minerai' manquant"))
|
||||
problem_detected = True
|
||||
|
||||
|
||||
# Vérifier les chemins SSD
|
||||
if "SSD25" in path:
|
||||
corrected_path = path.replace("SSD25", "SSD 2.5")
|
||||
if os.path.exists(os.path.join(base_dir, corrected_path)):
|
||||
results["problematic"].append((path, corrected_path, "Format 'SSD25' au lieu de 'SSD 2.5'"))
|
||||
problem_detected = True
|
||||
|
||||
|
||||
# Si aucun problème spécifique n'a été détecté, marquer comme manquant
|
||||
if not problem_detected:
|
||||
results["missing"].append(path)
|
||||
|
||||
|
||||
return results
|
||||
|
||||
def find_similar_paths(missing_path, base_dir):
|
||||
"""Essaie de trouver des chemins similaires pour aider à diagnostiquer le problème"""
|
||||
missing_parts = missing_path.split('/')
|
||||
similar_paths = []
|
||||
|
||||
|
||||
# Rechercher dans les sous-répertoires correspondants
|
||||
search_dir = os.path.join(base_dir, *missing_parts[:-1])
|
||||
if os.path.exists(search_dir):
|
||||
@ -74,7 +72,7 @@ def find_similar_paths(missing_path, base_dir):
|
||||
if file.endswith('.md'):
|
||||
similar_path = os.path.join(search_dir, file).replace(base_dir + '/', '')
|
||||
similar_paths.append(similar_path)
|
||||
|
||||
|
||||
# Si aucun chemin similaire n'est trouvé, remonter d'un niveau
|
||||
if not similar_paths and len(missing_parts) > 2:
|
||||
parent_dir = os.path.join(base_dir, *missing_parts[:-2])
|
||||
@ -87,36 +85,36 @@ def find_similar_paths(missing_path, base_dir):
|
||||
if file.endswith('.md'):
|
||||
similar_path = os.path.join(dir_path, file).replace(base_dir + '/', '')
|
||||
similar_paths.append(similar_path)
|
||||
|
||||
|
||||
return similar_paths
|
||||
|
||||
def main():
|
||||
# Vérifier que nous sommes dans le bon répertoire
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
base_dir = script_dir
|
||||
|
||||
|
||||
# Chemin vers le rapport_template.md
|
||||
template_path = os.path.join(base_dir, "Corpus", "rapport_template.md")
|
||||
|
||||
|
||||
if not os.path.exists(template_path):
|
||||
print(f"Erreur: Le fichier {template_path} n'existe pas.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
print("=== Vérification des chemins dans rapport_template.md ===")
|
||||
|
||||
|
||||
# Extraire les chemins
|
||||
paths = extract_paths(template_path)
|
||||
print(f"Nombre total de chemins trouvés: {len(paths)}")
|
||||
|
||||
|
||||
# Vérifier les chemins
|
||||
results = check_paths(paths, base_dir)
|
||||
|
||||
|
||||
# Afficher les résultats
|
||||
print("\n=== Résultats ===")
|
||||
print(f"Chemins existants: {len(results['existing'])}")
|
||||
print(f"Chemins manquants: {len(results['missing'])}")
|
||||
print(f"Chemins problématiques: {len(results['problematic'])}")
|
||||
|
||||
|
||||
# Afficher les chemins manquants
|
||||
if results["missing"]:
|
||||
print("\n=== Chemins manquants ===")
|
||||
@ -127,7 +125,7 @@ def main():
|
||||
print(" Chemins similaires trouvés:")
|
||||
for sim_path in similar[:3]: # Limiter à 3 suggestions
|
||||
print(f" * {sim_path}")
|
||||
|
||||
|
||||
# Afficher les chemins problématiques avec suggestions
|
||||
if results["problematic"]:
|
||||
print("\n=== Chemins problématiques ===")
|
||||
@ -135,7 +133,7 @@ def main():
|
||||
print(f"- {orig}")
|
||||
print(f" Suggestion: {corrected}")
|
||||
print(f" Raison: {reason}")
|
||||
|
||||
|
||||
# Résumé
|
||||
if not results["missing"] and not results["problematic"]:
|
||||
print("\nTous les chemins dans le rapport sont valides !")
|
||||
@ -143,4 +141,4 @@ def main():
|
||||
print("\nDes chemins problématiques ont été détectés. Veuillez corriger les erreurs.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@ -1,19 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Script de génération de template de rapport IHH/ISG.
|
||||
"""Script de génération de template de rapport IHH/ISG.
|
||||
Ce script lit un fichier DOT, identifie les éléments Orange ou Rouge,
|
||||
et génère un template avec les chemins d'accès aux sections à récupérer.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import glob
|
||||
import yaml
|
||||
import argparse
|
||||
import unicodedata
|
||||
import networkx as nx
|
||||
import glob
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from networkx.drawing.nx_agraph import read_dot
|
||||
|
||||
# Chemins de base
|
||||
@ -36,7 +32,7 @@ CRIT_PATH = 'Criticités'
|
||||
def load_config():
|
||||
"""Charge les seuils depuis le fichier de configuration"""
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r') as file:
|
||||
with open(CONFIG_PATH) as file:
|
||||
config = yaml.safe_load(file)
|
||||
return config.get('seuils', {})
|
||||
except Exception as e:
|
||||
@ -518,16 +514,14 @@ def find_real_path(base_dir, resource_type, resource_name, file_pattern, G=None)
|
||||
if resource_type == "produit":
|
||||
if is_principaux_pattern:
|
||||
return f"Assemblage/Fiche assemblage {best_label}/02-principaux-assembleurs.md"
|
||||
else:
|
||||
# Numéros arbitraires mais dans le format correct
|
||||
return f"{IHH_ROOT_PATH}/10-assemblage-{best_label}/00-indice-de-herfindahl-hirschmann.md"
|
||||
elif resource_type == "composant":
|
||||
# Numéros arbitraires mais dans le format correct
|
||||
return f"{IHH_ROOT_PATH}/10-assemblage-{best_label}/00-indice-de-herfindahl-hirschmann.md"
|
||||
if resource_type == "composant":
|
||||
if is_principaux_pattern:
|
||||
return f"Fabrication/Fiche fabrication {best_label}/02-principaux-fabricants.md"
|
||||
else:
|
||||
# Numéros arbitraires mais dans le format correct
|
||||
return f"{IHH_ROOT_PATH}/20-fabrication-{best_label}/00-indice-de-herfindahl-hirschmann.md"
|
||||
elif resource_type == "minerai":
|
||||
# Numéros arbitraires mais dans le format correct
|
||||
return f"{IHH_ROOT_PATH}/20-fabrication-{best_label}/00-indice-de-herfindahl-hirschmann.md"
|
||||
if resource_type == "minerai":
|
||||
operation = file_pattern.split("-")[-1] if "-" in file_pattern else ""
|
||||
if is_principaux_pattern:
|
||||
# Choisir le numéro du préfixe en fonction de l'opération
|
||||
@ -535,9 +529,8 @@ def find_real_path(base_dir, resource_type, resource_name, file_pattern, G=None)
|
||||
# S'assurer que le nom du minerai est correctement formaté (sans tiret à la fin et en minuscules)
|
||||
clean_label = best_label.rstrip(" -").lower()
|
||||
return f"Minerai/Fiche minerai {clean_label}/{prefix_num}-principaux-producteurs-{operation}.md"
|
||||
else:
|
||||
# Numéros arbitraires mais dans le format correct
|
||||
return f"{IHH_ROOT_PATH}/30-{operation}-{best_label}/00-indice-de-herfindahl-hirschmann.md"
|
||||
# Numéros arbitraires mais dans le format correct
|
||||
return f"{IHH_ROOT_PATH}/30-{operation}-{best_label}/00-indice-de-herfindahl-hirschmann.md"
|
||||
|
||||
return ""
|
||||
|
||||
@ -834,19 +827,19 @@ def generate_template(elements, isg_data=None, G=None):
|
||||
# Indices documentaires
|
||||
try:
|
||||
has_ihh = any(element['ihh_critique'] for element in elements)
|
||||
except:
|
||||
except (KeyError, TypeError):
|
||||
has_ihh = False
|
||||
try:
|
||||
has_isg = any(element['isg_critique'] for element in elements)
|
||||
except:
|
||||
except (KeyError, TypeError):
|
||||
has_isg = False
|
||||
try:
|
||||
has_ivc = any(element['ivc_critique'] for element in elements)
|
||||
except:
|
||||
except (KeyError, TypeError):
|
||||
has_ivc = False
|
||||
try:
|
||||
has_ics = any(element['ics_critique'] for element in elements)
|
||||
except:
|
||||
except (KeyError, TypeError):
|
||||
has_ics = False
|
||||
|
||||
if has_ihh:
|
||||
@ -1196,16 +1189,14 @@ def get_component_label(G, component_name):
|
||||
if node.lower() == component_name.lower():
|
||||
if 'label' in attrs:
|
||||
return attrs['label'], True
|
||||
else:
|
||||
return component_name.capitalize(), True
|
||||
return component_name.capitalize(), True
|
||||
|
||||
# Si pas de correspondance exacte, chercher un nœud qui contient le nom du composant
|
||||
for node, attrs in G.nodes(data=True):
|
||||
if component_name.lower() in node.lower():
|
||||
if 'label' in attrs:
|
||||
return attrs['label'], True
|
||||
else:
|
||||
return component_name.capitalize(), True
|
||||
return component_name.capitalize(), True
|
||||
|
||||
# Si toujours pas trouvé, le composant n'existe pas dans le graphe
|
||||
return component_name.capitalize(), False
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Script pour remplacer les références de chemins dans le rapport par le contenu des fichiers.
|
||||
"""Script pour remplacer les références de chemins dans le rapport par le contenu des fichiers.
|
||||
Ajuste automatiquement les niveaux de titres pour maintenir la hiérarchie.
|
||||
"""
|
||||
|
||||
@ -90,7 +88,7 @@ def process_report():
|
||||
return
|
||||
|
||||
# Lire le rapport
|
||||
with open(INPUT_PATH, 'r', encoding='utf-8') as f:
|
||||
with open(INPUT_PATH, encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
output_lines = []
|
||||
@ -115,7 +113,7 @@ def process_report():
|
||||
# Vérifier si c'est une section d'Indice de Vulnérabilité de Concurrence
|
||||
is_ivc_section = "Vulnérabilité de Concurrence" in line or "/ivc-" in path.lower() or "/fiche technique ivc/" in path.lower()
|
||||
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
with open(full_path, encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Ajuster les niveaux de titres
|
||||
|
||||
@ -1,21 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Script d'injection automatique de documents pour PrivateGPT
|
||||
"""Script d'injection automatique de documents pour PrivateGPT
|
||||
|
||||
Ce script parcourt un répertoire spécifié et injecte tous les fichiers
|
||||
compatibles dans PrivateGPT via son API REST.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import argparse
|
||||
import logging
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Tuple, Set
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# Configuration du logging
|
||||
logging.basicConfig(
|
||||
@ -89,9 +87,8 @@ def parse_arguments():
|
||||
return parser.parse_args()
|
||||
|
||||
def find_files(directory: str, recursive: bool = False,
|
||||
extensions: Set[str] = SUPPORTED_EXTENSIONS) -> List[Path]:
|
||||
"""
|
||||
Trouve tous les fichiers avec les extensions spécifiées dans le répertoire.
|
||||
extensions: set[str] = SUPPORTED_EXTENSIONS) -> list[Path]:
|
||||
"""Trouve tous les fichiers avec les extensions spécifiées dans le répertoire.
|
||||
|
||||
Args:
|
||||
directory: Répertoire à scanner
|
||||
@ -125,9 +122,8 @@ def find_files(directory: str, recursive: bool = False,
|
||||
return files
|
||||
|
||||
def ingest_file(file_path: Path, pgpt_url: str, timeout: int,
|
||||
retry_count: int, retry_delay: int) -> Tuple[Path, bool, str]:
|
||||
"""
|
||||
Injecte un fichier dans PrivateGPT.
|
||||
retry_count: int, retry_delay: int) -> tuple[Path, bool, str]:
|
||||
"""Injecte un fichier dans PrivateGPT.
|
||||
|
||||
Args:
|
||||
file_path: Chemin du fichier à injecter
|
||||
@ -154,14 +150,13 @@ def ingest_file(file_path: Path, pgpt_url: str, timeout: int,
|
||||
doc_ids = result.get('document_ids', [])
|
||||
logger.info(f"Succès! {file_path} -> {len(doc_ids)} documents créés")
|
||||
return file_path, True, f"{len(doc_ids)} documents créés"
|
||||
error_msg = f"Erreur HTTP {response.status_code}: {response.text}"
|
||||
logger.warning(error_msg)
|
||||
if attempt < retry_count - 1:
|
||||
logger.info(f"Nouvelle tentative dans {retry_delay} secondes...")
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
error_msg = f"Erreur HTTP {response.status_code}: {response.text}"
|
||||
logger.warning(error_msg)
|
||||
if attempt < retry_count - 1:
|
||||
logger.info(f"Nouvelle tentative dans {retry_delay} secondes...")
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
return file_path, False, error_msg
|
||||
return file_path, False, error_msg
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Exception: {str(e)}"
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script de nettoyage pour PrivateGPT
|
||||
"""Script de nettoyage pour PrivateGPT
|
||||
|
||||
Ce script permet de lister et supprimer les documents ingérés dans PrivateGPT.
|
||||
Options:
|
||||
@ -12,18 +11,18 @@ Options:
|
||||
Usage:
|
||||
python nettoyer_pgpt.py --list
|
||||
python nettoyer_pgpt.py --delete-prefix "temp_section_"
|
||||
python nettoyer_pgpt.py --delete-pattern "rapport_.*\.md"
|
||||
python nettoyer_pgpt.py --delete-pattern "rapport_.*\\.md"
|
||||
python nettoyer_pgpt.py --delete-all
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import requests
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
# Configuration de l'API PrivateGPT
|
||||
PGPT_URL = "http://127.0.0.1:8001"
|
||||
@ -37,57 +36,56 @@ def check_api_availability() -> bool:
|
||||
if response.status_code == 200:
|
||||
print("✅ API PrivateGPT disponible")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ L'API PrivateGPT a retourné le code d'état {response.status_code}")
|
||||
return False
|
||||
print(f"❌ L'API PrivateGPT a retourné le code d'état {response.status_code}")
|
||||
return False
|
||||
except requests.RequestException as e:
|
||||
print(f"❌ Erreur de connexion à l'API PrivateGPT: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def list_documents() -> List[Dict[str, Any]]:
|
||||
def list_documents() -> list[dict[str, Any]]:
|
||||
"""Liste tous les documents ingérés et renvoie la liste des métadonnées"""
|
||||
try:
|
||||
# Récupérer la liste des documents
|
||||
response = requests.get(f"{API_URL}/ingest/list")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
||||
# Format de réponse OpenAI
|
||||
if "data" in data:
|
||||
documents = data.get("data", [])
|
||||
# Format alternatif
|
||||
else:
|
||||
documents = data.get("documents", [])
|
||||
|
||||
|
||||
# Construire une liste normalisée des documents
|
||||
normalized_docs = []
|
||||
for doc in documents:
|
||||
doc_id = doc.get("doc_id") or doc.get("id")
|
||||
metadata = doc.get("doc_metadata", {})
|
||||
filename = metadata.get("file_name") or metadata.get("filename", "Inconnu")
|
||||
|
||||
|
||||
normalized_docs.append({
|
||||
"id": doc_id,
|
||||
"filename": filename,
|
||||
"metadata": metadata
|
||||
})
|
||||
|
||||
|
||||
return normalized_docs
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur lors de la récupération des documents: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def print_documents(documents: List[Dict[str, Any]]) -> None:
|
||||
def print_documents(documents: list[dict[str, Any]]) -> None:
|
||||
"""Affiche la liste des documents de façon lisible"""
|
||||
if not documents:
|
||||
print("📋 Aucun document trouvé dans PrivateGPT")
|
||||
return
|
||||
|
||||
|
||||
print(f"📋 {len(documents)} documents trouvés dans PrivateGPT:")
|
||||
|
||||
|
||||
# Regrouper par nom de fichier pour un affichage plus compact
|
||||
files_grouped = {}
|
||||
for doc in documents:
|
||||
@ -95,7 +93,7 @@ def print_documents(documents: List[Dict[str, Any]]) -> None:
|
||||
if filename not in files_grouped:
|
||||
files_grouped[filename] = []
|
||||
files_grouped[filename].append(doc["id"])
|
||||
|
||||
|
||||
# Afficher les résultats groupés
|
||||
for i, (filename, ids) in enumerate(files_grouped.items(), 1):
|
||||
print(f"{i}. {filename} ({len(ids)} chunks)")
|
||||
@ -110,36 +108,34 @@ def delete_document(doc_id: str) -> bool:
|
||||
response = requests.delete(f"{API_URL}/ingest/{doc_id}")
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
print(f"⚠️ Échec de la suppression de l'ID {doc_id}: Code {response.status_code}")
|
||||
return False
|
||||
print(f"⚠️ Échec de la suppression de l'ID {doc_id}: Code {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur lors de la suppression de l'ID {doc_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def delete_documents_by_criteria(documents: List[Dict[str, Any]],
|
||||
prefix: Optional[str] = None,
|
||||
pattern: Optional[str] = None,
|
||||
def delete_documents_by_criteria(documents: list[dict[str, Any]],
|
||||
prefix: str | None = None,
|
||||
pattern: str | None = None,
|
||||
delete_all: bool = False) -> int:
|
||||
"""
|
||||
Supprime des documents selon différents critères
|
||||
"""Supprime des documents selon différents critères
|
||||
Retourne le nombre de documents supprimés
|
||||
"""
|
||||
if not documents:
|
||||
print("❌ Aucun document à supprimer")
|
||||
return 0
|
||||
|
||||
|
||||
if not (prefix or pattern or delete_all):
|
||||
print("❌ Aucun critère de suppression spécifié")
|
||||
return 0
|
||||
|
||||
|
||||
# Comptage des suppressions réussies
|
||||
success_count = 0
|
||||
|
||||
|
||||
# Filtrer les documents à supprimer
|
||||
docs_to_delete = []
|
||||
|
||||
|
||||
if delete_all:
|
||||
docs_to_delete = documents
|
||||
print(f"🗑️ Suppression de tous les documents ({len(documents)} chunks)...")
|
||||
@ -154,24 +150,24 @@ def delete_documents_by_criteria(documents: List[Dict[str, Any]],
|
||||
except re.error as e:
|
||||
print(f"❌ Expression régulière invalide: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
# Demander confirmation si beaucoup de documents
|
||||
if len(docs_to_delete) > 5 and not args.force:
|
||||
confirm = input(f"⚠️ Vous êtes sur le point de supprimer {len(docs_to_delete)} chunks. Confirmer ? (o/N) ")
|
||||
if confirm.lower() != 'o':
|
||||
print("❌ Opération annulée")
|
||||
return 0
|
||||
|
||||
|
||||
# Supprimer les documents
|
||||
for doc in docs_to_delete:
|
||||
if delete_document(doc["id"]):
|
||||
success_count += 1
|
||||
if args.verbose:
|
||||
print(f"✅ Document supprimé: {doc['filename']} (ID: {doc['id']})")
|
||||
|
||||
|
||||
# Petite pause pour éviter de surcharger l'API
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
print(f"✅ {success_count}/{len(docs_to_delete)} documents supprimés avec succès")
|
||||
return success_count
|
||||
|
||||
@ -184,7 +180,7 @@ def generate_unique_prefix() -> str:
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Utilitaire de nettoyage pour PrivateGPT")
|
||||
|
||||
|
||||
# Options principales
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--list", action="store_true", help="Lister tous les documents ingérés")
|
||||
@ -192,27 +188,27 @@ if __name__ == "__main__":
|
||||
group.add_argument("--delete-pattern", type=str, help="Supprimer les documents dont le nom correspond au motif PATTERN (regex)")
|
||||
group.add_argument("--delete-all", action="store_true", help="Supprimer tous les documents (⚠️ DANGER)")
|
||||
group.add_argument("--generate-prefix", action="store_true", help="Générer un préfixe unique pour les fichiers temporaires")
|
||||
|
||||
|
||||
# Options additionnelles
|
||||
parser.add_argument("--force", action="store_true", help="Ne pas demander de confirmation")
|
||||
parser.add_argument("--verbose", action="store_true", help="Afficher plus de détails")
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
# Vérifier la disponibilité de l'API
|
||||
if not check_api_availability():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Générer un préfixe unique
|
||||
if args.generate_prefix:
|
||||
unique_prefix = generate_unique_prefix()
|
||||
print(f"🔑 Préfixe unique généré: {unique_prefix}")
|
||||
print(f"Utilisez ce préfixe pour les fichiers temporaires de votre script.")
|
||||
print("Utilisez ce préfixe pour les fichiers temporaires de votre script.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# Récupérer la liste des documents
|
||||
documents = list_documents()
|
||||
|
||||
|
||||
# Traiter selon l'option choisie
|
||||
if args.list:
|
||||
print_documents(documents)
|
||||
@ -221,4 +217,4 @@ if __name__ == "__main__":
|
||||
elif args.delete_pattern:
|
||||
delete_documents_by_criteria(documents, pattern=args.delete_pattern)
|
||||
elif args.delete_all:
|
||||
delete_documents_by_criteria(documents, delete_all=True)
|
||||
delete_documents_by_criteria(documents, delete_all=True)
|
||||
|
||||
@ -1,30 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Script de surveillance de répertoire pour l'injection automatique dans PrivateGPT
|
||||
"""Script de surveillance de répertoire pour l'injection automatique dans PrivateGPT
|
||||
|
||||
Ce script surveille un répertoire et injecte automatiquement les nouveaux fichiers
|
||||
dans PrivateGPT dès qu'ils sont ajoutés ou modifiés.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Set, Dict, List
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from watchdog.events import FileCreatedEvent, FileModifiedEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent
|
||||
except ImportError:
|
||||
print("Bibliothèque 'watchdog' non installée. Installation en cours...")
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "watchdog"])
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent
|
||||
|
||||
# Configuration du logging
|
||||
logging.basicConfig(
|
||||
@ -39,17 +35,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Extensions de fichiers couramment supportées par PrivateGPT
|
||||
SUPPORTED_EXTENSIONS = {
|
||||
'.pdf', '.txt', '.md', '.doc', '.docx', '.ppt', '.pptx',
|
||||
'.pdf', '.txt', '.md', '.doc', '.docx', '.ppt', '.pptx',
|
||||
'.xls', '.xlsx', '.csv', '.epub', '.html', '.htm'
|
||||
}
|
||||
|
||||
class DocumentHandler(FileSystemEventHandler):
|
||||
"""Gestionnaire d'événements pour les fichiers de documents."""
|
||||
|
||||
def __init__(self, watch_dir: str, ingest_script: str, pgpt_url: str,
|
||||
extensions: Set[str], delay: int = 5):
|
||||
"""
|
||||
Initialise le gestionnaire d'événements.
|
||||
|
||||
def __init__(self, watch_dir: str, ingest_script: str, pgpt_url: str,
|
||||
extensions: set[str], delay: int = 5):
|
||||
"""Initialise le gestionnaire d'événements.
|
||||
|
||||
Args:
|
||||
watch_dir: Répertoire à surveiller
|
||||
@ -63,10 +58,10 @@ class DocumentHandler(FileSystemEventHandler):
|
||||
self.pgpt_url = pgpt_url
|
||||
self.extensions = extensions
|
||||
self.delay = delay
|
||||
|
||||
|
||||
# Queue pour les fichiers en attente de traitement
|
||||
self.pending_files: Dict[str, float] = {}
|
||||
|
||||
self.pending_files: dict[str, float] = {}
|
||||
|
||||
# Vérifier que le script d'injection existe
|
||||
if not os.path.exists(self.ingest_script):
|
||||
logger.error(f"Le script d'injection {self.ingest_script} n'existe pas!")
|
||||
@ -76,49 +71,48 @@ class DocumentHandler(FileSystemEventHandler):
|
||||
"""Appelé lorsqu'un fichier est créé."""
|
||||
if not event.is_directory:
|
||||
self._handle_file_event(event)
|
||||
|
||||
|
||||
def on_modified(self, event):
|
||||
"""Appelé lorsqu'un fichier est modifié."""
|
||||
if not event.is_directory:
|
||||
self._handle_file_event(event)
|
||||
|
||||
|
||||
def _handle_file_event(self, event):
|
||||
"""Traite un événement de fichier (création ou modification)."""
|
||||
file_path = event.src_path
|
||||
file_ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
|
||||
# Ignorer les fichiers non supportés
|
||||
if file_ext not in self.extensions:
|
||||
return
|
||||
|
||||
|
||||
# Ignorer les fichiers temporaires et cachés
|
||||
file_name = os.path.basename(file_path)
|
||||
if file_name.startswith('.') or file_name.startswith('~') or file_name.endswith('.tmp'):
|
||||
return
|
||||
|
||||
|
||||
# Ajouter à la queue avec l'horodatage actuel
|
||||
self.pending_files[file_path] = time.time()
|
||||
logger.info(f"Fichier détecté: {file_path} (en attente pendant {self.delay} secondes)")
|
||||
|
||||
|
||||
def process_pending_files(self):
|
||||
"""Traite les fichiers en attente qui ont dépassé le délai d'attente."""
|
||||
current_time = time.time()
|
||||
files_to_process: List[str] = []
|
||||
|
||||
files_to_process: list[str] = []
|
||||
|
||||
# Identifier les fichiers prêts à être traités
|
||||
for file_path, timestamp in list(self.pending_files.items()):
|
||||
if current_time - timestamp >= self.delay:
|
||||
if os.path.exists(file_path): # Vérifier que le fichier existe toujours
|
||||
files_to_process.append(file_path)
|
||||
self.pending_files.pop(file_path)
|
||||
|
||||
|
||||
# Traiter les fichiers par lot
|
||||
if files_to_process:
|
||||
self._ingest_files(files_to_process)
|
||||
|
||||
def _ingest_files(self, files: List[str]):
|
||||
"""
|
||||
Injecte une liste de fichiers en utilisant le script d'injection.
|
||||
|
||||
def _ingest_files(self, files: list[str]):
|
||||
"""Injecte une liste de fichiers en utilisant le script d'injection.
|
||||
|
||||
Args:
|
||||
files: Liste des chemins de fichiers à injecter
|
||||
@ -127,20 +121,20 @@ class DocumentHandler(FileSystemEventHandler):
|
||||
# Créer un répertoire temporaire pour stocker la liste des fichiers
|
||||
temp_dir = os.path.join(os.path.dirname(self.ingest_script), "temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
|
||||
# Créer un fichier de liste
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
list_file = os.path.join(temp_dir, f"files_to_ingest_{timestamp}.txt")
|
||||
|
||||
|
||||
with open(list_file, "w") as f:
|
||||
for file_path in files:
|
||||
f.write(f"{file_path}\n")
|
||||
|
||||
|
||||
# Construire la commande pour le script d'injection
|
||||
for file_path in files:
|
||||
file_dir = os.path.dirname(file_path)
|
||||
logger.info(f"Injection de {file_path}...")
|
||||
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
self.ingest_script,
|
||||
@ -148,19 +142,19 @@ class DocumentHandler(FileSystemEventHandler):
|
||||
"-u", self.pgpt_url,
|
||||
"--extensions", os.path.splitext(file_path)[1][1:] # Extension sans le point
|
||||
]
|
||||
|
||||
|
||||
# Exécuter la commande
|
||||
process = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
|
||||
if process.returncode == 0:
|
||||
logger.info(f"Injection réussie de {file_path}")
|
||||
else:
|
||||
logger.error(f"Échec de l'injection de {file_path}: {process.stderr}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'injection des fichiers: {str(e)}")
|
||||
|
||||
@ -170,26 +164,26 @@ def parse_arguments():
|
||||
description="Surveille un répertoire et injecte automatiquement les nouveaux fichiers dans PrivateGPT"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d", "--directory",
|
||||
type=str,
|
||||
"-d", "--directory",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Chemin du répertoire à surveiller"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s", "--script",
|
||||
type=str,
|
||||
"-s", "--script",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Chemin vers le script auto_ingest.py (par défaut: détection automatique)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-u", "--url",
|
||||
type=str,
|
||||
"-u", "--url",
|
||||
type=str,
|
||||
default="http://localhost:8001",
|
||||
help="URL de l'API PrivateGPT (défaut: http://localhost:8001)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delay",
|
||||
type=int,
|
||||
"--delay",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Délai en secondes avant de traiter un nouveau fichier (défaut: 5)"
|
||||
)
|
||||
@ -198,36 +192,36 @@ def parse_arguments():
|
||||
nargs="+",
|
||||
help="Liste d'extensions spécifiques à surveiller (ex: pdf txt)"
|
||||
)
|
||||
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
def main():
|
||||
"""Fonction principale."""
|
||||
args = parse_arguments()
|
||||
|
||||
|
||||
# Préparation des extensions si spécifiées
|
||||
extensions = set(args.extensions) if args.extensions else SUPPORTED_EXTENSIONS
|
||||
# Assurer que les extensions commencent par un point
|
||||
extensions = {ext if ext.startswith('.') else f'.{ext}' for ext in extensions}
|
||||
|
||||
|
||||
# Déterminer le chemin du script d'injection
|
||||
if args.script:
|
||||
ingest_script = args.script
|
||||
else:
|
||||
# Utiliser le script auto_ingest.py dans le même répertoire que ce script
|
||||
ingest_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "auto_ingest.py")
|
||||
|
||||
|
||||
# Créer le répertoire de surveillance s'il n'existe pas
|
||||
watch_dir = os.path.abspath(args.directory)
|
||||
if not os.path.exists(watch_dir):
|
||||
logger.info(f"Création du répertoire de surveillance: {watch_dir}")
|
||||
os.makedirs(watch_dir, exist_ok=True)
|
||||
|
||||
|
||||
logger.info(f"Démarrage de la surveillance de {watch_dir}")
|
||||
logger.info(f"URL PrivateGPT: {args.url}")
|
||||
logger.info(f"Extensions surveillées: {', '.join(extensions)}")
|
||||
logger.info(f"Délai de traitement: {args.delay} secondes")
|
||||
|
||||
|
||||
# Initialiser le gestionnaire et l'observateur
|
||||
event_handler = DocumentHandler(
|
||||
watch_dir=watch_dir,
|
||||
@ -236,23 +230,23 @@ def main():
|
||||
extensions=extensions,
|
||||
delay=args.delay
|
||||
)
|
||||
|
||||
|
||||
observer = Observer()
|
||||
observer.schedule(event_handler, path=watch_dir, recursive=True)
|
||||
observer.start()
|
||||
|
||||
|
||||
try:
|
||||
logger.info("Surveillance en cours... (Ctrl+C pour quitter)")
|
||||
|
||||
|
||||
while True:
|
||||
# Traiter les fichiers en attente
|
||||
event_handler.process_pending_files()
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("\nInterruption par l'utilisateur. Arrêt de la surveillance.")
|
||||
observer.stop()
|
||||
|
||||
|
||||
observer.join()
|
||||
|
||||
if __name__ == "__main__":
|
||||
@ -260,4 +254,4 @@ if __name__ == "__main__":
|
||||
main()
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur non gérée: {str(e)}", exc_info=True)
|
||||
sys.exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
from datetime import datetime
|
||||
from collections import defaultdict, deque
|
||||
import os
|
||||
import sys
|
||||
from datetime import timezone
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timezone
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# À adapter dans ton environnement
|
||||
from config import GITEA_URL, ORGANISATION, DEPOT_FICHES, ENV
|
||||
from utils.gitea import recuperer_date_dernier_commit
|
||||
from config import DEPOT_FICHES, ENV, GITEA_URL, ORGANISATION
|
||||
from IA.make_config import MAKE # MAKE doit être importé depuis un fichier de config
|
||||
from utils.gitea import recuperer_date_dernier_commit
|
||||
|
||||
|
||||
def get_mtime(path):
|
||||
try:
|
||||
@ -42,8 +42,7 @@ def resolve_path_from_where(where_str):
|
||||
directory = context["directory"]
|
||||
if "fiches" in where_str:
|
||||
return os.path.join("Fiches", directory, current)
|
||||
else:
|
||||
return os.path.join(directory, current)
|
||||
return os.path.join(directory, current)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from utils.gitea import recuperer_date_dernier_commit
|
||||
#
|
||||
# from config import GITEA_URL, GITEA_TOKEN, ORGANISATION, DEPOT_FICHES, DEPOT_CODE, ENV, ENV_CODE, DOT_FILE
|
||||
#
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
Cet outil interactif vous permet d'explorer et d'analyser les vulnérabilités de la chaîne de fabrication du numérique. Grâce à une interface, espérons-le, intuitive, vous pourrez visualiser les différentes étapes de production, identifier les points critiques et comprendre les enjeux géopolitiques et plus liés à la fabrication des technologies numériques.
|
||||
|
||||
L'application vous offre diverses fonctionnalités :
|
||||
|
||||
* Visualisation des données et des graphiques
|
||||
* Personnalisation des produits à analyser
|
||||
* Exploration détaillée des indices de vulnérabilité et des opérations
|
||||
@ -15,21 +14,18 @@ L'application vous offre diverses fonctionnalités :
|
||||
|
||||
### Structure de la chaîne
|
||||
La chaîne de fabrication numérique se décompose en trois niveaux hiérarchiques :
|
||||
|
||||
* Produits finaux : appareils complets comme les smartphones, ordinateurs, serveurs, etc.
|
||||
* Composants : éléments constitutifs comme les processeurs, écrans, capteurs, batteries, etc.
|
||||
* Minerais et matériaux : ressources de base nécessaires à la fabrication des composants
|
||||
|
||||
### Opérations à chaque niveau
|
||||
Chaque niveau de la chaîne implique des opérations spécifiques :
|
||||
|
||||
* Niveau Produit final : assemblage des composants pour créer le produit fini
|
||||
* Niveau Composant : fabrication des pièces à partir des minerais et matériaux
|
||||
* Niveau Minerai : extraction et traitement des ressources premières
|
||||
|
||||
### Dimension géopolitique
|
||||
Pour chaque opération, l'application détaille :
|
||||
|
||||
* Les pays où l'opération est réalisée, avec leur part respective du marché mondial
|
||||
* Les acteurs économiques impliqués dans chaque pays, avec leur part de marché
|
||||
* Les liens entre les pays d'opération, les acteurs et leur contexte géopolitique
|
||||
@ -41,33 +37,29 @@ Pour chaque opération, l'application détaille :
|
||||
L'application utilise quatre indices clés pour évaluer les vulnérabilités dans la chaîne de fabrication numérique :
|
||||
|
||||
### Indice de Herfindahl-Hirschmann (IHH)
|
||||
|
||||
* Que mesure-t-il ? La concentration géographique ou industrielle d'une opération.
|
||||
* Comment l'interpréter ? Plus l'indice est élevé, plus l'opération est concentrée dans un nombre limité de pays, ce qui augmente la vulnérabilité.
|
||||
* Que mesure-t-il ? La concentration géographique ou industrielle d'une opération.
|
||||
* Comment l'interpréter ? Plus l'indice est élevé, plus l'opération est concentrée dans un nombre limité de pays, ce qui augmente la vulnérabilité.
|
||||
* Où le trouver ? Associé à chaque opération dans les graphiques d'analyse.
|
||||
|
||||
[Voir la fiche détaillée](https://fabnum-dev.peccini.fr/app/static/Fiches/Criticit%C3%A9s/Fiche%20technique%20IHH.pdf)
|
||||
|
||||
### Indice de Vulnérabilité Concurrentielle (IVC)
|
||||
|
||||
* Que mesure-t-il ? La pression exercée par d'autres secteurs industriels sur une même ressource minérale.
|
||||
* Comment l'interpréter ? Un IVC élevé indique une forte compétition pour accéder à ce minerai, augmentant le risque de pénurie.
|
||||
* Que mesure-t-il ? La pression exercée par d'autres secteurs industriels sur une même ressource minérale.
|
||||
* Comment l'interpréter ? Un IVC élevé indique une forte compétition pour accéder à ce minerai, augmentant le risque de pénurie.
|
||||
* Où le trouver ? Associé à chaque minerai dans les visualisations et fiches.
|
||||
|
||||
[Voir la fiche détaillée](https://fabnum-dev.peccini.fr/app/static/Fiches/Criticit%C3%A9s/Fiche%20technique%20IVC.pdf)
|
||||
|
||||
### Indice de Criticité de Substituabilité (ICS)
|
||||
|
||||
* Que mesure-t-il ? La possibilité de remplacer un minerai par un autre dans la fabrication d'un composant. Plus rarement, il matérialise la capacité de remplacer un procédé.
|
||||
* Comment l'interpréter ? Un ICS élevé signifie qu'il est difficile de trouver une alternative à ce minerai.
|
||||
* Comment l'interpréter ? Un ICS élevé signifie qu'il est difficile de trouver une alternative à ce minerai.
|
||||
* Où le trouver ? Associé à la relation entre composants et minerais.
|
||||
|
||||
[Voir la fiche détaillée](https://fabnum-dev.peccini.fr/app/static/Fiches/Criticit%C3%A9s/Fiche%20technique%20ICS.pdf)
|
||||
|
||||
### Indice de Stabilité Géopolitique (ISG)
|
||||
|
||||
* Que mesure-t-il ? La stabilité politique, économique et sociale d'un pays, basée sur trois sous-indicateurs.
|
||||
* Comment l'interpréter ? Un ISG élevé indique un pays instable, ce qui augmente les risques d'approvisionnement.
|
||||
* Que mesure-t-il ? La stabilité politique, économique et sociale d'un pays, basée sur trois sous-indicateurs.
|
||||
* Comment l'interpréter ? Un ISG élevé indique un pays instable, ce qui augmente les risques d'approvisionnement.
|
||||
* Où le trouver ? Utilisé pour pondérer les risques identifiés tout au long de la chaîne.
|
||||
|
||||
[Voir la fiche détaillée](https://fabnum-dev.peccini.fr/app/static/Fiches/Criticit%C3%A9s/Fiche%20technique%20ISG.pdf)
|
||||
@ -77,17 +69,12 @@ L'application utilise quatre indices clés pour évaluer les vulnérabilités da
|
||||
L'application est organisée en quatre onglets principaux, chacun offrant une perspective différente sur la chaîne de fabrication numérique :
|
||||
|
||||
* Onglet Personnalisation : Créer et gérer des produits finaux personnalisés pour des analyses spécifiques.
|
||||
* À noter : Les produits personnalisés sont temporaires par défaut, mais peuvent être sauvegardés pour une utilisation ultérieure.
|
||||
* À noter : Les produits personnalisés sont temporaires par défaut, mais peuvent être sauvegardés pour une utilisation ultérieure.
|
||||
* Onglet Analyse : Explorer visuellement les relations entre les différents niveaux de la chaîne de fabrication.
|
||||
* Exemple d'utilisation : Pour comprendre les vulnérabilités liées aux composants d'un smartphone, sélectionnez « Produit final » comme niveau de départ, « Composant » comme niveau d'arrivée, puis spécifiez « Smartphone » comme item de produit final.
|
||||
* Onglet IA'nalyse : Obtenez un rapport synthétique circonstancié d'une partie du schéma global.
|
||||
* Cet onglet n'est accessible qu'aux personnes diposant d'un compte.
|
||||
* Onglet Plan d'action : Visualiser toutes les criticités d'une chaîne Produit final <-> Composant <-> Minerai.
|
||||
* À partir d'une sélection, toutes les chaînes critiques sont examinées à la loupe.
|
||||
* Des propositions d'actions et d'indicateurs sont fournies selon les opérations et leur niveau de criticité.
|
||||
* Exemple d'utilisation : Pour comprendre les vulnérabilités liées aux composants d'un smartphone, sélectionnez « Produit final » comme niveau de départ, « Composant » comme niveau d'arrivée, puis spécifiez « Smartphone » comme item de produit final.
|
||||
* Onglet Visualisations : Observer les corrélations entre les différents indices et comprendre les tendances globales.
|
||||
* Indicateurs clés : Portez attention aux points situés dans les zones de haute valeur pour les deux indices, car ils représentent les vulnérabilités les plus critiques.
|
||||
* Indicateurs clés : Portez attention aux points situés dans les zones de haute valeur pour les deux indices, car ils représentent les vulnérabilités les plus critiques.
|
||||
* Onglet Fiches : Accéder à des informations détaillées sur chaque opération et minerai.
|
||||
* À explorer : Les fiches contiennent souvent des informations qui ne sont pas visibles directement dans les graphiques, comme des tendances historiques ou des prévisions futures ; n'hésitez pas à les consulter
|
||||
* À explorer : Les fiches contiennent souvent des informations qui ne sont pas visibles directement dans les graphiques, comme des tendances historiques ou des prévisions futures ; n'hésitez pas à les consulter
|
||||
|
||||
En début de chacun des onglets, vous trouverez un mini-guide spécifique sur leur utilisation.
|
||||
En début de chacun des onglets, vous trouverez un mini-guide spécifique sur leur utilisation.
|
||||
@ -144,72 +144,70 @@ def selectionner_noeuds(
|
||||
depart_labels = {n: G.nodes[n].get("label", n) for n in depart_nodes}
|
||||
arrivee_labels = {n: G.nodes[n].get("label", n) for n in arrivee_nodes}
|
||||
|
||||
# Mapping inverse Label -> ID pour retrouver les IDs sélectionnés
|
||||
depart_labels_inverse = {v: k for k, v in depart_labels.items()}
|
||||
arrivee_labels_inverse = {v: k for k, v in arrivee_labels.items()}
|
||||
# Mapping inverse Label -> tous les IDs (un label peut correspondre à N nœuds)
|
||||
depart_par_label: dict[str, list[str]] = {}
|
||||
for node_id, label in depart_labels.items():
|
||||
depart_par_label.setdefault(label, []).append(node_id)
|
||||
arrivee_par_label: dict[str, list[str]] = {}
|
||||
for node_id, label in arrivee_labels.items():
|
||||
arrivee_par_label.setdefault(label, []).append(node_id)
|
||||
|
||||
# DEPARTS -------------------------------------
|
||||
if "analyse_noeuds_depart" not in st.session_state:
|
||||
anciens_departs = []
|
||||
# Persistance par labels (pas par IDs) pour éviter l'explosion des doublons
|
||||
if "analyse_labels_depart" not in st.session_state:
|
||||
anciens = []
|
||||
i = 0
|
||||
while True:
|
||||
val = get_champ_statut(f"pages.analyse.filter_start_nodes.{i}")
|
||||
if not val:
|
||||
break
|
||||
anciens_departs.append(val)
|
||||
anciens.append(val)
|
||||
i += 1
|
||||
st.session_state["analyse_noeuds_depart"] = anciens_departs
|
||||
st.session_state["analyse_labels_depart"] = anciens
|
||||
|
||||
# Afficher les labels mais stocker les IDs
|
||||
selected_labels_depart = st.multiselect(
|
||||
str(_("pages.analyse.filter_start_nodes")),
|
||||
sorted(depart_labels.values()),
|
||||
default=[depart_labels.get(n, n) for n in st.session_state["analyse_noeuds_depart"] if n in depart_labels],
|
||||
key="analyse_noeuds_depart_labels"
|
||||
sorted(depart_par_label),
|
||||
default=[lb for lb in st.session_state["analyse_labels_depart"] if lb in depart_par_label],
|
||||
key="analyse_labels_depart_widget"
|
||||
)
|
||||
|
||||
# Convertir les labels sélectionnés en IDs
|
||||
departs_selection = [depart_labels_inverse.get(label, label) for label in selected_labels_depart]
|
||||
st.session_state["analyse_noeuds_depart"] = departs_selection
|
||||
st.session_state["analyse_labels_depart"] = selected_labels_depart
|
||||
|
||||
supprime_champ_statut("pages.analyse.filter_start_nodes")
|
||||
if departs_selection:
|
||||
for i, val in enumerate(departs_selection):
|
||||
maj_champ_statut(f"pages.analyse.filter_start_nodes.{i}", val)
|
||||
for i, val in enumerate(selected_labels_depart):
|
||||
maj_champ_statut(f"pages.analyse.filter_start_nodes.{i}", val)
|
||||
|
||||
# Expansion label → tous les IDs pour le Sankey
|
||||
departs_selection = [nid for lb in selected_labels_depart for nid in depart_par_label[lb]]
|
||||
|
||||
# ARRIVEES -------------------------------------
|
||||
if "analyse_noeuds_arrivee" not in st.session_state:
|
||||
anciens_arrivees = []
|
||||
if "analyse_labels_arrivee" not in st.session_state:
|
||||
anciens = []
|
||||
i = 0
|
||||
while True:
|
||||
val = get_champ_statut(f"pages.analyse.filter_end_nodes.{i}")
|
||||
if not val:
|
||||
break
|
||||
anciens_arrivees.append(val)
|
||||
anciens.append(val)
|
||||
i += 1
|
||||
st.session_state["analyse_noeuds_arrivee"] = anciens_arrivees
|
||||
st.session_state["analyse_labels_arrivee"] = anciens
|
||||
|
||||
# Afficher les labels mais stocker les IDs
|
||||
selected_labels_arrivee = st.multiselect(
|
||||
str(_("pages.analyse.filter_end_nodes")),
|
||||
sorted(arrivee_labels.values()),
|
||||
default=[arrivee_labels.get(n, n) for n in st.session_state["analyse_noeuds_arrivee"] if n in arrivee_labels],
|
||||
key="analyse_noeuds_arrivee_labels"
|
||||
sorted(arrivee_par_label),
|
||||
default=[lb for lb in st.session_state["analyse_labels_arrivee"] if lb in arrivee_par_label],
|
||||
key="analyse_labels_arrivee_widget"
|
||||
)
|
||||
|
||||
# Convertir les labels sélectionnés en IDs
|
||||
arrivees_selection = [arrivee_labels_inverse.get(label, label) for label in selected_labels_arrivee]
|
||||
st.session_state["analyse_noeuds_arrivee"] = arrivees_selection
|
||||
st.session_state["analyse_labels_arrivee"] = selected_labels_arrivee
|
||||
|
||||
supprime_champ_statut("pages.analyse.filter_end_nodes")
|
||||
if arrivees_selection:
|
||||
for i, val in enumerate(arrivees_selection):
|
||||
maj_champ_statut(f"pages.analyse.filter_end_nodes.{i}", val)
|
||||
for i, val in enumerate(selected_labels_arrivee):
|
||||
maj_champ_statut(f"pages.analyse.filter_end_nodes.{i}", val)
|
||||
|
||||
departs_selection = departs_selection if departs_selection else None
|
||||
arrivees_selection = arrivees_selection if arrivees_selection else None
|
||||
# Expansion label → tous les IDs pour le Sankey
|
||||
arrivees_selection = [nid for lb in selected_labels_arrivee for nid in arrivee_par_label[lb]]
|
||||
|
||||
return departs_selection, arrivees_selection
|
||||
return departs_selection or None, arrivees_selection or None
|
||||
|
||||
def configurer_filtres_vulnerabilite() -> tuple[bool, bool, bool, str, bool, str]:
|
||||
"""Interface pour configurer les filtres de vulnérabilité.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import networkx as nx
|
||||
import pandas as pd
|
||||
@ -298,10 +299,14 @@ def filtrer_chemins_par_criteres(
|
||||
# Appliquer la logique de filtrage
|
||||
if logique_filtrage == "ET":
|
||||
keep = True
|
||||
if filtrer_ihh: keep = keep and has_ihh
|
||||
if filtrer_ivc: keep = keep and has_ivc
|
||||
if filtrer_ics: keep = keep and has_ics
|
||||
if filtrer_isg: keep = keep and has_isg_critique
|
||||
if filtrer_ihh:
|
||||
keep = keep and has_ihh
|
||||
if filtrer_ivc:
|
||||
keep = keep and has_ivc
|
||||
if filtrer_ics:
|
||||
keep = keep and has_ics
|
||||
if filtrer_isg:
|
||||
keep = keep and has_isg_critique
|
||||
if keep:
|
||||
chemins_filtres.add(tuple(chemin))
|
||||
elif logique_filtrage == "OU":
|
||||
@ -461,28 +466,28 @@ def creer_graphique_sankey(
|
||||
|
||||
fig = go.Figure(go.Sankey(
|
||||
arrangement="snap",
|
||||
node=dict(
|
||||
pad=10,
|
||||
thickness=8,
|
||||
label=sorted_nodes,
|
||||
x=[niveaux.get(n, 99) / 100 for n in sorted_nodes],
|
||||
color=[couleur_noeud(n, niveaux, G) for n in sorted_nodes],
|
||||
customdata=customdata,
|
||||
hovertemplate="%{customdata}<extra></extra>"
|
||||
),
|
||||
link=dict(
|
||||
source=sources,
|
||||
target=targets,
|
||||
value=values,
|
||||
color=df_liens["color"].tolist(),
|
||||
customdata=link_customdata,
|
||||
hovertemplate="%{customdata}<extra></extra>",
|
||||
line=dict(
|
||||
width=1, # Set fixed width to 3 pixels (or use 2 if preferred)
|
||||
color="grey"
|
||||
),
|
||||
arrowlen=10
|
||||
)
|
||||
node={
|
||||
"pad": 10,
|
||||
"thickness": 8,
|
||||
"label": sorted_nodes,
|
||||
"x": [niveaux.get(n, 99) / 100 for n in sorted_nodes],
|
||||
"color": [couleur_noeud(n, niveaux, G) for n in sorted_nodes],
|
||||
"customdata": customdata,
|
||||
"hovertemplate": "%{customdata}<extra></extra>"
|
||||
},
|
||||
link={
|
||||
"source": sources,
|
||||
"target": targets,
|
||||
"value": values,
|
||||
"color": df_liens["color"].tolist(),
|
||||
"customdata": link_customdata,
|
||||
"hovertemplate": "%{customdata}<extra></extra>",
|
||||
"line": {
|
||||
"width": 1, # Set fixed width to 3 pixels (or use 2 if preferred)
|
||||
"color": "grey"
|
||||
},
|
||||
"arrowlen": 10
|
||||
}
|
||||
))
|
||||
|
||||
fig.update_layout(
|
||||
@ -526,7 +531,7 @@ def exporter_graphe_filtre(
|
||||
write_dot(G_export, f.name)
|
||||
dot_path = f.name
|
||||
|
||||
with open(dot_path, encoding="utf-8") as f:
|
||||
with Path(dot_path).open(encoding="utf-8") as f:
|
||||
st.download_button(
|
||||
label=str(_("pages.analyse.sankey.download_dot")),
|
||||
data=f.read(),
|
||||
@ -546,10 +551,13 @@ def afficher_sankey(
|
||||
|
||||
Args:
|
||||
G: Le graphe NetworkX contenant les données des produits.
|
||||
niveau_depart, niveau_arrivee: Les niveaux initiaux pour le filtrage.
|
||||
noeuds_depart, noeuds_arrivee: Les nœuds initiaux pour le filtrage.
|
||||
niveau_depart: Le niveau de départ pour le filtrage.
|
||||
niveau_arrivee: Le niveau d'arrivée pour le filtrage.
|
||||
noeuds_depart: Les nœuds de départ pour le filtrage.
|
||||
noeuds_arrivee: Les nœuds d'arrivée pour le filtrage.
|
||||
minerais: La liste des minerais à inclure dans le filtrage.
|
||||
filtrer_ics, filtrer_ivc: Les booléens pour le filtrage ICS et IVC.
|
||||
filtrer_ics: Booléen pour activer le filtrage ICS.
|
||||
filtrer_ivc: Booléen pour activer le filtrage IVC.
|
||||
filtrer_ihh: Le booléen pour le filtrage IHH.
|
||||
ihh_type: Le type d'application pour les IHH (Pays ou Acteur).
|
||||
filtrer_isg: Le booléen pour le filtrage ISG.
|
||||
|
||||
@ -10,8 +10,8 @@ Toutes ces fonctions gèrent la conversion et le rendu de contenu Markdown
|
||||
vers du HTML structuré avec des mathématiques, respectant les règles RGAA.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import markdown
|
||||
import pypandoc
|
||||
@ -59,8 +59,7 @@ def remplacer_latex_par_mathml(markdown_text: str) -> str:
|
||||
return f"<code>Erreur LaTeX inline: {e}</code>"
|
||||
|
||||
markdown_text = re.sub(r"\$\$(.*?)\$\$", remplacer_bloc_display, markdown_text, flags=re.DOTALL)
|
||||
markdown_text = re.sub(r"(?<!\$)\$(.+?)\$(?!\$)", remplacer_bloc_inline, markdown_text, flags=re.DOTALL)
|
||||
return markdown_text
|
||||
return re.sub(r"(?<!\$)\$(.+?)\$(?!\$)", remplacer_bloc_inline, markdown_text, flags=re.DOTALL)
|
||||
|
||||
def markdown_to_html_rgaa(markdown_text: str, caption_text: str|None) -> str:
|
||||
"""Convertit un texte Markdown en HTML structuré accessible.
|
||||
@ -74,7 +73,7 @@ def markdown_to_html_rgaa(markdown_text: str, caption_text: str|None) -> str:
|
||||
"""
|
||||
html = markdown.markdown(markdown_text, extensions=['tables'])
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for i, table in enumerate(soup.find_all("table"), start=1):
|
||||
for _, table in enumerate(soup.find_all("table"), start=1):
|
||||
table["role"] = "table"
|
||||
table["summary"] = caption_text
|
||||
if caption_text:
|
||||
@ -174,18 +173,18 @@ def generer_fiche(md_source: str, dossier: str, nom_fichier: str, seuils: dict)
|
||||
|
||||
contenu_md = render_fiche_markdown(md_source, seuils, license_path="assets/licence.md")
|
||||
|
||||
md_path = os.path.join("Fiches", dossier, nom_fichier)
|
||||
os.makedirs(os.path.dirname(md_path), exist_ok=True)
|
||||
with open(md_path, "w", encoding="utf-8") as f:
|
||||
md_path = Path("Fiches") / dossier / nom_fichier
|
||||
md_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with md_path.open("w", encoding="utf-8") as f:
|
||||
f.write(contenu_md)
|
||||
|
||||
# Génération automatique du PDF
|
||||
pdf_dir = os.path.join("static", "Fiches", dossier)
|
||||
os.makedirs(pdf_dir, exist_ok=True)
|
||||
pdf_dir = Path("static") / "Fiches" / dossier
|
||||
pdf_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Construire le chemin PDF correspondant (même nom que .md, mais .pdf)
|
||||
nom_pdf = os.path.splitext(nom_fichier)[0] + ".pdf"
|
||||
pdf_path = os.path.join(pdf_dir, nom_pdf)
|
||||
nom_pdf = Path(nom_fichier).stem + ".pdf"
|
||||
pdf_path = pdf_dir / nom_pdf
|
||||
|
||||
try:
|
||||
pypandoc.convert_file(
|
||||
@ -205,10 +204,10 @@ def generer_fiche(md_source: str, dossier: str, nom_fichier: str, seuils: dict)
|
||||
|
||||
html_output = rendu_html(contenu_md)
|
||||
|
||||
html_dir = os.path.join("HTML", dossier)
|
||||
os.makedirs(html_dir, exist_ok=True)
|
||||
html_path = os.path.join(html_dir, os.path.splitext(nom_fichier)[0] + ".html")
|
||||
with open(html_path, "w", encoding="utf-8") as f:
|
||||
html_dir = Path("HTML") / dossier
|
||||
html_dir.mkdir(parents=True, exist_ok=True)
|
||||
html_path = html_dir / (Path(nom_fichier).stem + ".html")
|
||||
with html_path.open("w", encoding="utf-8") as f:
|
||||
f.write("\n".join(html_output))
|
||||
|
||||
return html_path
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# === Constantes et imports ===
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import streamlit as st
|
||||
@ -85,8 +85,8 @@ def interface_fiches() -> None:
|
||||
else:
|
||||
SEUILS = st.session_state["seuils"]
|
||||
|
||||
nom_fiche = os.path.splitext(fiche_choisie)[0]
|
||||
html_path = os.path.join("HTML", dossier_choisi, nom_fiche + ".html")
|
||||
nom_fiche = Path(fiche_choisie).stem
|
||||
html_path = Path("HTML") / dossier_choisi / (nom_fiche + ".html")
|
||||
path_relative = f"Documents/{dossier_choisi}/{fiche_choisie}"
|
||||
commits_url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/commits?path={path_relative}&sha={ENV}"
|
||||
|
||||
@ -101,16 +101,16 @@ def interface_fiches() -> None:
|
||||
if regenerate:
|
||||
html_path = generer_fiche(md_source, dossier_choisi, fiche_choisie, SEUILS)
|
||||
|
||||
with open(html_path, encoding="utf-8") as f:
|
||||
with Path(html_path).open(encoding="utf-8") as f:
|
||||
st.markdown(f.read(), unsafe_allow_html=True)
|
||||
|
||||
from utils.persistance import get_champ_statut
|
||||
if not get_champ_statut("login") == "":
|
||||
if get_champ_statut("login") != "":
|
||||
pdf_name = nom_fiche + ".pdf"
|
||||
pdf_path = os.path.join("static", "Fiches", dossier_choisi, pdf_name)
|
||||
pdf_path = Path("static") / "Fiches" / dossier_choisi / pdf_name
|
||||
|
||||
if os.path.exists(pdf_path):
|
||||
with open(pdf_path, "rb") as pdf_file:
|
||||
if pdf_path.exists():
|
||||
with pdf_path.open("rb") as pdf_file:
|
||||
st.download_button(
|
||||
label=str(_("pages.fiches.download_pdf")),
|
||||
data=pdf_file,
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
# Ce module gère à la fois les fiches d'assemblage ET de fabrication.
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
import yaml
|
||||
@ -57,13 +58,13 @@ def build_production_sections(md: str) -> str:
|
||||
produit_data = yaml_data[produit_key]
|
||||
|
||||
pays_data = []
|
||||
for pays_key, pays_info in produit_data.items():
|
||||
for _pays_key, pays_info in produit_data.items():
|
||||
nom_pays = pays_info.get('nom_du_pays', '')
|
||||
part_marche_pays = pays_info.get('part_de_marche', '0%')
|
||||
part_marche_num = float(part_marche_pays.strip('%'))
|
||||
|
||||
acteurs = []
|
||||
for acteur_key, acteur_info in pays_info.get('acteurs', {}).items():
|
||||
for _acteur_key, acteur_info in pays_info.get('acteurs', {}).items():
|
||||
nom_acteur = acteur_info.get('nom_de_l_acteur', '')
|
||||
part_marche_acteur = acteur_info.get('part_de_marche', '0%')
|
||||
pays_origine = acteur_info.get('pays_d_origine', '')
|
||||
@ -123,7 +124,7 @@ def build_production_sections(md: str) -> str:
|
||||
# Charger le contenu de la fiche technique IHH
|
||||
try:
|
||||
# Essayer de lire le fichier depuis le système de fichiers
|
||||
with open(FICHES_CRITICITE["IHH"], encoding="utf-8") as f:
|
||||
with Path(FICHES_CRITICITE["IHH"]).open(encoding="utf-8") as f:
|
||||
ihh_content = f.read()
|
||||
|
||||
# Chercher la section IHH correspondant au schéma et au type de fiche
|
||||
@ -152,6 +153,4 @@ def build_production_sections(md: str) -> str:
|
||||
st.error(f"Erreur lors de la lecture/traitement de la fiche IHH: {e}")
|
||||
|
||||
# Supprimer le bloc YAML du markdown final
|
||||
md_modifie = md_modifie.replace(yaml_block_full, "")
|
||||
|
||||
return md_modifie
|
||||
return md_modifie.replace(yaml_block_full, "")
|
||||
|
||||
@ -23,20 +23,19 @@ def _pairs_dataframe(md: str) -> pd.DataFrame:
|
||||
def _fill(segment: str, pair: dict) -> str:
|
||||
segment = _normalize_unicode(segment)
|
||||
for k, v in pair.items():
|
||||
val = f"{v:.2f}" if isinstance(v, (int, float)) else str(v)
|
||||
val = f"{v:.2f}" if isinstance(v, int | float) else str(v)
|
||||
segment = re.sub(
|
||||
rf"{{{{\s*{re.escape(k)}\s*}}}}",
|
||||
val,
|
||||
segment,
|
||||
flags=re.I,
|
||||
)
|
||||
segment = re.sub(
|
||||
return re.sub(
|
||||
r"ICS\s*=\s*[-+]?\d+(?:\.\d+)?",
|
||||
f"ICS = {pair['ics']:.2f}",
|
||||
segment,
|
||||
count=1,
|
||||
)
|
||||
return segment
|
||||
|
||||
def _segments(md: str):
|
||||
blocs = list(PAIR_RE.finditer(md))
|
||||
@ -75,7 +74,7 @@ def build_dynamic_sections(md_raw: str) -> str:
|
||||
3. Produire une synthèse finale avec l'analyse critique par composant.
|
||||
|
||||
Args:
|
||||
md (str): Contenu brut du fichier Markdown contenant les structures YAML à analyser.
|
||||
md_raw (str): Contenu brut du fichier Markdown contenant les structures YAML à analyser.
|
||||
|
||||
Returns:
|
||||
str: Le markdown enrichi des tableaux de donnée analysés, ou le contenu original inchangé si aucun bloc structuré n'est trouvé.
|
||||
|
||||
@ -16,7 +16,7 @@ def _synth_isg(md: str) -> str:
|
||||
lignes = ["| Pays | WGI | FSI | NDGAIN | ISG |", "| :-- | :-- | :-- | :-- | :-- |"]
|
||||
sorted_pays = sorted(yaml_data.items(), key=lambda x: x[1]['pays'].lower())
|
||||
|
||||
for identifiant, data in sorted_pays:
|
||||
for _identifiant, data in sorted_pays:
|
||||
pays = data['pays']
|
||||
wgi_ps = data['wgi_ps']
|
||||
fsi = data['fsi']
|
||||
@ -56,11 +56,9 @@ def build_isg_sections(md: str) -> str:
|
||||
flags=re.S
|
||||
)
|
||||
|
||||
md_final = re.sub(
|
||||
return re.sub(
|
||||
r"# Criticité par pays\s*\n```yaml[\s\S]*?```\s*",
|
||||
"# Criticité par pays\n\n",
|
||||
md_final,
|
||||
flags=re.S
|
||||
)
|
||||
|
||||
return md_final
|
||||
|
||||
@ -73,11 +73,9 @@ def build_ivc_sections(md: str) -> str:
|
||||
md_final = "\n\n".join(segments)
|
||||
|
||||
# Remplacer la section du tableau final
|
||||
md_final = re.sub(
|
||||
return re.sub(
|
||||
r"## Tableau de synthèse\s*\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
|
||||
f"## Tableau de synthèse\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_table}\n<!---- AUTO-END:TABLEAU-FINAL -->",
|
||||
md_final,
|
||||
flags=re.S
|
||||
)
|
||||
|
||||
return md_final
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
import yaml
|
||||
@ -50,7 +51,7 @@ def _build_extraction_tableau(md: str, produit: str) -> str:
|
||||
|
||||
# Préparer les données pour l'affichage
|
||||
pays_data = []
|
||||
for code_pays, pays_info in extraction_data.items():
|
||||
for _code_pays, pays_info in extraction_data.items():
|
||||
# Trier les acteurs par part de marché décroissante
|
||||
acteurs_tries = sorted(pays_info['acteurs'], key=lambda x: x['part_marche_num'], reverse=True)
|
||||
|
||||
@ -88,15 +89,13 @@ def _build_extraction_tableau(md: str, produit: str) -> str:
|
||||
tableau_final = "\n".join(lignes_tableau)
|
||||
|
||||
# Remplacer la section du tableau dans la fiche
|
||||
md_modifie = re.sub(
|
||||
return re.sub(
|
||||
r"<!---- AUTO-BEGIN:TABLEAU-EXTRACTION -->.*?<!---- AUTO-END:TABLEAU-EXTRACTION -->",
|
||||
f"<!---- AUTO-BEGIN:TABLEAU-EXTRACTION -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-EXTRACTION -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
return md_modifie
|
||||
|
||||
def _build_traitement_tableau(md: str, produit: str) -> str:
|
||||
"""Génère le tableau de traitement pour les fiches de minerai."""
|
||||
# Identifier la section de traitement
|
||||
@ -171,7 +170,7 @@ def _build_traitement_tableau(md: str, produit: str) -> str:
|
||||
|
||||
# Préparer les données pour l'affichage
|
||||
pays_data = []
|
||||
for code_pays, pays_info in traitement_data.items():
|
||||
for _code_pays, pays_info in traitement_data.items():
|
||||
# Trier les acteurs par part de marché décroissante
|
||||
acteurs_tries = sorted(pays_info['acteurs'], key=lambda x: x['part_marche_num'], reverse=True)
|
||||
|
||||
@ -209,15 +208,13 @@ def _build_traitement_tableau(md: str, produit: str) -> str:
|
||||
tableau_final = "\n".join(lignes_tableau)
|
||||
|
||||
# Remplacer la section du tableau dans la fiche
|
||||
md_modifie = re.sub(
|
||||
return re.sub(
|
||||
r"<!---- AUTO-BEGIN:TABLEAU-TRAITEMENT -->.*?<!---- AUTO-END:TABLEAU-TRAITEMENT -->",
|
||||
f"<!---- AUTO-BEGIN:TABLEAU-TRAITEMENT -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-TRAITEMENT -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
return md_modifie
|
||||
|
||||
def _build_reserves_tableau(md: str, produit: str) -> str:
|
||||
"""Génère le tableau des réserves pour les fiches de minerai."""
|
||||
# Identifier la section des réserves
|
||||
@ -263,18 +260,15 @@ def _build_reserves_tableau(md: str, produit: str) -> str:
|
||||
tableau_final = "\n".join(lignes_tableau)
|
||||
|
||||
# Remplacer la section du tableau dans la fiche
|
||||
md_modifie = re.sub(
|
||||
return re.sub(
|
||||
r"<!---- AUTO-BEGIN:TABLEAU-RESERVES -->.*?<!---- AUTO-END:TABLEAU-RESERVES -->",
|
||||
f"<!---- AUTO-BEGIN:TABLEAU-RESERVES -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-RESERVES -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
return md_modifie
|
||||
|
||||
def build_minerai_ivc_section(md: str) -> str:
|
||||
"""Ajoute les informations IVC depuis la fiche technique IVC.md pour un minerai spécifique.
|
||||
"""
|
||||
"""Ajoute les informations IVC depuis la fiche technique IVC.md pour un minerai spécifique."""
|
||||
# Extraire le type de fiche et le produit depuis l'en-tête YAML
|
||||
type_fiche = None
|
||||
produit = None
|
||||
@ -295,8 +289,8 @@ def build_minerai_ivc_section(md: str) -> str:
|
||||
# Injecter les informations IVC depuis la fiche technique
|
||||
try:
|
||||
# Charger le contenu de la fiche technique IVC
|
||||
ivc_path = "Fiches/Criticités/Fiche technique IVC.md"
|
||||
with open(ivc_path, encoding="utf-8") as f:
|
||||
ivc_path = Path("Fiches/Criticités/Fiche technique IVC.md")
|
||||
with ivc_path.open(encoding="utf-8") as f:
|
||||
ivc_content = f.read()
|
||||
|
||||
# Chercher la section correspondant au minerai
|
||||
@ -332,8 +326,7 @@ def build_minerai_ivc_section(md: str) -> str:
|
||||
return md
|
||||
|
||||
def build_minerai_ics_section(md: str) -> str:
|
||||
"""Ajoute les informations ICS depuis la fiche technique ICS.md pour un minerai spécifique.
|
||||
"""
|
||||
"""Ajoute les informations ICS depuis la fiche technique ICS.md pour un minerai spécifique."""
|
||||
# Extraire le type de fiche et le produit depuis l'en-tête YAML
|
||||
type_fiche = None
|
||||
produit = None
|
||||
@ -354,8 +347,8 @@ def build_minerai_ics_section(md: str) -> str:
|
||||
# Injecter les informations ICS depuis la fiche technique
|
||||
try:
|
||||
# Charger le contenu de la fiche technique ICS
|
||||
ics_path = "Fiches/Criticités/Fiche technique ICS.md"
|
||||
with open(ics_path, encoding="utf-8") as f:
|
||||
ics_path = Path("Fiches/Criticités/Fiche technique ICS.md")
|
||||
with ics_path.open(encoding="utf-8") as f:
|
||||
ics_content = f.read()
|
||||
|
||||
# Extraire la section ICS pour le minerai
|
||||
@ -389,8 +382,9 @@ def build_minerai_ics_section(md: str) -> str:
|
||||
return md
|
||||
|
||||
def build_minerai_ics_composant_section(md: str) -> str:
|
||||
"""Ajoute les informations ICS pour tous les composants liés à un minerai spécifique
|
||||
depuis la fiche technique ICS.md, en augmentant d'un niveau les titres.
|
||||
"""Ajoute les informations ICS pour tous les composants liés à un minerai spécifique.
|
||||
|
||||
Depuis la fiche technique ICS.md, en augmentant d'un niveau les titres.
|
||||
"""
|
||||
# Extraire le type de fiche et le produit depuis l'en-tête YAML
|
||||
type_fiche = None
|
||||
@ -412,8 +406,8 @@ def build_minerai_ics_composant_section(md: str) -> str:
|
||||
# Injecter les informations ICS depuis la fiche technique
|
||||
try:
|
||||
# Charger le contenu de la fiche technique ICS
|
||||
ics_path = "Fiches/Criticités/Fiche technique ICS.md"
|
||||
with open(ics_path, encoding="utf-8") as f:
|
||||
ics_path = Path("Fiches/Criticités/Fiche technique ICS.md")
|
||||
with ics_path.open(encoding="utf-8") as f:
|
||||
ics_content = f.read()
|
||||
|
||||
# Rechercher toutes les sections de composants liés au minerai
|
||||
@ -514,8 +508,8 @@ def build_minerai_sections(md: str) -> str:
|
||||
# Injecter les sections IHH depuis la fiche technique
|
||||
try:
|
||||
# Charger le contenu de la fiche technique IHH
|
||||
ihh_path = "Fiches/Criticités/Fiche technique IHH.md"
|
||||
with open(ihh_path, encoding="utf-8") as f:
|
||||
ihh_path = Path("Fiches/Criticités/Fiche technique IHH.md")
|
||||
with ihh_path.open(encoding="utf-8") as f:
|
||||
ihh_content = f.read()
|
||||
|
||||
# D'abord, extraire toute la section concernant le produit
|
||||
@ -580,6 +574,4 @@ def build_minerai_sections(md: str) -> str:
|
||||
md = build_minerai_ics_section(md)
|
||||
|
||||
# Ajouter les informations ICS pour les composants liés au minerai
|
||||
md = build_minerai_ics_composant_section(md)
|
||||
|
||||
return md
|
||||
return build_minerai_ics_composant_section(md)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""fiche_utils.py – outils de lecture / rendu des fiches Markdown (indices et opérations)
|
||||
"""fiche_utils.py – outils de lecture / rendu des fiches Markdown (indices et opérations).
|
||||
|
||||
Dépendances :
|
||||
pip install python-frontmatter pyyaml jinja2
|
||||
@ -12,10 +12,10 @@ Usage :
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
import jinja2
|
||||
@ -135,7 +135,7 @@ def fichier_plus_recent(
|
||||
bool: True si le fichier est plus récent, False sinon.
|
||||
"""
|
||||
try:
|
||||
modif = datetime.fromtimestamp(os.path.getmtime(chemin_fichier), tz=timezone.utc)
|
||||
modif = datetime.fromtimestamp(Path(chemin_fichier).stat().st_mtime, tz=timezone.utc)
|
||||
return modif > reference
|
||||
except Exception:
|
||||
return False
|
||||
@ -159,10 +159,10 @@ def doit_regenerer_fiche(
|
||||
Returns:
|
||||
bool: True si la fiche doit être regénérée, False sinon.
|
||||
"""
|
||||
if not os.path.exists(html_path):
|
||||
if not Path(html_path).exists():
|
||||
return True
|
||||
|
||||
local_mtime = datetime.fromtimestamp(os.path.getmtime(html_path), tz=timezone.utc)
|
||||
local_mtime = datetime.fromtimestamp(Path(html_path).stat().st_mtime, tz=timezone.utc)
|
||||
remote_mtime = recuperer_date_dernier_commit(commit_url)
|
||||
|
||||
if remote_mtime is None or remote_mtime > local_mtime:
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import streamlit as st
|
||||
@ -43,11 +43,11 @@ def charger_fiches_et_labels():
|
||||
dict: Dictionnaire au format {nom_fiche: {"operations": [str], "item": str}}.
|
||||
Retourne un dict vide en cas d'erreur.
|
||||
"""
|
||||
chemin_csv = os.path.join("assets", "fiches_labels.csv")
|
||||
chemin_csv = Path("assets") / "fiches_labels.csv"
|
||||
dictionnaire_fiches = {}
|
||||
|
||||
try:
|
||||
with open(chemin_csv, encoding="utf-8") as fichier_csv:
|
||||
with chemin_csv.open(encoding="utf-8") as fichier_csv:
|
||||
lecteur = csv.DictReader(fichier_csv)
|
||||
for ligne in lecteur:
|
||||
fiche = ligne.get("Fiche")
|
||||
@ -97,13 +97,13 @@ def rechercher_tickets_gitea(fiche_selectionnee):
|
||||
if not cible:
|
||||
return []
|
||||
|
||||
labels_cibles = set([cible["item"]])
|
||||
labels_cibles = {cible["item"]}
|
||||
tickets_associes = []
|
||||
|
||||
for issue in issues:
|
||||
if issue.get("ref") != f"refs/heads/{ENV}":
|
||||
continue
|
||||
issue_labels = set(label.get("name", "") for label in issue.get("labels", []))
|
||||
issue_labels = {label.get("name", "") for label in issue.get("labels", [])}
|
||||
if labels_cibles.issubset(issue_labels):
|
||||
tickets_associes.append(issue)
|
||||
|
||||
@ -137,7 +137,7 @@ def nettoyer_labels(labels):
|
||||
Returns:
|
||||
list[str]: Liste triee de labels uniques et non vides.
|
||||
"""
|
||||
return sorted(set(l.strip() for l in labels if isinstance(l, str) and l.strip()))
|
||||
return sorted({lbl.strip() for lbl in labels if isinstance(lbl, str) and lbl.strip()})
|
||||
|
||||
|
||||
def construire_corps_ticket_markdown(reponses):
|
||||
@ -172,6 +172,4 @@ def creer_ticket_gitea(titre, corps, labels):
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
|
||||
|
||||
reponse = gitea_request("post", url, headers={"Content-Type": "application/json"}, data=json.dumps(data))
|
||||
if not reponse:
|
||||
return False
|
||||
return True
|
||||
return bool(reponse)
|
||||
|
||||
@ -124,7 +124,7 @@ def gerer_previsualisation_et_soumission(reponses, labels, selected_ops, cible):
|
||||
|
||||
if st.button(str(_("pages.fiches.tickets.confirm"))):
|
||||
labels_existants = get_labels_existants()
|
||||
labels_ids = [labels_existants[l] for l in final_labels if l in labels_existants]
|
||||
labels_ids = [labels_existants[lbl] for lbl in final_labels if lbl in labels_existants]
|
||||
if "Backlog" in labels_existants:
|
||||
labels_ids.append(labels_existants["Backlog"])
|
||||
|
||||
|
||||
@ -112,7 +112,7 @@ def afficher_carte_ticket(ticket):
|
||||
created = ticket.get("created_at", "")
|
||||
updated = ticket.get("updated_at", "")
|
||||
body = ticket.get("body", "")
|
||||
labels = [l["name"] for l in ticket.get("labels", []) if "name" in l]
|
||||
labels = [lbl["name"] for lbl in ticket.get("labels", []) if "name" in lbl]
|
||||
|
||||
sujet = ""
|
||||
match = re.search(r"## Sujet de la proposition\s+(.+?)(\n|$)", body, re.DOTALL)
|
||||
|
||||
@ -56,8 +56,6 @@ def selectionner_minerais(
|
||||
Optional[List[str]]: La liste des minerais si une sélection a été effectuée,
|
||||
- None sinon
|
||||
"""
|
||||
minerais_selection = None
|
||||
|
||||
st.markdown(f"## {str(_('pages.ia_nalyse.select_minerals'))}")
|
||||
# Tous les nœuds de niveau 2 (minerai)
|
||||
minerais_nodes = sorted([
|
||||
@ -65,14 +63,12 @@ def selectionner_minerais(
|
||||
if d.get("niveau") and int(str(d.get("niveau")).strip('"')) == 2
|
||||
])
|
||||
|
||||
minerais_selection = st.multiselect(
|
||||
return st.multiselect(
|
||||
str(_("pages.ia_nalyse.filter_by_minerals")),
|
||||
minerais_nodes,
|
||||
key="analyse_minerais"
|
||||
)
|
||||
|
||||
return minerais_selection
|
||||
|
||||
|
||||
def selectionner_noeuds(
|
||||
G: nx.DiGraph,
|
||||
|
||||
@ -18,6 +18,4 @@ def interface_personnalisation(G):
|
||||
|
||||
G = ajouter_produit(G)
|
||||
G = modifier_produit(G)
|
||||
G = importer_exporter_graph(G)
|
||||
|
||||
return G
|
||||
return importer_exporter_graph(G)
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
import networkx as nx
|
||||
"""Script pour générer un rapport factorisé des vulnérabilités critiques.
|
||||
|
||||
"""
|
||||
Script pour générer un rapport factorisé des vulnérabilités critiques
|
||||
suivant la structure définie dans Remarques.md.
|
||||
Suit la structure définie dans Remarques.md.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import networkx as nx
|
||||
import streamlit as st
|
||||
from networkx.drawing.nx_agraph import write_dot
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_chains_md(filepath: str) -> tuple[dict, dict, dict, list, dict, dict]:
|
||||
@ -28,7 +29,7 @@ def parse_chains_md(filepath: str) -> tuple[dict, dict, dict, list, dict, dict]:
|
||||
current_section = None
|
||||
in_section = False
|
||||
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
with Path(filepath).open(encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.strip()
|
||||
if not in_section:
|
||||
@ -105,7 +106,7 @@ def parse_chains_md(filepath: str) -> tuple[dict, dict, dict, list, dict, dict]:
|
||||
descriptions[current_section] += raw_line
|
||||
|
||||
# Parse detailed sections from the complete file
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
with Path(filepath).open(encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract sections using regex patterns
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
import yaml
|
||||
|
||||
@ -99,7 +101,7 @@ def initialiser_seuils(config_path: str) -> dict:
|
||||
seuils = {}
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
with Path(config_path).open(encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f)
|
||||
seuils = config.get("seuils", seuils)
|
||||
except FileNotFoundError:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import streamlit as st
|
||||
|
||||
|
||||
def afficher_bloc_ihh_isg(titre, ihh, isg, details_content="", ui = True) -> str|None:
|
||||
def afficher_bloc_ihh_isg(titre, _ihh, _isg, details_content="", ui = True) -> str|None:
|
||||
"""Affiche un bloc detaille IHH/ISG avec vulnerabilite, tableaux et graphique."""
|
||||
contenu_bloc = ""
|
||||
if ui:
|
||||
@ -86,7 +86,7 @@ def afficher_bloc_ihh_isg(titre, ihh, isg, details_content="", ui = True) -> str
|
||||
return None
|
||||
|
||||
def afficher_section_avec_tableau(lines, section_start, section_end=None):
|
||||
"""Affiche une section contenant un tableau"""
|
||||
"""Affiche une section contenant un tableau."""
|
||||
in_section = False
|
||||
table_lines = []
|
||||
|
||||
@ -104,11 +104,11 @@ def afficher_section_avec_tableau(lines, section_start, section_end=None):
|
||||
break
|
||||
|
||||
if table_lines:
|
||||
contenu = '\n'.join(table_lines)
|
||||
return contenu
|
||||
return '\n'.join(table_lines)
|
||||
return None
|
||||
|
||||
def afficher_section_texte(lines, section_start, section_end_marker=None):
|
||||
"""Affiche le texte d'une section sans les tableaux"""
|
||||
"""Affiche le texte d'une section sans les tableaux."""
|
||||
in_section = False
|
||||
contenu_md = []
|
||||
|
||||
@ -121,8 +121,7 @@ def afficher_section_texte(lines, section_start, section_end_marker=None):
|
||||
if in_section and line.strip() and not line.strip().startswith('|'):
|
||||
contenu_md.append(line + '\n')
|
||||
|
||||
contenu = '\n'.join(contenu_md)
|
||||
return contenu
|
||||
return '\n'.join(contenu_md)
|
||||
|
||||
def afficher_description(titre, description, ui = True) -> str|None:
|
||||
"""Affiche ou retourne la description d'un element du plan d'action.
|
||||
@ -157,17 +156,11 @@ def afficher_description(titre, description, ui = True) -> str|None:
|
||||
break
|
||||
continue
|
||||
# Arrêter aux titres de sections ou tableaux
|
||||
if (line.startswith('####') or
|
||||
line.startswith('|') or
|
||||
line.startswith('**Unité')):
|
||||
if line.startswith(('####', '|', '**Unité')):
|
||||
break
|
||||
description_lines.append(line)
|
||||
|
||||
if description_lines:
|
||||
# Rejoindre les lignes en un seul paragraphe
|
||||
contenu_md = ' '.join(description_lines)
|
||||
else:
|
||||
contenu_md = "Description non disponible"
|
||||
contenu_md = ' '.join(description_lines) if description_lines else "Description non disponible"
|
||||
else:
|
||||
contenu_md = "Description non disponible"
|
||||
|
||||
@ -179,7 +172,7 @@ def afficher_description(titre, description, ui = True) -> str|None:
|
||||
contenu_bloc += contenu_md
|
||||
return contenu_bloc
|
||||
|
||||
def afficher_caracteristiques_minerai(minerai, mineraux_data, details_content="", ui = True) -> str|None:
|
||||
def afficher_caracteristiques_minerai(_minerai, _mineraux_data, details_content="", ui = True) -> str|None:
|
||||
"""Affiche les caracteristiques generales d'un minerai avec indices ICS et IVC.
|
||||
|
||||
Presente la vulnerabilite combinee ICS-IVC, puis les sections detaillees ICS
|
||||
@ -265,3 +258,7 @@ def afficher_caracteristiques_minerai(minerai, mineraux_data, details_content=""
|
||||
return None
|
||||
contenu_bloc += contenu_md
|
||||
return contenu_bloc
|
||||
|
||||
if not ui:
|
||||
return contenu_bloc
|
||||
return None
|
||||
|
||||
@ -97,9 +97,7 @@ def analyser_chaines(chaines: list[dict], produits: dict, composants: dict, mine
|
||||
seuil_poids = resultats[top_n - 1]["poids_total"]
|
||||
|
||||
# Inclure tous ceux dont le poids est égal au seuil
|
||||
top_resultats = [r for r in resultats if r["poids_total"] >= seuil_poids]
|
||||
|
||||
return top_resultats
|
||||
return [r for r in resultats if r["poids_total"] >= seuil_poids]
|
||||
|
||||
def tableau_de_bord(chains: list[dict], produits: dict, composants: dict, mineraux: dict, seuils: dict):
|
||||
"""Affiche le tableau de bord interactif pour selectionner et analyser une chaine.
|
||||
@ -237,7 +235,7 @@ def afficher_criticites(produits: dict, composants: dict, mineraux: dict, sel_pr
|
||||
""")
|
||||
|
||||
def afficher_explications_et_details(
|
||||
couleur_A, poids_A, couleur_F, poids_F, couleur_T, poids_T, couleur_E, poids_E, couleur_M, poids_M,
|
||||
couleur_A, poids_A, couleur_F, poids_F, couleur_T, _poids_T, couleur_E, poids_E, couleur_M, poids_M,
|
||||
produits, composants, mineraux, sel_prod, sel_comp, sel_miner,
|
||||
couleur_A_ihh, couleur_A_isg, couleur_F_ihh, couleur_F_isg, couleur_T_ihh, couleur_T_isg,couleur_E_ihh, couleur_E_isg, couleur_M_ics, couleur_M_ivc, ui = True) -> str|None:
|
||||
"""Affiche les explications detaillees des indices et ponderations pour chaque operation."""
|
||||
@ -277,12 +275,12 @@ def afficher_explications_et_details(
|
||||
return None
|
||||
return contenu_md
|
||||
|
||||
def afficher_preconisations_et_indicateurs_generiques(niveau_criticite: dict, poids_A: int, poids_F: int, poids_T: int, poids_E: int, poids_M: int, ui: bool = True) -> tuple[str|None,str|None]:
|
||||
def afficher_preconisations_et_indicateurs_generiques(niveau_criticite: dict, _poids_A: int, _poids_F: int, _poids_T: int, _poids_E: int, _poids_M: int, ui: bool = True) -> tuple[str|None,str|None]:
|
||||
"""Affiche les preconisations et indicateurs generiques selon le niveau de criticite global."""
|
||||
contenu_md_left = "### Préconisations :\n\n"
|
||||
contenu_md_left += "Mise en œuvre : \n"
|
||||
|
||||
for niveau, contenu in PRECONISATIONS.items():
|
||||
for niveau, _contenu in PRECONISATIONS.items():
|
||||
if niveau in niveau_criticite:
|
||||
contenu_md_left += f"* {colorer_couleurs(niveau)}\n"
|
||||
for p in PRECONISATIONS[niveau]:
|
||||
@ -291,7 +289,7 @@ def afficher_preconisations_et_indicateurs_generiques(niveau_criticite: dict, po
|
||||
contenu_md_right = "### Indicateurs :\n\n"
|
||||
contenu_md_right += "Mise en œuvre : \n"
|
||||
|
||||
for niveau, contenu in INDICATEURS.items():
|
||||
for niveau, _contenu in INDICATEURS.items():
|
||||
if niveau in niveau_criticite:
|
||||
contenu_md_right += f"* {colorer_couleurs(niveau)}\n"
|
||||
for p in INDICATEURS[niveau]:
|
||||
@ -311,7 +309,7 @@ def afficher_preconisations_specifiques(operation: str, niveau_criticite_operati
|
||||
"""Genere les preconisations specifiques a une operation selon son niveau de criticite."""
|
||||
contenu_md = "#### Préconisations :\n\n"
|
||||
contenu_md += "Mise en œuvre : \n"
|
||||
for niveau, contenu in PRECONISATIONS[operation].items():
|
||||
for niveau, _contenu in PRECONISATIONS[operation].items():
|
||||
if niveau in niveau_criticite_operation[operation]:
|
||||
contenu_md += f"* {colorer_couleurs(niveau)}\n"
|
||||
for p in PRECONISATIONS[operation][niveau]:
|
||||
@ -322,7 +320,7 @@ def afficher_indicateurs_specifiques(operation: str, niveau_criticite_operation:
|
||||
"""Genere les indicateurs specifiques a une operation selon son niveau de criticite."""
|
||||
contenu_md = "#### Indicateurs :\n\n"
|
||||
contenu_md += "Mise en œuvre : \n"
|
||||
for niveau, contenu in INDICATEURS[operation].items():
|
||||
for niveau, _contenu in INDICATEURS[operation].items():
|
||||
if niveau in niveau_criticite_operation[operation]:
|
||||
contenu_md += f"* {colorer_couleurs(niveau)}\n"
|
||||
for p in INDICATEURS[operation][niveau]:
|
||||
@ -398,7 +396,6 @@ def afficher_details_operations(produits, composants, mineraux, sel_prod, sel_co
|
||||
|
||||
def initialiser_interface(filepath: str, config_path: str = "assets/config.yaml") -> None:
|
||||
"""Point d'entree principal pour l'interface du plan d'action : charge donnees et affiche toutes sections."""
|
||||
|
||||
produits, composants, mineraux, chains, descriptions, details_sections = parse_chains_md(filepath)
|
||||
|
||||
if not chains:
|
||||
|
||||
@ -1,17 +1,13 @@
|
||||
# batch_ia/__init__.py
|
||||
|
||||
# config.py
|
||||
from .utils.config import TEMPLATE_PATH, load_config, session_uuid, TEMP_SECTIONS
|
||||
from .utils.config import TEMP_SECTIONS, TEMPLATE_PATH, load_config, session_uuid
|
||||
|
||||
# files.py
|
||||
from .utils.files import write_report
|
||||
|
||||
# graphs.py
|
||||
from .utils.graphs import (
|
||||
parse_graphs,
|
||||
extract_data_from_graph,
|
||||
calculate_vulnerabilities
|
||||
)
|
||||
from .utils.graphs import calculate_vulnerabilities, extract_data_from_graph, parse_graphs
|
||||
|
||||
# sections.py
|
||||
from .utils.sections import generate_report
|
||||
|
||||
@ -1,44 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Script pour générer un rapport factorisé des vulnérabilités critiques
|
||||
"""Script pour générer un rapport factorisé des vulnérabilités critiques
|
||||
suivant la structure définie dans Remarques.md.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from utils.config import (
|
||||
TEMP_SECTIONS,
|
||||
TEMPLATE_PATH, session_uuid,
|
||||
load_config
|
||||
)
|
||||
|
||||
from utils.files import (
|
||||
write_report
|
||||
)
|
||||
|
||||
from utils.graphs import (
|
||||
parse_graphs,
|
||||
extract_data_from_graph,
|
||||
calculate_vulnerabilities
|
||||
)
|
||||
|
||||
from utils.sections import (
|
||||
generate_report
|
||||
)
|
||||
|
||||
from utils.sections_utils import (
|
||||
nettoyer_texte_fr
|
||||
)
|
||||
|
||||
from utils.ia import (
|
||||
ingest_document,
|
||||
ia_analyse,
|
||||
supprimer_fichiers,
|
||||
generer_rapport_final
|
||||
)
|
||||
from utils.config import TEMP_SECTIONS, TEMPLATE_PATH, load_config, session_uuid
|
||||
from utils.files import write_report
|
||||
from utils.graphs import calculate_vulnerabilities, extract_data_from_graph, parse_graphs
|
||||
from utils.ia import generer_rapport_final, ia_analyse, ingest_document, supprimer_fichiers
|
||||
from utils.sections import generate_report
|
||||
from utils.sections_utils import nettoyer_texte_fr
|
||||
|
||||
|
||||
def main(dot_path, output_path):
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import time
|
||||
import subprocess
|
||||
from batch_utils import charger_status, sauvegarder_status, JOBS_DIR
|
||||
import time
|
||||
|
||||
from batch_utils import JOBS_DIR, charger_status, sauvegarder_status
|
||||
|
||||
while True:
|
||||
status = charger_status()
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from networkx.drawing.nx_agraph import write_dot
|
||||
|
||||
import streamlit as st
|
||||
from networkx.drawing.nx_agraph import write_dot
|
||||
|
||||
from utils.translations import _
|
||||
|
||||
BATCH_DIR = Path(__file__).resolve().parent
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script de nettoyage pour PrivateGPT
|
||||
"""Script de nettoyage pour PrivateGPT
|
||||
|
||||
Ce script permet de lister et supprimer les documents ingérés dans PrivateGPT.
|
||||
Options:
|
||||
@ -10,17 +9,17 @@ Options:
|
||||
- Supprimer tous les documents
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import requests
|
||||
import time
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
# Configuration de l'API PrivateGPT
|
||||
PGPT_URL = "http://127.0.0.1:8001"
|
||||
API_URL = f"{PGPT_URL}/v1"
|
||||
|
||||
def list_documents() -> List[Dict[str, Any]]:
|
||||
def list_documents() -> list[dict[str, Any]]:
|
||||
"""Liste tous les documents ingérés et renvoie la liste des métadonnées"""
|
||||
try:
|
||||
# Récupérer la liste des documents
|
||||
@ -61,20 +60,17 @@ def delete_document(doc_id: str) -> bool:
|
||||
response = requests.delete(f"{API_URL}/ingest/{doc_id}")
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
print(f"⚠️ Échec de la suppression de l'ID {doc_id}: Code {response.status_code}")
|
||||
return False
|
||||
print(f"⚠️ Échec de la suppression de l'ID {doc_id}: Code {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur lors de la suppression de l'ID {doc_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def delete_documents_by_criteria(pattern) -> int:
|
||||
"""
|
||||
Supprime des documents selon différents critères
|
||||
"""Supprime des documents selon différents critères
|
||||
Retourne le nombre de documents supprimés
|
||||
"""
|
||||
|
||||
documents = list_documents()
|
||||
|
||||
if not documents or not pattern:
|
||||
@ -88,7 +84,7 @@ def delete_documents_by_criteria(pattern) -> int:
|
||||
try:
|
||||
regex = re.compile(pattern)
|
||||
docs_to_delete = [doc for doc in documents if regex.search(doc["filename"])]
|
||||
except re.error as e:
|
||||
except re.error:
|
||||
return 0
|
||||
|
||||
# Supprimer les documents
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import os
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def init_uuid():
|
||||
if not TEMP_SECTIONS.exists():
|
||||
@ -48,17 +50,15 @@ def load_config(thresholds_path=THRESHOLDS_PATH):
|
||||
config = {}
|
||||
# Charger les seuils
|
||||
if os.path.exists(thresholds_path):
|
||||
with open(thresholds_path, 'r', encoding='utf-8') as f:
|
||||
with open(thresholds_path, encoding='utf-8') as f:
|
||||
thresholds = yaml.safe_load(f)
|
||||
config['thresholds'] = thresholds.get('seuils', {})
|
||||
return config
|
||||
|
||||
def determine_threshold_color(value, index_type, thresholds):
|
||||
"""
|
||||
Détermine la couleur du seuil en fonction du type d'indice et de sa valeur.
|
||||
"""Détermine la couleur du seuil en fonction du type d'indice et de sa valeur.
|
||||
Utilise les seuils de config.yaml si disponibles.
|
||||
"""
|
||||
|
||||
# Récupérer les seuils pour cet indice
|
||||
if index_type in thresholds:
|
||||
index_thresholds = thresholds[index_type]
|
||||
@ -67,12 +67,12 @@ def determine_threshold_color(value, index_type, thresholds):
|
||||
index_thresholds["vert"]["max"] is not None and value < index_thresholds["vert"]["max"]:
|
||||
suffix = get_suffix_for_index(index_type, "vert")
|
||||
return "Vert", suffix
|
||||
elif "orange" in index_thresholds and "min" in index_thresholds["orange"] and "max" in index_thresholds["orange"] and \
|
||||
if "orange" in index_thresholds and "min" in index_thresholds["orange"] and "max" in index_thresholds["orange"] and \
|
||||
index_thresholds["orange"]["min"] is not None and index_thresholds["orange"]["max"] is not None and \
|
||||
index_thresholds["orange"]["min"] <= value < index_thresholds["orange"]["max"]:
|
||||
suffix = get_suffix_for_index(index_type, "orange")
|
||||
return "Orange", suffix
|
||||
elif "rouge" in index_thresholds and "min" in index_thresholds["rouge"] and \
|
||||
if "rouge" in index_thresholds and "min" in index_thresholds["rouge"] and \
|
||||
index_thresholds["rouge"]["min"] is not None and value >= index_thresholds["rouge"]["min"]:
|
||||
suffix = get_suffix_for_index(index_type, "rouge")
|
||||
return "Rouge", suffix
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from .config import (
|
||||
CORPUS_DIR
|
||||
)
|
||||
from .config import CORPUS_DIR
|
||||
|
||||
|
||||
def strip_prefix(name):
|
||||
"""Supprime le préfixe numérique éventuel d'un nom de fichier ou de dossier."""
|
||||
return re.sub(r'^\d+[-_ ]*', '', name).lower()
|
||||
|
||||
def find_prefixed_directory(pattern, base_path=None):
|
||||
"""
|
||||
Recherche un sous-répertoire dont le nom (sans préfixe) correspond au pattern.
|
||||
"""Recherche un sous-répertoire dont le nom (sans préfixe) correspond au pattern.
|
||||
|
||||
Args:
|
||||
pattern: Nom du répertoire sans préfixe
|
||||
@ -38,8 +36,7 @@ def find_prefixed_directory(pattern, base_path=None):
|
||||
return None
|
||||
|
||||
def find_corpus_file(pattern, base_path=None):
|
||||
"""
|
||||
Recherche récursive dans le corpus d'un fichier en ignorant les préfixes numériques dans les dossiers et fichiers.
|
||||
"""Recherche récursive dans le corpus d'un fichier en ignorant les préfixes numériques dans les dossiers et fichiers.
|
||||
|
||||
Args:
|
||||
pattern: Chemin relatif type "sous-dossier/nom-fichier"
|
||||
@ -48,7 +45,6 @@ def find_corpus_file(pattern, base_path=None):
|
||||
Returns:
|
||||
Chemin relatif du fichier trouvé ou None
|
||||
"""
|
||||
|
||||
if base_path:
|
||||
search_path = os.path.join(CORPUS_DIR, base_path)
|
||||
else:
|
||||
@ -83,8 +79,7 @@ def find_corpus_file(pattern, base_path=None):
|
||||
|
||||
|
||||
def read_corpus_file(file_path, remove_first_title=False, shift_titles=0):
|
||||
"""
|
||||
Lit un fichier du corpus et applique les transformations demandées.
|
||||
"""Lit un fichier du corpus et applique les transformations demandées.
|
||||
|
||||
Args:
|
||||
file_path: Chemin relatif du fichier dans le corpus
|
||||
@ -101,7 +96,7 @@ def read_corpus_file(file_path, remove_first_title=False, shift_titles=0):
|
||||
return f"Fichier non trouvé: {file_path}"
|
||||
|
||||
# # print(f"Lecture du fichier: {full_path}")
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
with open(full_path, encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Supprimer la première ligne si c'est un titre et si demandé
|
||||
@ -124,7 +119,6 @@ def read_corpus_file(file_path, remove_first_title=False, shift_titles=0):
|
||||
|
||||
def write_report(report, fichier):
|
||||
"""Écrit le rapport généré dans le fichier spécifié."""
|
||||
|
||||
report = re.sub(r'<!----.*?-->', '', report)
|
||||
report = re.sub(r'\n\n\n+', '\n\n', report)
|
||||
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from networkx.drawing.nx_agraph import read_dot
|
||||
|
||||
from .config import (
|
||||
REFERENCE_GRAPH_PATH,
|
||||
determine_threshold_color, get_weight_for_color
|
||||
)
|
||||
from .config import REFERENCE_GRAPH_PATH, determine_threshold_color, get_weight_for_color
|
||||
|
||||
|
||||
def parse_graphs(graphe_path):
|
||||
"""
|
||||
Charge et analyse les graphes DOT (analyse et référence).
|
||||
"""Charge et analyse les graphes DOT (analyse et référence).
|
||||
"""
|
||||
print(graphe_path)
|
||||
# Charger le graphe à analyser
|
||||
@ -69,8 +67,7 @@ def parse_graphs(graphe_path):
|
||||
sys.exit(1)
|
||||
|
||||
def extract_data_from_graph(graph, ref_graph):
|
||||
"""
|
||||
Extrait toutes les données pertinentes des graphes DOT.
|
||||
"""Extrait toutes les données pertinentes des graphes DOT.
|
||||
"""
|
||||
data = {
|
||||
"products": {}, # Produits finaux (N0)
|
||||
@ -411,8 +408,7 @@ def extract_data_from_graph(graph, ref_graph):
|
||||
return data
|
||||
|
||||
def calculate_vulnerabilities(data, config):
|
||||
"""
|
||||
Calcule les vulnérabilités combinées pour toutes les opérations et minerais.
|
||||
"""Calcule les vulnérabilités combinées pour toutes les opérations et minerais.
|
||||
"""
|
||||
thresholds = config.get('thresholds', {})
|
||||
results = {
|
||||
|
||||
@ -1,20 +1,15 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
import requests
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import streamlit as st
|
||||
from nettoyer_pgpt import delete_documents_by_criteria
|
||||
|
||||
from nettoyer_pgpt import (
|
||||
delete_documents_by_criteria
|
||||
)
|
||||
from utils.config import API_URL, PROMPT_METHODOLOGIE, TEMP_SECTIONS, session_uuid
|
||||
|
||||
from utils.config import (
|
||||
TEMP_SECTIONS,
|
||||
session_uuid,
|
||||
API_URL, PROMPT_METHODOLOGIE
|
||||
)
|
||||
|
||||
def ingest_document(file_path: Path) -> bool:
|
||||
"""Ingère un document dans PrivateGPT"""
|
||||
@ -83,9 +78,8 @@ def generate_text(input_file, full_prompt, system_message, temperature = "0.3",
|
||||
result = response.json()
|
||||
if "choices" in result and len(result["choices"]) > 0:
|
||||
return result["choices"][0]["message"]["content"]
|
||||
else:
|
||||
print("⚠️ Format de réponse inattendu:", json.dumps(result, indent=2))
|
||||
return None
|
||||
print("⚠️ Format de réponse inattendu:", json.dumps(result, indent=2))
|
||||
return None
|
||||
|
||||
except requests.RequestException as e:
|
||||
print(f"❌ Erreur lors de la génération de texte: {e}")
|
||||
@ -270,7 +264,7 @@ def supprimer_fichiers(session_uuid):
|
||||
for temp_file in TEMP_SECTIONS.glob(f"*{session_uuid}*.md"):
|
||||
temp_file.unlink()
|
||||
return True
|
||||
except:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def generer_rapport_final(rapport, analyse, resultat):
|
||||
|
||||
@ -1,30 +1,17 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
from .config import (
|
||||
CORPUS_DIR,
|
||||
TEMPLATE_PATH,
|
||||
determine_threshold_color
|
||||
)
|
||||
from .config import CORPUS_DIR, TEMPLATE_PATH, determine_threshold_color
|
||||
from .files import find_corpus_file, find_prefixed_directory, read_corpus_file, write_report
|
||||
from .sections_utils import extraire_sections_par_mot_cle, trouver_dossier_composant
|
||||
|
||||
from .files import (
|
||||
find_prefixed_directory,
|
||||
find_corpus_file,
|
||||
write_report,
|
||||
read_corpus_file
|
||||
)
|
||||
|
||||
from .sections_utils import (
|
||||
trouver_dossier_composant,
|
||||
extraire_sections_par_mot_cle
|
||||
)
|
||||
|
||||
def generate_introduction_section(data):
|
||||
"""
|
||||
Génère la section d'introduction du rapport.
|
||||
"""Génère la section d'introduction du rapport.
|
||||
"""
|
||||
products = [p["label"] for p in data["products"].values()]
|
||||
components = [c["label"] for c in data["components"].values()]
|
||||
@ -41,8 +28,7 @@ def generate_introduction_section(data):
|
||||
return "\n".join(template)
|
||||
|
||||
def generate_methodology_section():
|
||||
"""
|
||||
Génère la section méthodologie du rapport.
|
||||
"""Génère la section méthodologie du rapport.
|
||||
"""
|
||||
template = []
|
||||
template.append("## Méthodologie d'analyse des risques\n")
|
||||
@ -198,8 +184,7 @@ def generate_methodology_section():
|
||||
return "\n".join(template)
|
||||
|
||||
def generate_operations_section(data, results, config):
|
||||
"""
|
||||
Génère la section détaillant les opérations (assemblage, fabrication, extraction, traitement).
|
||||
"""Génère la section détaillant les opérations (assemblage, fabrication, extraction, traitement).
|
||||
"""
|
||||
# # print("DEBUG: Génération de la section des opérations")
|
||||
# # print(f"DEBUG: Nombre de produits: {len(data['products'])}")
|
||||
@ -376,8 +361,7 @@ def generate_operations_section(data, results, config):
|
||||
return result
|
||||
|
||||
def generate_minerals_section(data, results, config):
|
||||
"""
|
||||
Génère la section détaillant les minerais et leurs opérations d'extraction et traitement.
|
||||
"""Génère la section détaillant les minerais et leurs opérations d'extraction et traitement.
|
||||
"""
|
||||
template = []
|
||||
template.append("## Détails des minerais\n")
|
||||
@ -574,8 +558,7 @@ def generate_minerals_section(data, results, config):
|
||||
return "\n".join(template)
|
||||
|
||||
def generate_critical_paths_section(data, results):
|
||||
"""
|
||||
Génère la section des chemins critiques.
|
||||
"""Génère la section des chemins critiques.
|
||||
"""
|
||||
template = []
|
||||
template.append("## Chemins critiques\n")
|
||||
@ -713,8 +696,7 @@ def slugify(text):
|
||||
return re.sub(r'\W+', '-', text.strip()).strip('-').lower()
|
||||
|
||||
def generate_report(data, results, config):
|
||||
"""
|
||||
Génère le rapport complet structuré selon les spécifications.
|
||||
"""Génère le rapport complet structuré selon les spécifications.
|
||||
"""
|
||||
# Titre principal
|
||||
report_titre = ["# Évaluation des vulnérabilités critiques\n"]
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from .config import CORPUS_DIR
|
||||
|
||||
from .config import (
|
||||
CORPUS_DIR
|
||||
)
|
||||
|
||||
def composant_match(nom_composant, nom_dossier):
|
||||
"""
|
||||
Vérifie si le nom du composant correspond approximativement à un nom de dossier (lettres et chiffres dans le même ordre).
|
||||
"""Vérifie si le nom du composant correspond approximativement à un nom de dossier (lettres et chiffres dans le même ordre).
|
||||
"""
|
||||
def clean(s):
|
||||
return ''.join(c.lower() for c in s if c.isalnum())
|
||||
@ -22,8 +20,7 @@ def composant_match(nom_composant, nom_dossier):
|
||||
return all(c in it for c in cleaned_comp)
|
||||
|
||||
def trouver_dossier_composant(nom_composant, base_path, prefixe):
|
||||
"""
|
||||
Parcourt les sous-répertoires de base_path et retourne celui qui correspond au composant.
|
||||
"""Parcourt les sous-répertoires de base_path et retourne celui qui correspond au composant.
|
||||
"""
|
||||
search_path = os.path.join(CORPUS_DIR, base_path)
|
||||
if not os.path.exists(search_path):
|
||||
@ -36,8 +33,7 @@ def trouver_dossier_composant(nom_composant, base_path, prefixe):
|
||||
return None
|
||||
|
||||
def extraire_sections_par_mot_cle(fichier_markdown: Path) -> dict:
|
||||
"""
|
||||
Extrait les sections de niveau 3 uniquement dans la section
|
||||
"""Extrait les sections de niveau 3 uniquement dans la section
|
||||
'## Chaînes avec risque critique' du fichier Markdown,
|
||||
et les regroupe par mot-clé (ce qui se trouve entre '### ' et ' →').
|
||||
Réduit chaque titre d’un niveau (#).
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
import streamlit as st
|
||||
import requests
|
||||
import logging
|
||||
import os
|
||||
from utils.translations import _
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import streamlit as st
|
||||
|
||||
from utils.persistance import get_champ_statut, maj_champ_statut
|
||||
from utils.translations import _
|
||||
|
||||
|
||||
def initialiser_logger():
|
||||
LOG_FILE_PATH = "/var/log/fabnum-auth.log"
|
||||
if not os.path.exists(os.path.dirname(LOG_FILE_PATH)):
|
||||
os.makedirs(os.path.dirname(LOG_FILE_PATH), exist_ok=True)
|
||||
"""Initialise et retourne le logger d'authentification."""
|
||||
LOG_FILE_PATH = Path("/var/log/fabnum-auth.log")
|
||||
if not LOG_FILE_PATH.parent.exists():
|
||||
LOG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("auth_logger")
|
||||
logger.setLevel(logging.INFO)
|
||||
@ -20,6 +25,7 @@ def initialiser_logger():
|
||||
return logger
|
||||
|
||||
def connexion():
|
||||
"""Affiche le formulaire de connexion et authentifie l'utilisateur via Gitea."""
|
||||
login = get_champ_statut("login")
|
||||
if login == "":
|
||||
auth_title = str(_("auth.title"))
|
||||
@ -40,7 +46,7 @@ def connexion():
|
||||
# Ajout d'un champ identifiant fictif pour activer l'autocomplétion navigateur
|
||||
# et permettre de stocker le token comme un mot de passe par le navigateur
|
||||
# L'identifiant n'est donc pas utilisé par la suite ; il est caché en CSS
|
||||
identifiant = st.text_input(str(_("auth.username")), value="fabnum-connexion", key="nom_utilisateur")
|
||||
_identifiant = st.text_input(str(_("auth.username")), value="fabnum-connexion", key="nom_utilisateur")
|
||||
token = st.text_input(str(_("auth.token")), type="password")
|
||||
submitted = st.form_submit_button(str(_("auth.login")), icon=":material/login:")
|
||||
|
||||
@ -87,8 +93,9 @@ def connexion():
|
||||
|
||||
|
||||
def bouton_deconnexion():
|
||||
"""Affiche le bouton de déconnexion dans la barre latérale."""
|
||||
login = get_champ_statut("login")
|
||||
if not login == "":
|
||||
if login != "":
|
||||
auth_title = str(_("auth.title"))
|
||||
st.html(f"""
|
||||
<section role="region" aria-label="region-authentification">
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import streamlit as st
|
||||
|
||||
from utils.translations import _
|
||||
|
||||
|
||||
def afficher_pied_de_page():
|
||||
"""Affiche le pied de page avec les mentions légales et crédits."""
|
||||
st.markdown("""
|
||||
<section role="region" aria-label="Contenu principal" id="main-content">
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import streamlit as st
|
||||
|
||||
from config import ENV
|
||||
from utils.translations import _
|
||||
from utils.persistance import get_session_id
|
||||
from utils.translations import _
|
||||
|
||||
|
||||
def afficher_entete():
|
||||
"""Affiche l'en-tête de l'application avec le titre et le sous-titre."""
|
||||
header = f"""
|
||||
<header role="banner" aria-labelledby="entete-header">
|
||||
<div class='wide-header'>
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import streamlit as st
|
||||
from components.connexion import connexion, bouton_deconnexion
|
||||
import streamlit.components.v1 as components
|
||||
from utils.translations import _
|
||||
|
||||
from components.connexion import bouton_deconnexion, connexion
|
||||
from utils.persistance import get_champ_statut, maj_champ_statut
|
||||
from utils.translations import _
|
||||
|
||||
|
||||
def afficher_menu():
|
||||
"""Affiche le menu de navigation et les options de thème dans la barre latérale."""
|
||||
with st.sidebar:
|
||||
st.markdown(f"""
|
||||
<nav role="navigation" aria-label="{str(_('sidebar.menu'))}">
|
||||
@ -23,7 +26,7 @@ def afficher_menu():
|
||||
str(_("navigation.instructions")),
|
||||
str(_("navigation.personnalisation")),
|
||||
str(_("navigation.analyse")),
|
||||
*([str(_("navigation.ia_nalyse"))] if not get_champ_statut("login") == "" else []),
|
||||
*([str(_("navigation.ia_nalyse"))] if get_champ_statut("login") != "" else []),
|
||||
*([str(_("navigation.plan_d_action"))]),
|
||||
str(_("navigation.visualisations")),
|
||||
str(_("navigation.fiches"))
|
||||
@ -80,7 +83,7 @@ def afficher_menu():
|
||||
|
||||
connexion()
|
||||
|
||||
if not get_champ_statut("login") == "":
|
||||
if get_champ_statut("login") != "":
|
||||
bouton_deconnexion()
|
||||
|
||||
# === RERUN SI BESOIN ===
|
||||
@ -97,6 +100,7 @@ def afficher_menu():
|
||||
# sudo chcon -Rt httpd_sys_content_t /chemin/d/acces/assets/
|
||||
#
|
||||
def afficher_impact(total_bytes):
|
||||
"""Affiche le widget d'impact environnemental CO2 dans la barre latérale."""
|
||||
impact_label = str(_("sidebar.impact"))
|
||||
loading_text = str(_("sidebar.loading"))
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import os
|
||||
|
||||
import streamlit as st
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@ -7,6 +8,7 @@ load_dotenv(".env.local", override=True)
|
||||
|
||||
# Fonction pour déterminer l'environnement à partir de l'en-tête X-Environment
|
||||
def determine_environment():
|
||||
"""Détermine l'environnement d'exécution à partir de l'en-tête Nginx X-Environment."""
|
||||
# Valeur par défaut (si aucun en-tête n'est détecté)
|
||||
environment = "dev"
|
||||
|
||||
@ -42,7 +44,7 @@ FICHE_ISG = os.getenv("FICHE_ISG")
|
||||
# Optionnel : vérification + fallback
|
||||
for key, value in [("FICHE_IHH", FICHE_IHH), ("FICHE_ICS", FICHE_ICS), ("FICHE_IVC", FICHE_IVC), ("FICHE_ISG", FICHE_ISG)]:
|
||||
if not value:
|
||||
raise EnvironmentError(f"Variable d'environnement '{key}' non définie.")
|
||||
raise OSError(f"Variable d'environnement '{key}' non définie.")
|
||||
|
||||
FICHES_CRITICITE = {
|
||||
"IHH": FICHE_IHH,
|
||||
|
||||
204
docs/plans/2026-03-02-audit-qualite-design.md
Normal file
204
docs/plans/2026-03-02-audit-qualite-design.md
Normal file
@ -0,0 +1,204 @@
|
||||
# Audit qualite, securite et simplicite - FabNum
|
||||
|
||||
Date : 2 mars 2026
|
||||
Objectif : Bilan de sante complet du projet
|
||||
|
||||
## Contexte
|
||||
|
||||
FabNum est une application Streamlit (Python 3.14) d'analyse de chaines de valeur numeriques.
|
||||
Le projet comprend ~11 200 lignes de code Python reparties sur 6 modules applicatifs,
|
||||
7 utilitaires, 4 composants UI et des scripts auxiliaires (IA, batch, generation).
|
||||
|
||||
### Etat actuel
|
||||
|
||||
| Metrique | Valeur |
|
||||
|---|---|
|
||||
| Securite (Bandit) | 0 vulnerabilite |
|
||||
| Dependances (pip-audit --local) | 0 vulnerabilite |
|
||||
| Qualite (ruff) | 907 erreurs |
|
||||
| Tests | 67 tests, 100% passent |
|
||||
| Couverture | 16% |
|
||||
| Bugs averes | 1 (F821 nom indefini) |
|
||||
| CLAUDE.md | Absent |
|
||||
|
||||
## Plan par phases
|
||||
|
||||
### Phase 1 : Critique (bugs et erreurs dangereuses)
|
||||
|
||||
Objectif : Corriger les problemes qui causent ou masquent des bugs.
|
||||
|
||||
| Regle | Nb | Description |
|
||||
|---|---|---|
|
||||
| F821 | 1 | Nom indefini `ingested_section_ids` dans scripts/generer_analyse.py:471 |
|
||||
| E722 | 5 | `except:` nu (masque toutes les exceptions y compris KeyboardInterrupt) |
|
||||
| B904 | 1 | `raise` sans `from` dans un except (perte de contexte d'erreur) |
|
||||
| W605 | 1 | Sequence d'echappement invalide dans une chaine |
|
||||
|
||||
Total : 8 erreurs. Effort : ~30 minutes.
|
||||
Verification : `ruff check . --exclude venv,pgpt,.git --select F821,E722,B904,W605`
|
||||
|
||||
### Phase 2 : Nettoyage automatique (ruff --fix)
|
||||
|
||||
Objectif : Appliquer les 330 corrections automatiques sans risque.
|
||||
|
||||
Commande : `ruff check . --exclude venv,pgpt,.git --fix`
|
||||
|
||||
Principales corrections :
|
||||
- W293 (125) : Espaces sur lignes vides
|
||||
- I001 (41) : Imports non tries
|
||||
- D212 (38) : Docstrings multi-lignes mal formatees
|
||||
- F401 (27) : Imports inutilises
|
||||
- UP006 (27) : Annotations type obsoletes (List -> list, Dict -> dict)
|
||||
- RET505 (16) : Else superflu apres return
|
||||
- W291 (15) : Espaces en fin de ligne
|
||||
- Autres (41) : UP009, UP015, F541, D202, W292, UP007, UP024, SIM114, SIM300, UP012
|
||||
|
||||
Verification apres fix : relancer les 67 tests pour confirmer aucune regression.
|
||||
|
||||
### Phase 3 : Qualite manuelle (577 erreurs restantes)
|
||||
|
||||
Objectif : Ameliorer la qualite du code manuellement, par sous-chantiers.
|
||||
|
||||
#### 3a. Modernisation pathlib (~220 erreurs)
|
||||
|
||||
Remplacer les appels `os.path.*` et `os.makedirs` par `pathlib.Path`.
|
||||
Regles : PTH118, PTH123, PTH110, PTH208, PTH103, PTH120, PTH122, PTH112,
|
||||
PTH100, PTH119, PTH204, PTH207, PTH113.
|
||||
|
||||
Approche : traiter fichier par fichier, en commencant par utils/ puis app/.
|
||||
|
||||
#### 3b. Nommage (88 erreurs)
|
||||
|
||||
N803 : noms d'arguments invalides.
|
||||
Decision a prendre : `G` (convention NetworkX pour les graphes) est utilise partout.
|
||||
Options :
|
||||
- Ignorer N803 globalement pour les arguments nommes `G`, `G_temp`, `G_temp_ivc`
|
||||
- Renommer en `graph` partout (refactoring lourd)
|
||||
Recommandation : ajouter `G` a la liste des noms acceptes dans la config ruff.
|
||||
|
||||
#### 3c. Documentation (90 erreurs)
|
||||
|
||||
- D103 (35) : Fonctions publiques sans docstring
|
||||
- D415 (26) : Docstrings sans ponctuation finale
|
||||
- D200 (14) : Docstrings multi-lignes inutiles
|
||||
- D205 (12) : Ligne vide manquante apres resume de docstring
|
||||
- D417 (2) : Parametres non documentes
|
||||
- D301 (1) : Sequence d'echappement dans docstring
|
||||
|
||||
#### 3d. Code inutilise (~53 erreurs)
|
||||
|
||||
- B007 (30) : Variables de boucle inutilisees (remplacer par `_`)
|
||||
- ARG001 (11) : Arguments de fonction inutilises
|
||||
- ARG002 (7) : Arguments de methode inutilises
|
||||
- F841 (5) : Variables locales inutilisees
|
||||
|
||||
#### 3e. Imports (46 erreurs)
|
||||
|
||||
- E402 (29) : Imports pas en haut de fichier
|
||||
Note : certains sont voulus dans Streamlit (imports apres st.set_page_config)
|
||||
Decision : ajouter `# noqa: E402` pour les cas legitimes
|
||||
- UP035 (17) : Imports deprecies (typing.List -> list, etc.)
|
||||
|
||||
#### 3f. Simplifications (~59 erreurs)
|
||||
|
||||
- RET504 (23) : Assignation inutile avant return
|
||||
- SIM108 (7) : If/else remplacable par expression ternaire
|
||||
- SIM105 (6) : Try/except remplacable par contextlib.suppress
|
||||
- SIM102 (5) : If imbriques collapsibles
|
||||
- SIM201 (4) : Negation de comparaison (not x == y -> x != y)
|
||||
- RET503 (4) : Return implicite
|
||||
- Autres (10) : C401, C408, C405, E701, E741, PIE810, SIM103, SIM117, SIM210, UP038
|
||||
|
||||
### Phase 4 : Couverture de tests (16% -> 40%)
|
||||
|
||||
Objectif : Couvrir les modules critiques qui sont a 0%.
|
||||
|
||||
Priorite haute :
|
||||
- utils/persistance.py (0%, 112 lignes) : coeur de la gestion de session
|
||||
- app/fiches/utils/dynamic/minerai/minerai.py (4%, 585 lignes) : module le plus gros
|
||||
- app/fiches/generer.py (12%, 113 lignes) : generation de fiches
|
||||
|
||||
Priorite moyenne :
|
||||
- app/personnalisation/ (0%, ~253 lignes)
|
||||
- app/plan_d_action/ (0%, ~67 lignes interface)
|
||||
- app/visualisations/ (0%, ~133 lignes)
|
||||
|
||||
Priorite basse :
|
||||
- app/ia_nalyse/ (0%, 114 lignes) : depend d'un service externe
|
||||
- utils/translations.py (28%)
|
||||
- utils/visualisation.py (0%)
|
||||
|
||||
Note : les modules Streamlit sont difficiles a tester unitairement.
|
||||
Strategie : tester la logique metier en l'isolant de l'interface Streamlit.
|
||||
|
||||
### Phase 5 : Simplification / Refactoring
|
||||
|
||||
Objectif : Decouper les gros fichiers pour ameliorer la maintenabilite.
|
||||
|
||||
Candidats :
|
||||
- minerai.py (~585 lignes) : decouper par type de section (header, body, indices, etc.)
|
||||
- modification.py (~341 lignes) : separer logique de donnees et interface
|
||||
- fabnum.py (217 lignes) : extraire `get_total_bytes_for_session` et `charger_theme`
|
||||
dans un module `utils/session.py` ou similaire
|
||||
|
||||
### Phase bonus : Documentation projet
|
||||
|
||||
- Creer un CLAUDE.md avec les conventions du projet
|
||||
(structure, nommage, outils, commandes de dev, decisions architecturales)
|
||||
|
||||
## Ordre d'execution recommande
|
||||
|
||||
1. Phase 1 (critique) - a faire immediatement
|
||||
2. Phase 2 (auto-fix) - dans la foulee
|
||||
3. Phase 3b (nommage - decision sur `G`) - debloquer avant le reste
|
||||
4. Phase 3a (pathlib) - le plus gros chantier, fichier par fichier
|
||||
5. Phase 3d (code inutilise) - rapide
|
||||
6. Phase 3e (imports) - rapide
|
||||
7. Phase 3f (simplifications) - au fil de l'eau
|
||||
8. Phase 3c (documentation) - au fil de l'eau
|
||||
9. Phase 4 (tests) - en parallele des phases 3
|
||||
10. Phase 5 (refactoring) - une fois les tests en place
|
||||
11. Phase bonus (CLAUDE.md) - a tout moment
|
||||
|
||||
## Criteres de succes
|
||||
|
||||
| Metrique | Avant | Objectif | Resultat |
|
||||
| --- | --- | --- | --- |
|
||||
| Erreurs ruff | 907 | < 50 | **0** |
|
||||
| Couverture tests | 16% | >= 40% | **35%** (448 tests) |
|
||||
| Bugs averes | 1 | 0 | **0** |
|
||||
| CLAUDE.md | Absent | Present | **Present** |
|
||||
|
||||
## Resultats de l'audit (2 mars 2026)
|
||||
|
||||
### Phase 1 : COMPLETE
|
||||
|
||||
- 8 erreurs critiques corrigees (F821, E722, B904, W605)
|
||||
|
||||
### Phase 2 : COMPLETE
|
||||
|
||||
- 330 corrections automatiques appliquees avec ruff --fix
|
||||
|
||||
### Phase 3 : COMPLETE
|
||||
|
||||
- 907 -> 0 erreurs ruff
|
||||
- Migration complete vers pathlib (PTH)
|
||||
- Ajout N803 aux ignores (convention NetworkX G)
|
||||
- Corrections manuelles via 5 agents paralleles (RET, SIM, B007, D*, PTH)
|
||||
|
||||
### Phase 4 : COMPLETE (35% au lieu de 40% vise)
|
||||
|
||||
- 67 -> 448 tests (+381 tests, x6.7)
|
||||
- 14 modules a 100% de couverture
|
||||
- Modules non-testes : principalement interface Streamlit (difficile a tester unitairement)
|
||||
- La couverture restante concerne des modules UI fortement couples a Streamlit
|
||||
|
||||
### Phase 5 : ANALYSEE (refactoring deporte)
|
||||
|
||||
- Candidats identifies : minerai.py (577L), modification.py (341L), fabnum.py (212L)
|
||||
- Decision : refactoring deporte car fortement couple a Streamlit, risque eleve pour benefice limite dans le cadre de l'audit
|
||||
- Recommandation : refactorer incrementalement lors des evolutions fonctionnelles
|
||||
|
||||
### Bonus : COMPLETE
|
||||
|
||||
- CLAUDE.md cree avec conventions du projet
|
||||
54
fabnum.py
54
fabnum.py
@ -1,7 +1,9 @@
|
||||
import utils.persistance
|
||||
|
||||
utils.persistance.update_session_paths()
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from utils.persistance import get_champ_statut, get_session_id
|
||||
|
||||
st.set_page_config(
|
||||
@ -12,23 +14,19 @@ st.set_page_config(
|
||||
)
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Configuration Gitea
|
||||
from config import INSTRUCTIONS, ENV
|
||||
|
||||
from utils.gitea import (
|
||||
charger_instructions_depuis_gitea
|
||||
)
|
||||
from config import ENV, INSTRUCTIONS
|
||||
from utils.gitea import charger_instructions_depuis_gitea
|
||||
|
||||
# Import du module de traductions
|
||||
from utils.translations import init_translations, _, set_language
|
||||
|
||||
from utils.translations import _, init_translations, set_language
|
||||
from utils.widgets import html_expander
|
||||
|
||||
|
||||
def afficher_instructions_avec_expanders(markdown_content):
|
||||
"""
|
||||
Affiche le contenu markdown avec les sections de niveau 2 (## Titre) dans des expanders
|
||||
"""
|
||||
"""Affiche le contenu markdown avec les sections de niveau 2 dans des expanders."""
|
||||
# Extraction du titre principal (niveau 1)
|
||||
titre_pattern = r'^# (.+)$'
|
||||
titre_match = re.search(titre_pattern, markdown_content, re.MULTILINE)
|
||||
@ -62,28 +60,20 @@ def afficher_instructions_avec_expanders(markdown_content):
|
||||
contenu_section += "\n\n" + lignes[1].strip()
|
||||
|
||||
# Affichage dans un expander
|
||||
status = True if i == 1 else False
|
||||
status = i == 1
|
||||
# with st.expander(f"## {titre_section}", expanded=status):
|
||||
html_expander(f"{titre_section}", content=contenu_section, open_by_default=status, details_class="details_introduction")
|
||||
|
||||
from utils.graph_utils import (
|
||||
charger_graphe
|
||||
)
|
||||
|
||||
from components.sidebar import (
|
||||
afficher_menu,
|
||||
afficher_impact
|
||||
)
|
||||
|
||||
from components.header import afficher_entete
|
||||
from components.footer import afficher_pied_de_page
|
||||
|
||||
from app.fiches import interface_fiches
|
||||
from app.visualisations import interface_visualisations
|
||||
from app.personnalisation import interface_personnalisation
|
||||
from app.analyse import interface_analyse
|
||||
from app.fiches import interface_fiches
|
||||
from app.ia_nalyse import interface_ia_nalyse
|
||||
from app.personnalisation import interface_personnalisation
|
||||
from app.plan_d_action.interface import interface_plan_d_action
|
||||
from app.visualisations import interface_visualisations
|
||||
from components.footer import afficher_pied_de_page
|
||||
from components.header import afficher_entete
|
||||
from components.sidebar import afficher_impact, afficher_menu
|
||||
from utils.graph_utils import charger_graphe
|
||||
|
||||
# Initialisation des traductions (langue française par défaut)
|
||||
init_translations()
|
||||
@ -100,9 +90,10 @@ set_language("fr")
|
||||
#
|
||||
session_id = get_session_id()
|
||||
def get_total_bytes_for_session(session_id):
|
||||
"""Calcule le volume total d'octets transférés pour une session Nginx."""
|
||||
total_bytes = 0
|
||||
try:
|
||||
with open(f"/var/log/nginx/fabnum-{ENV}.access.log", "r") as f:
|
||||
with Path(f"/var/log/nginx/fabnum-{ENV}.access.log").open() as f:
|
||||
for line in f:
|
||||
if session_id in line:
|
||||
match = re.search(r'"GET.*?" \d+ (\d+)', line)
|
||||
@ -114,17 +105,18 @@ def get_total_bytes_for_session(session_id):
|
||||
return total_bytes
|
||||
|
||||
def charger_theme():
|
||||
"""Charge et injecte les fichiers CSS du thème sélectionné."""
|
||||
# Chargement des fichiers CSS (une seule fois)
|
||||
if "base_css_content" not in st.session_state:
|
||||
with open("assets/styles/base.css") as f:
|
||||
with Path("assets/styles/base.css").open() as f:
|
||||
st.session_state["base_css_content"] = f.read()
|
||||
|
||||
if "theme_css_content_light" not in st.session_state:
|
||||
with open("assets/styles/theme-light.css") as f:
|
||||
with Path("assets/styles/theme-light.css").open() as f:
|
||||
st.session_state["theme_css_content_light"] = f.read()
|
||||
|
||||
if "theme_css_content_dark" not in st.session_state:
|
||||
with open("assets/styles/theme-dark.css") as f:
|
||||
with Path("assets/styles/theme-dark.css").open() as f:
|
||||
st.session_state["theme_css_content_dark"] = f.read()
|
||||
|
||||
# Mappage des noms traduits vers les noms internes
|
||||
@ -148,6 +140,7 @@ def charger_theme():
|
||||
st.markdown(f"<style>{st.session_state['base_css_content']}</style>", unsafe_allow_html=True)
|
||||
|
||||
def ouvrir_page():
|
||||
"""Initialise la page avec le thème, l'en-tête et le menu latéral."""
|
||||
charger_theme()
|
||||
afficher_entete()
|
||||
afficher_menu()
|
||||
@ -156,6 +149,7 @@ def ouvrir_page():
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
def fermer_page():
|
||||
"""Ferme les balises HTML de la page et affiche le pied de page."""
|
||||
st.markdown("</div>", unsafe_allow_html=True)
|
||||
st.markdown("""</section>""", unsafe_allow_html=True)
|
||||
st.markdown("</main>", unsafe_allow_html=True)
|
||||
|
||||
@ -52,14 +52,16 @@ ignore = [
|
||||
"D213", # Multi-line docstring summary should start at the second line (conflit avec D212)
|
||||
"E501", # Line too long (géré par line-length)
|
||||
"N802", # Function name should be lowercase (streamlit utilise des noms de fonctions variés)
|
||||
"N803", # Argument name should be lowercase (pour compatibilité avec NetworkX : G)
|
||||
"N806", # Variable in function should be lowercase (pour compatibilité avec NetworkX)
|
||||
]
|
||||
|
||||
# Fichiers à ignorer pour certaines règles
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401"] # Imports non utilisés dans __init__ sont OK
|
||||
"tests/**/*.py" = ["D103", "ARG001"] # Pas de docstrings obligatoires dans les tests
|
||||
"tests/**/*.py" = ["D103", "ARG001", "ARG002", "SIM300"] # Tests: pas de docstrings, mocks @patch inutilisés OK, Yoda conditions OK
|
||||
"scripts/**/*.py" = ["D"] # Pas de docstrings obligatoires dans les scripts
|
||||
"fabnum.py" = ["E402"] # Streamlit impose st.set_page_config() avant les imports d'app modules
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
# Convention de docstrings (Google style)
|
||||
|
||||
@ -1,21 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Script pour l'injection automatique de fichiers dans Private GPT.
|
||||
Ce script scanne un répertoire source et injecte les nouveaux fichiers via l'API de Private GPT.
|
||||
"""
|
||||
|
||||
import os
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import argparse
|
||||
import logging
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from typing import List, Set, Dict, Any
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
# Configuration du logging
|
||||
logging.basicConfig(
|
||||
@ -50,11 +49,11 @@ class PrivateGPTIngestor:
|
||||
self.processed_file = processed_file
|
||||
self.processed_files = self._load_processed_files()
|
||||
|
||||
def _load_processed_files(self) -> Set[str]:
|
||||
def _load_processed_files(self) -> set[str]:
|
||||
"""Charge la liste des fichiers déjà traités."""
|
||||
try:
|
||||
if os.path.exists(self.processed_file):
|
||||
with open(self.processed_file, 'r', encoding='utf-8') as f:
|
||||
if Path(self.processed_file).exists():
|
||||
with Path(self.processed_file).open(encoding='utf-8') as f:
|
||||
return set(json.load(f))
|
||||
return set()
|
||||
except Exception as e:
|
||||
@ -64,13 +63,13 @@ class PrivateGPTIngestor:
|
||||
def _save_processed_files(self) -> None:
|
||||
"""Sauvegarde la liste des fichiers déjà traités."""
|
||||
try:
|
||||
with open(self.processed_file, 'w', encoding='utf-8') as f:
|
||||
with Path(self.processed_file).open('w', encoding='utf-8') as f:
|
||||
json.dump(list(self.processed_files), f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la sauvegarde des fichiers traités: {e}")
|
||||
|
||||
def scan_directory(self, directory: str, extensions: Set[str] = None,
|
||||
recursive: bool = True) -> List[str]:
|
||||
def scan_directory(self, directory: str, extensions: set[str] = None,
|
||||
recursive: bool = True) -> list[str]:
|
||||
"""
|
||||
Scanne un répertoire pour trouver des fichiers à injecter.
|
||||
|
||||
@ -121,8 +120,8 @@ class PrivateGPTIngestor:
|
||||
logger.info(f"Injection du fichier: {file_path}")
|
||||
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (os.path.basename(file_path), f)}
|
||||
with Path(file_path).open('rb') as f:
|
||||
files = {'file': (Path(file_path).name, f)}
|
||||
response = requests.post(f"{self.api_url}/v1/ingest/file", files=files, timeout=30)
|
||||
|
||||
if response.status_code == 200:
|
||||
@ -130,14 +129,13 @@ class PrivateGPTIngestor:
|
||||
self.processed_files.add(file_path)
|
||||
self._save_processed_files()
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Échec de l'injection pour {file_path}: {response.status_code} - {response.text}")
|
||||
return False
|
||||
logger.error(f"Échec de l'injection pour {file_path}: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'injection de {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def list_documents(self) -> List[Dict[str, Any]]:
|
||||
def list_documents(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Liste les documents déjà injectés dans Private GPT.
|
||||
|
||||
@ -148,14 +146,13 @@ class PrivateGPTIngestor:
|
||||
response = requests.get(f"{self.api_url}/v1/ingest/list", timeout=10)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
logger.error(f"Échec de la récupération des documents: {response.status_code} - {response.text}")
|
||||
return []
|
||||
logger.error(f"Échec de la récupération des documents: {response.status_code} - {response.text}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la récupération des documents: {e}")
|
||||
return []
|
||||
|
||||
def run_ingestion(self, directory: str, extensions: Set[str] = None,
|
||||
def run_ingestion(self, directory: str, extensions: set[str] = None,
|
||||
recursive: bool = True, batch_size: int = 5,
|
||||
delay: float = 2.0) -> None:
|
||||
"""
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import requests
|
||||
import time
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# Configuration de l'API PrivateGPT
|
||||
PGPT_URL = "http://127.0.0.1:8001"
|
||||
@ -108,9 +108,8 @@ def check_api_availability() -> bool:
|
||||
if response.status_code == 200:
|
||||
print("✅ API PrivateGPT disponible")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ L'API PrivateGPT a retourné le code d'état {response.status_code}")
|
||||
return False
|
||||
print(f"❌ L'API PrivateGPT a retourné le code d'état {response.status_code}")
|
||||
return False
|
||||
except requests.RequestException as e:
|
||||
print(f"❌ Erreur de connexion à l'API PrivateGPT: {e}")
|
||||
return False
|
||||
@ -118,13 +117,10 @@ def check_api_availability() -> bool:
|
||||
def ingest_document(file_path: Path, session_id: str = "") -> bool:
|
||||
"""Ingère un document dans PrivateGPT"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
with file_path.open("rb") as f:
|
||||
# Si un session_id est fourni, l'ajouter au nom du fichier pour le tracking
|
||||
if session_id:
|
||||
file_name = f"input_{session_id}_{file_path.name}"
|
||||
else:
|
||||
file_name = file_path.name
|
||||
|
||||
file_name = f"input_{session_id}_{file_path.name}" if session_id else file_path.name
|
||||
|
||||
files = {"file": (file_name, f, "text/markdown")}
|
||||
# Ajouter des métadonnées pour identifier facilement ce fichier d'entrée
|
||||
metadata = {
|
||||
@ -154,11 +150,11 @@ def setup_temp_directory() -> None:
|
||||
TEMP_DIR.mkdir(parents=True)
|
||||
print(f"📁 Répertoire temporaire '{TEMP_DIR}' créé")
|
||||
|
||||
def save_section_to_file(section: Dict[str, str], index: int, session_uuid: str) -> Path:
|
||||
def save_section_to_file(section: dict[str, str], index: int, session_uuid: str) -> Path:
|
||||
"""Sauvegarde une section dans un fichier temporaire et retourne le chemin"""
|
||||
setup_temp_directory()
|
||||
section_file = TEMP_DIR / f"temp_section_{session_uuid}_{index+1}_{section['title'].lower().replace(' ', '_')}.md"
|
||||
|
||||
|
||||
# Contenu du fichier avec métadonnées et commentaire explicite
|
||||
content = (
|
||||
f"# SECTION TEMPORAIRE GÉNÉRÉE - {section['title']}\n\n"
|
||||
@ -166,17 +162,17 @@ def save_section_to_file(section: Dict[str, str], index: int, session_uuid: str)
|
||||
f"UUID de session: {session_uuid}\n\n"
|
||||
f"{section['output']}"
|
||||
)
|
||||
|
||||
|
||||
# Écrire dans le fichier
|
||||
section_file.write_text(content, encoding="utf-8")
|
||||
return section_file
|
||||
|
||||
def ingest_section_files(section_files: List[Path]) -> List[str]:
|
||||
def ingest_section_files(section_files: list[Path]) -> list[str]:
|
||||
"""Ingère les fichiers de section et retourne leurs noms de fichiers"""
|
||||
ingested_file_names = []
|
||||
for file_path in section_files:
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
with file_path.open("rb") as f:
|
||||
files = {"file": (file_path.name, f, "text/markdown")}
|
||||
# Ajouter des métadonnées pour identifier facilement nos fichiers temporaires
|
||||
metadata = {
|
||||
@ -197,7 +193,7 @@ def ingest_section_files(section_files: List[Path]) -> List[str]:
|
||||
print(f"⚠️ Erreur lors de l'ingestion de '{file_path.name}': {e}")
|
||||
return ingested_file_names
|
||||
|
||||
def get_context(sections: List[Dict[str, str]], strategy: ContextStrategy, max_length: int) -> str:
|
||||
def get_context(sections: list[dict[str, str]], strategy: ContextStrategy, max_length: int) -> str:
|
||||
"""Génère le contexte selon la stratégie choisie"""
|
||||
if not sections or strategy == ContextStrategy.NONE:
|
||||
return ""
|
||||
@ -210,8 +206,8 @@ def get_context(sections: List[Dict[str, str]], strategy: ContextStrategy, max_l
|
||||
context_note = f"NOTE IMPORTANTE: Les sections précédentes ({', '.join(section_names)}) " + \
|
||||
f"ont été ingérées sous forme de fichiers temporaires avec l'identifiant unique '{session_uuid}'. " + \
|
||||
f"Utilisez UNIQUEMENT le document '{input_file.name}' et ces sections temporaires pour votre analyse. " + \
|
||||
f"IGNOREZ tous les autres documents qui pourraient être présents dans la base de connaissances. " + \
|
||||
f"Assurez la cohérence avec les sections déjà générées pour maintenir la continuité du rapport."
|
||||
"IGNOREZ tous les autres documents qui pourraient être présents dans la base de connaissances. " + \
|
||||
"Assurez la cohérence avec les sections déjà générées pour maintenir la continuité du rapport."
|
||||
print(f"📄 Utilisation de {len(sections)} sections ingérées comme contexte")
|
||||
return context_note
|
||||
|
||||
@ -272,7 +268,9 @@ def get_context(sections: List[Dict[str, str]], strategy: ContextStrategy, max_l
|
||||
# En cas d'échec, revenir à la stratégie de troncature
|
||||
return get_context(sections, ContextStrategy.TRUNCATE, max_length)
|
||||
|
||||
def generate_text(prompt: str, previous_context: str = "", use_context: bool = True, retry_on_error: bool = True) -> Optional[str]:
|
||||
return ""
|
||||
|
||||
def generate_text(prompt: str, previous_context: str = "", use_context: bool = True, _retry_on_error: bool = True) -> str | None:
|
||||
"""Génère du texte avec l'API PrivateGPT"""
|
||||
try:
|
||||
# Préparer le prompt avec le contexte précédent si disponible et demandé
|
||||
@ -328,9 +326,8 @@ def generate_text(prompt: str, previous_context: str = "", use_context: bool = T
|
||||
result = response.json()
|
||||
if "choices" in result and len(result["choices"]) > 0:
|
||||
return result["choices"][0]["message"]["content"]
|
||||
else:
|
||||
print("⚠️ Format de réponse inattendu:", json.dumps(result, indent=2))
|
||||
return None
|
||||
print("⚠️ Format de réponse inattendu:", json.dumps(result, indent=2))
|
||||
return None
|
||||
|
||||
except requests.RequestException as e:
|
||||
print(f"❌ Erreur lors de la génération de texte: {e}")
|
||||
@ -338,7 +335,7 @@ def generate_text(prompt: str, previous_context: str = "", use_context: bool = T
|
||||
print(f"Détails: {e.response.text}")
|
||||
return None
|
||||
|
||||
def cleanup_temp_files(temp_file_names: List[str] = None, remove_directory: bool = False, session_id: str = "") -> None:
|
||||
def cleanup_temp_files(temp_file_names: list[str] = None, remove_directory: bool = False, session_id: str = "") -> None:
|
||||
"""Nettoie les fichiers temporaires et les documents ingérés"""
|
||||
try:
|
||||
# Supprimer les fichiers du répertoire temporaire
|
||||
@ -346,54 +343,50 @@ def cleanup_temp_files(temp_file_names: List[str] = None, remove_directory: bool
|
||||
for temp_file in TEMP_DIR.glob("*.md"):
|
||||
temp_file.unlink()
|
||||
print(f"🗑️ Fichier temporaire supprimé : {temp_file.name}")
|
||||
|
||||
|
||||
# Supprimer le répertoire s'il est vide et si demandé
|
||||
if remove_directory and not any(TEMP_DIR.iterdir()):
|
||||
TEMP_DIR.rmdir()
|
||||
print(f"🗑️ Répertoire temporaire '{TEMP_DIR}' supprimé")
|
||||
|
||||
|
||||
# Supprimer les documents ingérés via l'API de liste et suppression
|
||||
try:
|
||||
# Lister tous les documents ingérés
|
||||
list_response = requests.get(f"{API_URL}/ingest/list", timeout=10)
|
||||
if list_response.status_code == 200:
|
||||
documents_data = list_response.json()
|
||||
|
||||
|
||||
# Format de réponse OpenAI
|
||||
if "data" in documents_data:
|
||||
documents = documents_data.get("data", [])
|
||||
# Format alternatif
|
||||
else:
|
||||
documents = documents_data.get("documents", [])
|
||||
|
||||
|
||||
deleted_count = 0
|
||||
# Parcourir les documents et supprimer ceux qui correspondent à nos fichiers temporaires
|
||||
for doc in documents:
|
||||
doc_metadata = doc.get("doc_metadata", {})
|
||||
file_name = doc_metadata.get("file_name", "") or doc_metadata.get("filename", "")
|
||||
|
||||
|
||||
# Vérifier si c'est un de nos fichiers temporaires ou le fichier d'entrée
|
||||
is_our_file = False
|
||||
if temp_file_names and file_name in temp_file_names:
|
||||
if temp_file_names and file_name in temp_file_names or f"temp_section_{session_uuid}_" in file_name or session_id and f"input_{session_id}_" in file_name:
|
||||
is_our_file = True
|
||||
elif f"temp_section_{session_uuid}_" in file_name:
|
||||
is_our_file = True
|
||||
elif session_id and f"input_{session_id}_" in file_name:
|
||||
is_our_file = True
|
||||
|
||||
|
||||
if is_our_file:
|
||||
doc_id = doc.get("doc_id") or doc.get("id")
|
||||
if doc_id:
|
||||
delete_response = requests.delete(f"{API_URL}/ingest/{doc_id}", timeout=10)
|
||||
if delete_response.status_code == 200:
|
||||
deleted_count += 1
|
||||
|
||||
|
||||
if deleted_count > 0:
|
||||
print(f"🗑️ {deleted_count} documents supprimés de PrivateGPT")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Erreur lors de la suppression des documents ingérés: {e}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Erreur lors du nettoyage des fichiers temporaires: {e}")
|
||||
|
||||
@ -405,42 +398,40 @@ def clean_ai_thoughts(text: str) -> str:
|
||||
text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
|
||||
|
||||
# Supprimer les lignes vides multiples
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
|
||||
return text
|
||||
return re.sub(r'\n{3,}', '\n\n', text)
|
||||
|
||||
def main(input_path: str, output_path: str, context_strategy: ContextStrategy = ContextStrategy.FILE, context_length: int = MAX_CONTEXT_LENGTH):
|
||||
"""Fonction principale qui exécute le processus complet"""
|
||||
global input_file, section_files, session_uuid # Variables globales pour le filtre de contexte et l'UUID
|
||||
|
||||
|
||||
# Générer un UUID unique pour cette session
|
||||
session_uuid = str(uuid.uuid4())[:8] # Utiliser les 8 premiers caractères pour plus de concision
|
||||
print(f"🔑 UUID de session généré: {session_uuid}")
|
||||
|
||||
|
||||
# Vérifier la disponibilité de l'API
|
||||
if not check_api_availability():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Convertir les chemins en objets Path (accessibles globalement)
|
||||
input_file = Path(input_path)
|
||||
input_file = Path(input_path)
|
||||
output_file = Path(output_path)
|
||||
|
||||
|
||||
# Ingérer le document principal avec l'UUID de session
|
||||
if not ingest_document(input_file, session_uuid):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Récupérer la valeur du délai depuis args
|
||||
delay = args.delay if 'args' in globals() else 5
|
||||
|
||||
|
||||
# Attendre que l'ingestion soit complètement traitée
|
||||
print(f"⏳ Attente du traitement de l'ingestion pendant {delay} secondes...")
|
||||
time.sleep(delay)
|
||||
|
||||
|
||||
print(f"🔧 Stratégie de contexte initiale: {context_strategy.value}, taille max: {context_length} caractères")
|
||||
|
||||
|
||||
# Préparer le répertoire pour les fichiers temporaires
|
||||
setup_temp_directory()
|
||||
|
||||
|
||||
# Générer chaque section du rapport
|
||||
step_outputs = []
|
||||
section_files = [] # Chemins des fichiers temporaires
|
||||
@ -465,13 +456,13 @@ def main(input_path: str, output_path: str, context_strategy: ContextStrategy =
|
||||
if j >= len(section_files): # Cette section n'a pas encore été sauvegardée
|
||||
section_file = save_section_to_file(section, j, session_uuid)
|
||||
section_files.append(section_file)
|
||||
|
||||
|
||||
# Ingérer le fichier si nous utilisons la stratégie FILE
|
||||
new_ids = ingest_section_files([section_file])
|
||||
ingested_section_ids.extend(new_ids)
|
||||
ingested_file_names.extend(new_ids)
|
||||
|
||||
# Attendre que l'ingestion soit traitée
|
||||
print(f"⏳ Attente du traitement de l'ingestion des sections précédentes...")
|
||||
print("⏳ Attente du traitement de l'ingestion des sections précédentes...")
|
||||
time.sleep(2)
|
||||
|
||||
# Essayer chaque stratégie jusqu'à ce qu'une réussisse
|
||||
@ -506,11 +497,11 @@ def main(input_path: str, output_path: str, context_strategy: ContextStrategy =
|
||||
if context_strategy == ContextStrategy.FILE:
|
||||
section_file = save_section_to_file(step_outputs[-1], len(step_outputs)-1, session_uuid)
|
||||
section_files.append(section_file)
|
||||
|
||||
|
||||
# Ingérer le fichier
|
||||
new_file_names = ingest_section_files([section_file])
|
||||
ingested_file_names.extend(new_file_names)
|
||||
|
||||
|
||||
# Petite pause pour permettre l'indexation
|
||||
time.sleep(1)
|
||||
else:
|
||||
@ -540,7 +531,7 @@ def main(input_path: str, output_path: str, context_strategy: ContextStrategy =
|
||||
try:
|
||||
output_file.write_text(report_text, encoding="utf-8")
|
||||
print(f"\n📄 Rapport final généré dans '{output_file}'")
|
||||
except IOError as e:
|
||||
except OSError as e:
|
||||
print(f"❌ Erreur lors de l'écriture du fichier de sortie: {e}")
|
||||
|
||||
# Nettoyer les fichiers temporaires si demandé
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
|
||||
"""
|
||||
|
||||
import sys
|
||||
import networkx as nx
|
||||
from networkx.drawing.nx_agraph import read_dot
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from networkx.drawing.nx_agraph import read_dot
|
||||
|
||||
logging.basicConfig(
|
||||
filename="beautify_debug.log",
|
||||
@ -100,11 +101,9 @@ def formater_noeuds_par_niveau(schema, niveau, indentation=4):
|
||||
relations.append((s, d, attrs))
|
||||
|
||||
# Suppression des doublons en convertissant le dictionnaire en tuple trié
|
||||
relations_sans_doublons = set((a, b, tuple(sorted(c.items()))) for a, b, c in relations)
|
||||
relations_sans_doublons = {(a, b, tuple(sorted(c.items()))) for a, b, c in relations}
|
||||
# Reconversion en dictionnaire
|
||||
relations_finales = [(a, b, dict(c)) for a, b, c in relations_sans_doublons]
|
||||
|
||||
return relations_finales
|
||||
return [(a, b, dict(c)) for a, b, c in relations_sans_doublons]
|
||||
|
||||
# Définir les niveaux d'indentation
|
||||
indent = " " * (indentation + 4)
|
||||
@ -292,7 +291,7 @@ def generer_rank_same(schema, indentation=4):
|
||||
sortie = "\n" + indent + "// Alignement des nœuds par niveau\n"
|
||||
|
||||
# Trier les niveaux numériquement
|
||||
for niveau, noeuds in sorted(noeuds_par_niveau.items(), key=lambda x: int(x[0]) if x[0].isdigit() else 99):
|
||||
for _niveau, noeuds in sorted(noeuds_par_niveau.items(), key=lambda x: int(x[0]) if x[0].isdigit() else 99):
|
||||
if noeuds: # S'il y a des nœuds pour ce niveau
|
||||
sortie += indent + "{ rank=same; "
|
||||
sortie += "; ".join(noeuds)
|
||||
@ -407,13 +406,13 @@ def main(fichier_entree, fichier_sortie):
|
||||
"""
|
||||
|
||||
try:
|
||||
with open(f"{fichier_sortie}", "w", encoding="utf-8") as f:
|
||||
with Path(fichier_sortie).open("w", encoding="utf-8") as f:
|
||||
print(f"{sortie}", file=f)
|
||||
except FileNotFoundError:
|
||||
print(f"Erreur : Le chemin vers '{fichier_sortie}' n'existe pas")
|
||||
except PermissionError:
|
||||
print(f"Erreur : Permissions insuffisantes pour écrire dans '{fichier_sortie}'")
|
||||
except IOError as e:
|
||||
except OSError as e:
|
||||
print(f"Erreur d'E/S lors de l'écriture dans le fichier : {e}")
|
||||
except Exception as e:
|
||||
print(f"Erreur inattendue : {e}")
|
||||
|
||||
@ -17,10 +17,12 @@
|
||||
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
import networkx as nx
|
||||
from networkx.drawing.nx_pydot import write_dot
|
||||
import sys
|
||||
import re
|
||||
|
||||
|
||||
def calcul_ihh(graphe, depart, arrivee):
|
||||
ihh = 0
|
||||
@ -42,8 +44,7 @@ def calcul_ihh(graphe, depart, arrivee):
|
||||
print(ihh_inter)
|
||||
ihh += ihh_inter
|
||||
print(ihh)
|
||||
ihh = int(round(ihh/100))
|
||||
return ihh
|
||||
return int(round(ihh/100))
|
||||
|
||||
def mettre_a_jour_ihh(graph, noeuds):
|
||||
for noeud in noeuds:
|
||||
|
||||
@ -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
|
||||
|
||||
130
tests/integration/conftest.py
Normal file
130
tests/integration/conftest.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""Fixtures pour les tests d'intégration Playwright + Streamlit."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("playwright")
|
||||
|
||||
TEST_PORT = 8502
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
SESSION_DIR = PROJECT_ROOT / "tmp" / "sessions" / "anonymous"
|
||||
STATUT_FILE = SESSION_DIR / "statut_general.json"
|
||||
|
||||
# État vierge pour garantir des tests reproductibles
|
||||
ETAT_VIERGE = {
|
||||
"navigation_onglet": "Instructions",
|
||||
"theme_mode": "Clair",
|
||||
"pages": {},
|
||||
}
|
||||
|
||||
|
||||
class StreamlitApp:
|
||||
"""Encapsule les interactions avec les widgets Streamlit via Playwright."""
|
||||
|
||||
RERUN_WAIT = 2000 # ms à attendre après chaque interaction widget
|
||||
|
||||
def __init__(self, page):
|
||||
"""Initialise avec une page Playwright."""
|
||||
self.page = page
|
||||
|
||||
def naviguer_vers(self, onglet):
|
||||
"""Clique sur un onglet du menu latéral."""
|
||||
self.page.get_by_role("button", name=onglet).click()
|
||||
self.page.wait_for_timeout(self.RERUN_WAIT)
|
||||
|
||||
def choisir_selectbox(self, label, valeur):
|
||||
"""Sélectionne une valeur dans un selectbox Streamlit."""
|
||||
combobox = self.page.get_by_role("combobox", name=re.compile(label))
|
||||
combobox.click()
|
||||
self.page.get_by_role("option", name=valeur).click()
|
||||
self.page.wait_for_timeout(self.RERUN_WAIT)
|
||||
|
||||
def ajouter_multiselect(self, label, valeur):
|
||||
"""Ajoute une valeur à un multiselect Streamlit en filtrant par texte."""
|
||||
combobox = self.page.get_by_role("combobox", name=re.compile(label))
|
||||
combobox.click()
|
||||
self.page.wait_for_timeout(500)
|
||||
self.page.keyboard.type(valeur, delay=50)
|
||||
self.page.wait_for_timeout(1000)
|
||||
self.page.get_by_role("option", name=valeur).click()
|
||||
# Fermer le dropdown pour éviter qu'il n'intercepte les clics suivants
|
||||
self.page.keyboard.press("Escape")
|
||||
self.page.wait_for_timeout(self.RERUN_WAIT)
|
||||
|
||||
def cocher(self, label):
|
||||
"""Coche une checkbox Streamlit via le label texte (l'input natif est caché)."""
|
||||
texte = self.page.get_by_text(re.compile(label)).first
|
||||
texte.scroll_into_view_if_needed()
|
||||
texte.click()
|
||||
self.page.wait_for_timeout(self.RERUN_WAIT)
|
||||
|
||||
def choisir_radio(self, valeur):
|
||||
"""Sélectionne une option radio Streamlit via le label texte (l'input natif est caché)."""
|
||||
# Cibler le <p> dans le conteneur du radiogroup
|
||||
radio_label = self.page.locator(f'[role="radiogroup"] p:text-is("{valeur}")')
|
||||
radio_label.scroll_into_view_if_needed()
|
||||
radio_label.click()
|
||||
self.page.wait_for_timeout(self.RERUN_WAIT)
|
||||
|
||||
def cliquer_bouton(self, label):
|
||||
"""Clique sur un bouton Streamlit."""
|
||||
self.page.get_by_role("button", name=re.compile(label)).click()
|
||||
self.page.wait_for_timeout(self.RERUN_WAIT)
|
||||
|
||||
|
||||
def _reinitialiser_session():
|
||||
"""Remet le fichier de session à un état vierge."""
|
||||
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATUT_FILE.write_text(json.dumps(ETAT_VIERGE, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def streamlit_server():
|
||||
"""Démarre un serveur Streamlit dédié aux tests d'intégration."""
|
||||
_reinitialiser_session()
|
||||
url = f"http://localhost:{TEST_PORT}"
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable, "-m", "streamlit", "run", "fabnum.py",
|
||||
"--server.port", str(TEST_PORT),
|
||||
"--server.headless", "true",
|
||||
"--browser.gatherUsageStats", "false",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
for _ in range(30):
|
||||
try:
|
||||
response = urlopen(url)
|
||||
if response.status == 200:
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(1)
|
||||
else:
|
||||
process.terminate()
|
||||
pytest.fail("Le serveur Streamlit n'a pas démarré en 30 secondes")
|
||||
|
||||
yield url
|
||||
|
||||
process.terminate()
|
||||
process.wait(timeout=10)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(page, streamlit_server):
|
||||
"""Application Streamlit prête à interagir (état réinitialisé)."""
|
||||
_reinitialiser_session()
|
||||
page.set_viewport_size({"width": 1280, "height": 900})
|
||||
page.goto(streamlit_server)
|
||||
page.wait_for_timeout(3000)
|
||||
return StreamlitApp(page)
|
||||
48
tests/integration/test_analyse.py
Normal file
48
tests/integration/test_analyse.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Test E2E – Scénario 2 : Analyse complète Produit final → Pays géographique."""
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def test_analyse_serveur_germanium_chine(app):
|
||||
"""Formulaire complet : Serveur → Pays géo, Germanium, Chine, filtre IHH Pays."""
|
||||
page = app.page
|
||||
|
||||
# Naviguer vers l'onglet Analyse (le graphe se charge automatiquement)
|
||||
app.naviguer_vers("Analyse")
|
||||
expect(page.get_by_role("heading", name="Analyse du graphe")).to_be_visible(timeout=15000)
|
||||
|
||||
# Sélectionner le niveau de départ : Produit final
|
||||
app.choisir_selectbox("Niveau de départ", "Produit final")
|
||||
|
||||
# Sélectionner le niveau d'arrivée : Pays géographique
|
||||
app.choisir_selectbox("Niveau d'arrivée", "Pays géographique")
|
||||
|
||||
# Ajouter le minerai Germanium
|
||||
expect(page.get_by_text("Filtrer par minerais")).to_be_visible(timeout=10000)
|
||||
app.ajouter_multiselect("minerais", "Germanium")
|
||||
|
||||
# Ajouter le nœud de départ : Serveur
|
||||
app.ajouter_multiselect("noeuds de départ", "Serveur")
|
||||
|
||||
# Ajouter le nœud d'arrivée : Chine
|
||||
app.ajouter_multiselect("noeuds d'arrivée", "Chine")
|
||||
|
||||
# Cocher le filtre IHH
|
||||
app.cocher("IHH")
|
||||
|
||||
# Sélectionner Pays pour le filtre IHH
|
||||
app.choisir_radio("Pays")
|
||||
|
||||
# Lancer l'analyse
|
||||
app.cliquer_bouton("Lancer l'analyse")
|
||||
|
||||
# Vérifier que le diagramme Sankey s'affiche
|
||||
expect(page.get_by_text("Hiérarchie filtrée")).to_be_visible(timeout=15000)
|
||||
|
||||
# Vérifier la présence de nœuds clés dans le graphe Sankey (Plotly)
|
||||
sankey = page.get_by_test_id("stPlotlyChart")
|
||||
expect(sankey.get_by_text("Serveur").first).to_be_visible()
|
||||
expect(sankey.get_by_text("Germanium").first).to_be_visible()
|
||||
37
tests/integration/test_fiches.py
Normal file
37
tests/integration/test_fiches.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""Test E2E – Scénario 1 : Navigation et affichage des fiches."""
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def test_selectionner_dossier_et_fiche(app):
|
||||
"""Sélectionne Assemblage puis la première fiche et vérifie le contenu."""
|
||||
page = app.page
|
||||
|
||||
# Naviguer vers l'onglet Fiches
|
||||
app.naviguer_vers("Fiches")
|
||||
expect(page.get_by_role("heading", name="Découverte des fiches")).to_be_visible(timeout=10000)
|
||||
|
||||
# Sélectionner le dossier Assemblage
|
||||
app.choisir_selectbox("catégorie de fiches", "Assemblage")
|
||||
expect(page.get_by_text("Choisissez une fiche")).to_be_visible(timeout=10000)
|
||||
|
||||
# Sélectionner la première fiche (pattern unique pour éviter l'ambiguïté avec "catégorie de fiches")
|
||||
app.choisir_selectbox("Choisissez une fiche", "Fiche assemblage casques VR.md")
|
||||
|
||||
# Vérifier le titre de la fiche
|
||||
expect(page.get_by_role("heading", name="Fiche assemblage Casque VR")).to_be_visible(timeout=10000)
|
||||
|
||||
# Vérifier les sections principales (les titres apparaissent dans <summary> et <h2>,
|
||||
# on utilise .first pour éviter la strict mode violation)
|
||||
expect(page.get_by_text("Présentation synthétique").first).to_be_visible()
|
||||
expect(page.get_by_text("Composants assemblés").first).to_be_visible()
|
||||
expect(page.get_by_text("Principaux assembleurs").first).to_be_visible()
|
||||
|
||||
# Vérifier le tableau de version
|
||||
expect(page.get_by_text("Version initiale")).to_be_visible()
|
||||
|
||||
# Vérifier la section tickets
|
||||
expect(page.get_by_text("Gestion des tickets")).to_be_visible()
|
||||
35
tests/integration/test_plan_action.py
Normal file
35
tests/integration/test_plan_action.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""Test E2E – Scénario 3 : Plan d'action Serveur + Germanium."""
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def test_plan_action_serveur_germanium(app):
|
||||
"""Soumet Serveur + Germanium et vérifie le dashboard de résultats."""
|
||||
page = app.page
|
||||
|
||||
# Naviguer vers l'onglet Plan d'action
|
||||
app.naviguer_vers("Plan d'action")
|
||||
expect(page.get_by_role("heading", name="Analyse du graphe pour action")).to_be_visible(timeout=15000)
|
||||
|
||||
# Sélectionner le produit final : Serveur
|
||||
app.ajouter_multiselect("noeuds de départ", "Serveur")
|
||||
|
||||
# Ajouter le minerai : Germanium
|
||||
app.ajouter_multiselect("minerais", "Germanium")
|
||||
|
||||
# Soumettre la demande
|
||||
app.cliquer_bouton("Soumettre")
|
||||
|
||||
# Vérifier le dashboard (stage 1)
|
||||
expect(page.get_by_text("Panneau de sélection")).to_be_visible(timeout=30000)
|
||||
expect(page.get_by_text("Top chaînes critiques")).to_be_visible()
|
||||
|
||||
# Vérifier la chaîne critique principale
|
||||
expect(page.get_by_text("Serveur ↔ Carte mère ↔ Germanium").first).to_be_visible()
|
||||
|
||||
# Vérifier les criticités
|
||||
expect(page.get_by_text("Synthèse des criticités")).to_be_visible()
|
||||
expect(page.get_by_text("Criticité globale").first).to_be_visible()
|
||||
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")
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import streamlit as st
|
||||
@ -19,9 +19,8 @@ def lire_fichier_local(nom_fichier):
|
||||
Returns:
|
||||
str: Contenu du fichier.
|
||||
"""
|
||||
with open(nom_fichier, encoding="utf-8") as f:
|
||||
contenu_md = f.read()
|
||||
return contenu_md
|
||||
with Path(nom_fichier).open(encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
def charger_instructions_depuis_gitea(nom_fichier="Instructions.md"):
|
||||
"""Charge le fichier Instructions.md depuis Gitea avec cache local timestamp.
|
||||
@ -39,8 +38,9 @@ def charger_instructions_depuis_gitea(nom_fichier="Instructions.md"):
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/contents/{nom_fichier}?ref={ENV}"
|
||||
try:
|
||||
# Vérifier si une version plus récente existe sur le dépôt
|
||||
fichier = Path(nom_fichier)
|
||||
remote_last_modified = recuperer_date_dernier_commit(f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/commits?path={nom_fichier}&sha={ENV}")
|
||||
local_last_modified = datetime.fromtimestamp(os.path.getmtime(nom_fichier), tz=timezone.utc) if os.path.exists(nom_fichier) else None
|
||||
local_last_modified = datetime.fromtimestamp(fichier.stat().st_mtime, tz=timezone.utc) if fichier.exists() else None
|
||||
|
||||
# Si le fichier local n'existe pas ou si la version distante est plus récente
|
||||
if not local_last_modified or (remote_last_modified and remote_last_modified > local_last_modified):
|
||||
@ -49,7 +49,7 @@ def charger_instructions_depuis_gitea(nom_fichier="Instructions.md"):
|
||||
data = response.json()
|
||||
contenu_md = base64.b64decode(data["content"]).decode("utf-8")
|
||||
# Sauvegarder en local
|
||||
with open(nom_fichier, "w", encoding="utf-8") as f:
|
||||
with fichier.open("w", encoding="utf-8") as f:
|
||||
f.write(contenu_md)
|
||||
return contenu_md
|
||||
# Lire depuis le cache local
|
||||
@ -57,7 +57,7 @@ def charger_instructions_depuis_gitea(nom_fichier="Instructions.md"):
|
||||
except Exception as e:
|
||||
st.error(f"Erreur chargement instructions Gitea : {e}")
|
||||
# Essayer de charger depuis le cache local en cas d'erreur
|
||||
if os.path.exists(nom_fichier):
|
||||
if Path(nom_fichier).exists():
|
||||
return lire_fichier_local(nom_fichier)
|
||||
return None
|
||||
|
||||
@ -103,12 +103,13 @@ def charger_schema_depuis_gitea(fichier_local="schema_temp.txt"):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
fichier = Path(fichier_local)
|
||||
remote_last_modified = recuperer_date_dernier_commit(f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_CODE}/commits?path={DOT_FILE}&sha={ENV_CODE}")
|
||||
local_last_modified = datetime.fromtimestamp(os.path.getmtime(fichier_local), tz=timezone.utc) if os.path.exists(fichier_local) else None
|
||||
local_last_modified = datetime.fromtimestamp(fichier.stat().st_mtime, tz=timezone.utc) if fichier.exists() else None
|
||||
|
||||
if not local_last_modified or (remote_last_modified and remote_last_modified > local_last_modified):
|
||||
dot_text = base64.b64decode(data["content"]).decode("utf-8")
|
||||
with open(fichier_local, "w", encoding="utf-8") as f:
|
||||
with fichier.open("w", encoding="utf-8") as f:
|
||||
f.write(dot_text)
|
||||
|
||||
return "OK"
|
||||
|
||||
@ -7,14 +7,12 @@ import streamlit as st
|
||||
import yaml
|
||||
from networkx.drawing.nx_agraph import read_dot
|
||||
|
||||
from config import DOT_FILE
|
||||
from utils.gitea import charger_schema_depuis_gitea
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Configuration Gitea
|
||||
from config import DOT_FILE
|
||||
from utils.gitea import charger_schema_depuis_gitea
|
||||
|
||||
|
||||
def extraire_chemins_depuis(G, source):
|
||||
"""Extrait tous les chemins depuis un noeud source jusqu'aux feuilles du graphe.
|
||||
@ -208,6 +206,7 @@ def load_seuils_config(path: str = "assets/config.yaml") -> dict:
|
||||
|
||||
def determiner_couleur_par_seuil(valeur: int, seuils_indice: dict) -> str:
|
||||
"""Détermine la couleur en fonction de la valeur et des seuils configurés.
|
||||
|
||||
Logique alignée avec determine_threshold_color du projet.
|
||||
|
||||
Args:
|
||||
@ -221,19 +220,16 @@ def determiner_couleur_par_seuil(valeur: int, seuils_indice: dict) -> str:
|
||||
return "gray"
|
||||
|
||||
# Vérifier d'abord le seuil rouge (priorité la plus haute)
|
||||
if "rouge" in seuils_indice and "min" in seuils_indice["rouge"]:
|
||||
if valeur >= seuils_indice["rouge"]["min"]:
|
||||
return "darkred"
|
||||
if "rouge" in seuils_indice and "min" in seuils_indice["rouge"] and valeur >= seuils_indice["rouge"]["min"]:
|
||||
return "darkred"
|
||||
|
||||
# Ensuite le seuil orange
|
||||
if "orange" in seuils_indice and "min" in seuils_indice["orange"] and "max" in seuils_indice["orange"]:
|
||||
if valeur >= seuils_indice["orange"]["min"] and valeur < seuils_indice["orange"]["max"]:
|
||||
return "orange"
|
||||
if "orange" in seuils_indice and "min" in seuils_indice["orange"] and "max" in seuils_indice["orange"] and valeur >= seuils_indice["orange"]["min"] and valeur < seuils_indice["orange"]["max"]:
|
||||
return "orange"
|
||||
|
||||
# Seuil vert (valeurs inférieures au seuil orange)
|
||||
if "vert" in seuils_indice and "max" in seuils_indice["vert"]:
|
||||
if valeur < seuils_indice["vert"]["max"]:
|
||||
return "darkgreen"
|
||||
if "vert" in seuils_indice and "max" in seuils_indice["vert"] and valeur < seuils_indice["vert"]["max"]:
|
||||
return "darkgreen"
|
||||
|
||||
# Par défaut orange si on ne trouve pas de correspondance exacte
|
||||
return "orange"
|
||||
@ -241,6 +237,7 @@ def determiner_couleur_par_seuil(valeur: int, seuils_indice: dict) -> str:
|
||||
|
||||
def couleur_noeud(n: str, niveaux: dict, G: nx.DiGraph) -> str:
|
||||
"""Détermine la couleur d'un nœud en fonction de son niveau et de ses attributs.
|
||||
|
||||
Utilise les seuils définis dans le fichier de configuration.
|
||||
|
||||
Args:
|
||||
|
||||
@ -16,8 +16,7 @@ def get_session_id() -> str:
|
||||
Returns:
|
||||
str: ID de session ou "anonymous" si non disponible.
|
||||
"""
|
||||
session_id = st.context.headers.get("x-session-id", "anonymous")
|
||||
return session_id
|
||||
return st.context.headers.get("x-session-id", "anonymous")
|
||||
|
||||
def update_session_paths():
|
||||
"""Initialise les chemins de sauvegarde specifiques a la session courante.
|
||||
@ -60,7 +59,7 @@ def _maj_champ(fichier, cle: str, contenu: str = "") -> bool:
|
||||
|
||||
if fichier.exists():
|
||||
try:
|
||||
with open(fichier, encoding="utf-8") as f:
|
||||
with fichier.open(encoding="utf-8") as f:
|
||||
sauvegarde = json.load(f)
|
||||
sauvegarde = inserer_cle_json(sauvegarde, cle, serialize(contenu))
|
||||
except Exception as e:
|
||||
@ -72,7 +71,7 @@ def _maj_champ(fichier, cle: str, contenu: str = "") -> bool:
|
||||
sauvegarde = inserer_cle_json(sauvegarde, cle, serialize(contenu))
|
||||
|
||||
try:
|
||||
with open(fichier, "w", encoding="utf-8") as f:
|
||||
with fichier.open("w", encoding="utf-8") as f:
|
||||
json.dump(sauvegarde, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
@ -103,14 +102,14 @@ def _get_champ(fichier, cle: str) -> str:
|
||||
import json
|
||||
|
||||
def charger_json_sain(fichier: str) -> dict:
|
||||
with open(fichier, encoding="utf-8") as f:
|
||||
with fichier.open(encoding="utf-8") as f:
|
||||
contenu = json.load(f)
|
||||
|
||||
if isinstance(contenu, str):
|
||||
try:
|
||||
contenu = json.loads(contenu) # On essaie de parser une 2e fois si nécessaire
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Le fichier contient une chaîne JSON invalide.")
|
||||
except json.JSONDecodeError as err:
|
||||
raise ValueError("Le fichier contient une chaîne JSON invalide.") from err
|
||||
|
||||
if not isinstance(contenu, dict):
|
||||
raise ValueError("Le contenu JSON n'est pas un objet/dictionnaire valide.")
|
||||
@ -139,7 +138,7 @@ def _supprime_champ(fichier: Path, cle: str) -> bool:
|
||||
|
||||
if fichier.exists():
|
||||
try:
|
||||
with open(fichier, encoding="utf-8") as f:
|
||||
with fichier.open(encoding="utf-8") as f:
|
||||
sauvegarde = json.load(f)
|
||||
except Exception as e:
|
||||
st.error(_("persistance.errors.read_file").format(function="_maj_champ", file=fichier, error=e))
|
||||
@ -148,7 +147,7 @@ def _supprime_champ(fichier: Path, cle: str) -> bool:
|
||||
supprimer_cle_profonde(sauvegarde, cle)
|
||||
|
||||
try:
|
||||
with open(fichier, "w", encoding="utf-8") as f:
|
||||
with fichier.open("w", encoding="utf-8") as f:
|
||||
json.dump(sauvegarde, f, indent=4)
|
||||
except Exception as e:
|
||||
st.error(_("persistance.errors.write_file").format(function="_supprime_champ", file=fichier, error=e))
|
||||
@ -196,9 +195,8 @@ def get_full_structure() -> dict|None:
|
||||
fichier = SAVE_STATUT_PATH
|
||||
if fichier.exists():
|
||||
try:
|
||||
with open(fichier, encoding="utf-8") as f:
|
||||
sauvegarde = json.load(f)
|
||||
return sauvegarde
|
||||
with fichier.open(encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
st.error(_("persistance.errors.read_file").format(function="_maj_champ", file=fichier, error=e))
|
||||
return None
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
|
||||
@ -18,12 +18,12 @@ def load_translations(lang="fr"):
|
||||
dict: Dictionnaire des traductions ou un dictionnaire vide en cas d'erreur
|
||||
"""
|
||||
try:
|
||||
file_path = os.path.join("assets", "locales", f"{lang}.json")
|
||||
if not os.path.exists(file_path):
|
||||
file_path = Path("assets") / "locales" / f"{lang}.json"
|
||||
if not file_path.exists():
|
||||
logger.warning(f"Fichier de traduction non trouvé: {file_path}")
|
||||
return {}
|
||||
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
with file_path.open(encoding="utf-8") as f:
|
||||
translations = json.load(f)
|
||||
logger.info(f"Traductions chargées: {lang}")
|
||||
return translations
|
||||
@ -33,6 +33,7 @@ def load_translations(lang="fr"):
|
||||
|
||||
def get_translation(key):
|
||||
"""Récupère une traduction par sa clé.
|
||||
|
||||
Les clés peuvent être hiérarchiques, séparées par des points.
|
||||
Exemple: "header.title" pour accéder à translations["header"]["title"]
|
||||
|
||||
@ -74,7 +75,7 @@ def set_language(lang="fr"):
|
||||
|
||||
# Initialiser la langue française par défaut
|
||||
def init_translations():
|
||||
"""Initialise les traductions avec la langue française"""
|
||||
"""Initialise les traductions avec la langue française."""
|
||||
if "translations" not in st.session_state:
|
||||
set_language("fr")
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user