Initial commit
This commit is contained in:
417
scripts/generate-bdd-docs.py
Normal file
417
scripts/generate-bdd-docs.py
Normal file
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script pour générer la documentation Markdown à partir des fichiers Gherkin (.feature)
|
||||
Convertit les fichiers .feature en fichiers .md pour MkDocs Material
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Step:
|
||||
"""Représente une étape Gherkin"""
|
||||
keyword: str # Étant donné, Quand, Alors, Et, Mais
|
||||
text: str
|
||||
table: List[List[str]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scenario:
|
||||
"""Représente un scénario ou plan de scénario"""
|
||||
title: str
|
||||
is_outline: bool
|
||||
steps: List[Step] = field(default_factory=list)
|
||||
examples: List[List[str]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Feature:
|
||||
"""Représente une fonctionnalité complète"""
|
||||
title: str
|
||||
description: str = ""
|
||||
context_steps: List[Step] = field(default_factory=list)
|
||||
scenarios: List[Scenario] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_table(lines: List[str], start_idx: int) -> Tuple[List[List[str]], int]:
|
||||
"""Parse une table Gherkin et retourne (table, nouvel index)"""
|
||||
table = []
|
||||
idx = start_idx
|
||||
while idx < len(lines) and lines[idx].strip().startswith('|'):
|
||||
row = [cell.strip() for cell in lines[idx].strip().strip('|').split('|')]
|
||||
table.append(row)
|
||||
idx += 1
|
||||
return table, idx
|
||||
|
||||
|
||||
def parse_feature_content(content: str) -> Feature:
|
||||
"""Parse le contenu d'un fichier .feature"""
|
||||
# Supprimer la ligne de langage
|
||||
content = re.sub(r'^# language: \w+\n', '', content)
|
||||
|
||||
lines = content.split('\n')
|
||||
feature = Feature(title="")
|
||||
|
||||
i = 0
|
||||
current_scenario: Optional[Scenario] = None
|
||||
in_context = False
|
||||
in_examples = False
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
# Ligne vide
|
||||
if not stripped:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Fonctionnalité
|
||||
if stripped.startswith('Fonctionnalité:'):
|
||||
feature.title = stripped[len('Fonctionnalité:'):].strip()
|
||||
i += 1
|
||||
# Capturer la description (lignes indentées qui suivent)
|
||||
desc_lines = []
|
||||
while i < len(lines) and lines[i].startswith(' ') and not lines[i].strip().startswith(('Contexte:', 'Scénario:', 'Plan du Scénario:')):
|
||||
desc_lines.append(lines[i].strip())
|
||||
i += 1
|
||||
feature.description = '\n'.join(desc_lines)
|
||||
continue
|
||||
|
||||
# Contexte
|
||||
if stripped == 'Contexte:':
|
||||
in_context = True
|
||||
in_examples = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Plan du Scénario
|
||||
if stripped.startswith('Plan du Scénario:'):
|
||||
if current_scenario:
|
||||
feature.scenarios.append(current_scenario)
|
||||
current_scenario = Scenario(
|
||||
title=stripped[len('Plan du Scénario:'):].strip(),
|
||||
is_outline=True
|
||||
)
|
||||
in_context = False
|
||||
in_examples = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Scénario
|
||||
if stripped.startswith('Scénario:'):
|
||||
if current_scenario:
|
||||
feature.scenarios.append(current_scenario)
|
||||
current_scenario = Scenario(
|
||||
title=stripped[len('Scénario:'):].strip(),
|
||||
is_outline=False
|
||||
)
|
||||
in_context = False
|
||||
in_examples = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Exemples (pour Plan du Scénario)
|
||||
if stripped == 'Exemples:':
|
||||
in_examples = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Table d'exemples
|
||||
if in_examples and stripped.startswith('|') and current_scenario:
|
||||
table, i = parse_table(lines, i)
|
||||
current_scenario.examples = table
|
||||
in_examples = False
|
||||
continue
|
||||
|
||||
# Étapes Gherkin
|
||||
keywords = ['Étant donné', 'Quand', 'Alors', 'Et', 'Mais']
|
||||
matched_keyword = None
|
||||
for kw in keywords:
|
||||
if stripped.startswith(kw):
|
||||
matched_keyword = kw
|
||||
break
|
||||
|
||||
if matched_keyword:
|
||||
step_text = stripped[len(matched_keyword):].strip()
|
||||
step = Step(keyword=matched_keyword, text=step_text)
|
||||
|
||||
# Vérifier si une table suit
|
||||
if i + 1 < len(lines) and lines[i + 1].strip().startswith('|'):
|
||||
table, i = parse_table(lines, i + 1)
|
||||
step.table = table
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Ajouter l'étape au bon endroit
|
||||
if in_context:
|
||||
feature.context_steps.append(step)
|
||||
elif current_scenario:
|
||||
current_scenario.steps.append(step)
|
||||
continue
|
||||
|
||||
i += 1
|
||||
|
||||
# Ajouter le dernier scénario
|
||||
if current_scenario:
|
||||
feature.scenarios.append(current_scenario)
|
||||
|
||||
return feature
|
||||
|
||||
|
||||
def format_table_markdown(table: List[List[str]], indent: str = "") -> str:
|
||||
"""Formate une table en Markdown"""
|
||||
if not table:
|
||||
return ""
|
||||
|
||||
lines = []
|
||||
# En-tête
|
||||
lines.append(f"{indent}| " + " | ".join(table[0]) + " |")
|
||||
# Séparateur
|
||||
lines.append(f"{indent}|" + "|".join(["---" for _ in table[0]]) + "|")
|
||||
# Lignes de données
|
||||
for row in table[1:]:
|
||||
lines.append(f"{indent}| " + " | ".join(row) + " |")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def get_keyword_color(keyword: str) -> str:
|
||||
"""Retourne une couleur pour chaque type de mot-clé"""
|
||||
colors = {
|
||||
'Étant donné': '#2196F3', # Bleu
|
||||
'Quand': '#FF9800', # Orange
|
||||
'Alors': '#4CAF50', # Vert
|
||||
'Et': '#9E9E9E', # Gris
|
||||
'Mais': '#F44336' # Rouge
|
||||
}
|
||||
return colors.get(keyword, '#000000')
|
||||
|
||||
|
||||
def format_step(step: Step, indent: str = "") -> str:
|
||||
"""Formate une étape Gherkin avec couleur"""
|
||||
color = get_keyword_color(step.keyword)
|
||||
result = f'{indent}<span style="color: {color}">**{step.keyword}**</span> {step.text}'
|
||||
|
||||
if step.table:
|
||||
result += "\n\n"
|
||||
result += format_table_markdown(step.table, indent + " ")
|
||||
result += "\n"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def generate_markdown(feature: Feature) -> str:
|
||||
"""Génère le contenu Markdown pour une fonctionnalité"""
|
||||
lines = []
|
||||
|
||||
# Titre de la fonctionnalité
|
||||
lines.append(f"# {feature.title}\n")
|
||||
|
||||
# Description en tant que user story (bloc quote formaté proprement)
|
||||
if feature.description:
|
||||
desc_lines = feature.description.split('\n')
|
||||
for desc_line in desc_lines:
|
||||
lines.append(f"> *{desc_line}*\n")
|
||||
lines.append("\n")
|
||||
|
||||
# Statistiques
|
||||
scenario_count = len(feature.scenarios)
|
||||
outline_count = sum(1 for s in feature.scenarios if s.is_outline)
|
||||
regular_count = scenario_count - outline_count
|
||||
|
||||
stats_parts = [f"**{scenario_count} scénario{'s' if scenario_count > 1 else ''}**"]
|
||||
if outline_count > 0:
|
||||
stats_parts.append(f" ({regular_count} standard{'s' if regular_count > 1 else ''}, {outline_count} plan{'s' if outline_count > 1 else ''})")
|
||||
lines.append("".join(stats_parts) + "\n\n")
|
||||
|
||||
lines.append("---\n\n")
|
||||
|
||||
# Contexte
|
||||
if feature.context_steps:
|
||||
lines.append('!!! info "Contexte commun à tous les scénarios"\n\n')
|
||||
for step in feature.context_steps:
|
||||
lines.append(format_step(step, " ") + "\n")
|
||||
lines.append("\n")
|
||||
|
||||
# Scénarios
|
||||
for idx, scenario in enumerate(feature.scenarios, 1):
|
||||
# Header h2 pour chaque scénario
|
||||
if scenario.is_outline:
|
||||
lines.append(f"## {idx}. 📋 Plan: {scenario.title}\n\n")
|
||||
else:
|
||||
lines.append(f"## {idx}. {scenario.title}\n\n")
|
||||
|
||||
# Regrouper les étapes par section (Given/When/Then)
|
||||
current_section = None
|
||||
for step in scenario.steps:
|
||||
# Déterminer la section
|
||||
if step.keyword == 'Étant donné':
|
||||
section = 'given'
|
||||
elif step.keyword == 'Quand':
|
||||
section = 'when'
|
||||
elif step.keyword == 'Alors':
|
||||
section = 'then'
|
||||
else:
|
||||
section = current_section # Et/Mais gardent la section précédente
|
||||
|
||||
# Ajouter un séparateur visuel entre les sections
|
||||
if section != current_section and current_section is not None:
|
||||
lines.append("\n")
|
||||
current_section = section
|
||||
|
||||
lines.append(format_step(step, "") + "\n")
|
||||
|
||||
# Exemples pour les plans de scénario
|
||||
if scenario.is_outline and scenario.examples:
|
||||
lines.append("\n**📊 Exemples de données:**\n\n")
|
||||
lines.append(format_table_markdown(scenario.examples, "") + "\n")
|
||||
|
||||
lines.append("\n---\n\n")
|
||||
|
||||
return ''.join(lines)
|
||||
|
||||
|
||||
def generate_index(features_dir: Path, output_dir: Path) -> None:
|
||||
"""Génère un fichier index.md listant toutes les fonctionnalités"""
|
||||
categories: Dict[str, List[Tuple[str, Path, int, int]]] = {}
|
||||
|
||||
for feature_file in features_dir.rglob('*.feature'):
|
||||
category = feature_file.parent.name
|
||||
relative_path = feature_file.relative_to(features_dir)
|
||||
md_path = relative_path.with_suffix('.md')
|
||||
|
||||
# Extraire le titre et compter les scénarios
|
||||
with open(feature_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
feature_match = re.search(r'^Fonctionnalité: (.+)$', content, re.MULTILINE)
|
||||
title = feature_match.group(1) if feature_match else feature_file.stem
|
||||
|
||||
scenario_count = len(re.findall(r'^\s{2}Scénario:', content, re.MULTILINE))
|
||||
outline_count = len(re.findall(r'^\s{2}Plan du Scénario:', content, re.MULTILINE))
|
||||
|
||||
if category not in categories:
|
||||
categories[category] = []
|
||||
categories[category].append((title, md_path, scenario_count, outline_count))
|
||||
|
||||
# Calculer les totaux
|
||||
total_features = sum(len(cat) for cat in categories.values())
|
||||
total_scenarios = sum(s + o for cat in categories.values() for _, _, s, o in cat)
|
||||
|
||||
# Générer l'index
|
||||
index_lines = [
|
||||
"# Tests BDD - Documentation des fonctionnalités\n\n",
|
||||
"Cette documentation est générée automatiquement à partir des fichiers Gherkin (`.feature`).\n\n",
|
||||
"## Vue d'ensemble\n\n",
|
||||
f"| Métrique | Valeur |\n",
|
||||
f"|----------|--------|\n",
|
||||
f"| Fonctionnalités | **{total_features}** |\n",
|
||||
f"| Scénarios | **{total_scenarios}** |\n",
|
||||
f"| Domaines métier | **{len(categories)}** |\n\n",
|
||||
"---\n\n"
|
||||
]
|
||||
|
||||
# Icônes par catégorie
|
||||
category_icons = {
|
||||
'authentication': '🔐',
|
||||
'recommendation': '🎯',
|
||||
'interest-gauges': '📊',
|
||||
'content-creation': '🎨',
|
||||
'navigation': '🧭',
|
||||
'publicites': '📢',
|
||||
'radio-live': '📻',
|
||||
'abonnements': '🔔',
|
||||
'monetisation': '💰',
|
||||
'premium': '⭐',
|
||||
'mode-offline': '📴',
|
||||
'error-handling': '⚠️',
|
||||
'rgpd-compliance': '🔒',
|
||||
'moderation': '🛡️',
|
||||
'partage': '🔗',
|
||||
'profil': '👤',
|
||||
'recherche': '🔍',
|
||||
'audio-guides': '🎧',
|
||||
}
|
||||
|
||||
for category in sorted(categories.keys()):
|
||||
category_title = category.replace('-', ' ').replace('_', ' ').title()
|
||||
icon = category_icons.get(category, '📁')
|
||||
cat_scenarios = sum(s + o for _, _, s, o in categories[category])
|
||||
|
||||
index_lines.append(f"## {icon} {category_title}\n\n")
|
||||
|
||||
# Carte de la catégorie
|
||||
index_lines.append(f"| Fonctionnalité | Scénarios |\n")
|
||||
index_lines.append(f"|----------------|:---------:|\n")
|
||||
|
||||
for title, md_path, scenario_count, outline_count in sorted(categories[category]):
|
||||
link = str(md_path).replace('\\', '/')
|
||||
total = scenario_count + outline_count
|
||||
index_lines.append(f"| [{title}]({link}) | {total} |\n")
|
||||
|
||||
index_lines.append(f"\n*{len(categories[category])} fonctionnalités • {cat_scenarios} scénarios*\n\n")
|
||||
|
||||
# Écrire l'index
|
||||
index_file = output_dir / 'index.md'
|
||||
with open(index_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(index_lines)
|
||||
|
||||
print(f"✓ Index généré: {index_file}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Point d'entrée principal"""
|
||||
# Chemins
|
||||
project_root = Path(__file__).parent.parent
|
||||
features_dir = project_root / 'features'
|
||||
output_dir = project_root / 'docs' / 'bdd'
|
||||
|
||||
# Nettoyer le dossier de sortie
|
||||
if output_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"📁 Génération de la documentation BDD...")
|
||||
print(f" Source: {features_dir}")
|
||||
print(f" Destination: {output_dir}\n")
|
||||
|
||||
# Convertir tous les fichiers .feature
|
||||
feature_files = list(features_dir.rglob('*.feature'))
|
||||
count = 0
|
||||
|
||||
for feature_file in feature_files:
|
||||
# Créer la structure de dossiers
|
||||
relative_path = feature_file.relative_to(features_dir)
|
||||
md_file = output_dir / relative_path.with_suffix('.md')
|
||||
md_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Parser et convertir
|
||||
with open(feature_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
feature = parse_feature_content(content)
|
||||
markdown = generate_markdown(feature)
|
||||
|
||||
# Écrire le fichier
|
||||
with open(md_file, 'w', encoding='utf-8') as f:
|
||||
f.write(markdown)
|
||||
|
||||
count += 1
|
||||
print(f" ✓ {relative_path} → {md_file.relative_to(project_root)}")
|
||||
|
||||
print(f"\n✓ {count} fichiers .feature convertis en Markdown")
|
||||
|
||||
# Générer l'index
|
||||
print("\n📋 Génération de l'index...")
|
||||
generate_index(features_dir, output_dir)
|
||||
|
||||
print(f"\n✅ Documentation BDD générée avec succès dans {output_dir}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user