- Fusionner Architecture Technique et ADR sous section Architecture unique - Fermer les menus de navigation par défaut (retrait navigation.expand) - Ajouter features BDD manquantes pour domaine Shared : * Authentication (13 features) * Profil (3 features) * Partage (2 features) * Error Handling (4 features) - Corriger indentation tableaux dans génération Markdown BDD (éviter transformation en blocs de code) - Corriger diagramme séquence authentification (API → DB au lieu de App Mobile → DB) - Supprimer fichiers obsolètes et index manuels
419 lines
14 KiB
Python
419 lines
14 KiB
Python
#!/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"
|
|
# Pas d'indentation pour les tableaux (sinon ils deviennent des blocs de code)
|
|
result += format_table_markdown(step.table, "")
|
|
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 / 'docs' / '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()
|