#!/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}**{step.keyword}** {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 / 'docs' / 'domains' output_dir = project_root / 'generated' / '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()