refactor(docs): réorganiser la documentation selon principes DDD

Réorganise la documentation du projet selon les principes du Domain-Driven Design (DDD) pour améliorer la cohésion, la maintenabilité et l'alignement avec l'architecture modulaire du backend.

**Structure cible:**
```
docs/domains/
├── README.md (Context Map)
├── _shared/ (Core Domain)
├── recommendation/ (Supporting Subdomain)
├── content/ (Supporting Subdomain)
├── moderation/ (Supporting Subdomain)
├── advertising/ (Generic Subdomain)
├── premium/ (Generic Subdomain)
└── monetization/ (Generic Subdomain)
```

**Changements effectués:**

Phase 1: Création de l'arborescence des 7 bounded contexts
Phase 2: Déplacement des règles métier (01-19) vers domains/*/rules/
Phase 3: Déplacement des diagrammes d'entités vers domains/*/entities/
Phase 4: Déplacement des diagrammes flux/états/séquences vers domains/*/
Phase 5: Création des README.md pour chaque domaine
Phase 6: Déplacement des features Gherkin vers domains/*/features/
Phase 7: Création du Context Map (domains/README.md)
Phase 8: Mise à jour de mkdocs.yml pour la nouvelle navigation
Phase 9: Correction automatique des liens internes (script fix-markdown-links.sh)
Phase 10: Nettoyage de l'ancienne structure (regles-metier/, diagrammes/, features/)

**Configuration des tests:**
- Makefile: godog run docs/domains/*/features/
- scripts/generate-bdd-docs.py: features_dir → docs/domains

**Avantages:**
 Cohésion forte: toute la doc d'un domaine au même endroit
 Couplage faible: domaines indépendants, dépendances explicites
 Navigabilité améliorée: README par domaine = entrée claire
 Alignement code/docs: miroir de backend/internal/
 Onboarding facilité: exploration domaine par domaine
 Tests BDD intégrés: features au plus près des règles métier

Voir docs/REFACTOR-DDD.md pour le plan complet.
This commit is contained in:
jpgiannetti
2026-02-07 17:15:02 +01:00
parent 78422bb2c0
commit 5e5fcf4714
227 changed files with 1413 additions and 1967 deletions

View File

@@ -0,0 +1,296 @@
# language: fr
Fonctionnalité: API - Pas de dégradation temporelle
En tant qu'API backend
Je veux que les jauges n'évoluent que par les actions utilisateur
Afin d'avoir un comportement prévisible sans automatisme caché
Contexte:
Étant donné que l'API RoadWave est disponible
Et que la base de données PostgreSQL est accessible
Scénario: API ne dégrade jamais les jauges automatiquement
Étant donné qu'un utilisateur "user123" a une jauge "Économie" à 80% en base
Et que la colonne updated_at = "2026-01-01T10:00:00Z"
Quand 30 jours s'écoulent sans activité
Et je GET /api/v1/users/user123/interest-gauges le "2026-02-01T10:00:00Z"
Alors le statut de réponse est 200
Et la jauge "Économie" est toujours à 80%
Et aucune dégradation temporelle n'a été appliquée
Et la colonne updated_at n'a pas changé
Scénario: Aucun cron job de dégradation n'existe
Étant donné que le système vérifie les tâches cron planifiées
Quand je liste tous les cron jobs du backend
Alors aucun job nommé "degrade_interest_gauges" n'existe
Et aucun job périodique ne modifie la table interest_gauges
Et aucune ressource CPU n'est consommée pour la dégradation
Scénario: API GET jauges après 6 mois d'inactivité
Étant donné qu'un utilisateur "user123" a les jauges suivantes:
| catégorie | niveau | updated_at |
| Automobile | 75% | 2025-08-01T10:00:00 |
| Voyage | 60% | 2025-08-01T10:00:00 |
| Musique | 45% | 2025-08-01T10:00:00 |
Et qu'il ne se connecte pas pendant 6 mois
Quand je GET /api/v1/users/user123/interest-gauges le "2026-02-01T10:00:00Z"
Alors le statut de réponse est 200
Et les jauges sont exactement les mêmes:
| catégorie | niveau |
| Automobile | 75% |
| Voyage | 60% |
| Musique | 45% |
Et aucune modification n'a été appliquée
Scénario: Évolution par actions utilisateur uniquement
Étant donné qu'un utilisateur "user123" a une jauge "Économie" à 80%
Et qu'il skip 50 contenus "Économie" en 1 an
Quand je calcule l'évolution via les events
Alors la jauge "Économie" descend via les skips:
| action | impact | nouveau_niveau |
| 50 skips × -0.5%| -25% | 55% |
Et la dégradation vient des actions, pas du temps
Et la colonne updated_at reflète la date du dernier skip
Scénario: API POST réinitialiser centres d'intérêt
Étant donné qu'un utilisateur "user123" a des jauges personnalisées:
| catégorie | niveau |
| Automobile | 75% |
| Voyage | 60% |
| Économie | 34% |
| Sport | 88% |
Quand je POST /api/v1/users/user123/interest-gauges/reset
"""json
{
"confirmation": true
}
"""
Alors le statut de réponse est 200
Et la réponse contient:
"""json
{
"message": "Vos centres d'intérêt ont été réinitialisés",
"previous_gauges_saved": true,
"new_gauges": {
"all_categories": 50
}
}
"""
Et en base de données, toutes les jauges de "user123" sont à 50%:
| catégorie | niveau |
| Automobile | 50 |
| Voyage | 50 |
| Économie | 50 |
| Sport | 50 |
| Musique | 50 |
| Technologie | 50 |
| Santé | 50 |
| Politique | 50 |
| Cryptomonnaie | 50 |
| Culture générale | 50 |
| Famille | 50 |
| Amour | 50 |
Scénario: API sauvegarde jauges précédentes avant réinitialisation
Étant donné qu'un utilisateur "user123" a des jauges personnalisées
Quand je POST /api/v1/users/user123/interest-gauges/reset
"""json
{
"confirmation": true
}
"""
Alors le statut de réponse est 200
Et une ligne est insérée dans interest_gauges_snapshots:
| user_id | snapshot_type | snapshot_date | gauges_json |
| user123 | manual_reset | 2026-02-02T14:00:00 | {"Automobile": 75, "Voyage": 60, ...} |
Et l'historique permet de restaurer si besoin
Scénario: API rejette réinitialisation sans confirmation
Quand je POST /api/v1/users/user123/interest-gauges/reset
"""json
{
"confirmation": false
}
"""
Alors le statut de réponse est 400
Et la réponse contient:
"""json
{
"error": "CONFIRMATION_REQUIRED",
"message": "Vous devez confirmer la réinitialisation"
}
"""
Et les jauges ne sont pas modifiées
Scénario: API recommandations après réinitialisation
Étant donné qu'un utilisateur "user123" avait "Économie" à 85%
Et qu'il réinitialise ses jauges (toutes à 50%)
Quand je POST /api/v1/recommendations
"""json
{
"user_id": "user123",
"latitude": 48.8566,
"longitude": 2.3522,
"limit": 10
}
"""
Alors le statut de réponse est 200
Et les recommandations utilisent 50% pour toutes les catégories
Et plus aucun biais "Économie" n'est appliqué
Et la géolocalisation redevient le critère principal
Scénario: Historique d'écoute conservé après réinitialisation
Étant donné qu'un utilisateur "user123" a écouté 500 contenus
Et que la table listening_history contient 500 lignes pour "user123"
Quand je POST /api/v1/users/user123/interest-gauges/reset
Alors le statut de réponse est 200
Et la table listening_history conserve toujours les 500 lignes
Et aucune donnée d'historique n'est supprimée
Et l'utilisateur peut toujours consulter ses anciens contenus écoutés
Scénario: API GET historique après réinitialisation
Étant donné qu'un utilisateur "user123" a réinitialisé ses jauges
Quand je GET /api/v1/users/user123/listening-history
Alors le statut de réponse est 200
Et la réponse contient tous les anciens contenus écoutés
Et l'historique est intact
Scénario: API enregistre timestamp réinitialisation
Étant donné qu'un utilisateur "user123" réinitialise ses jauges
Quand je POST /api/v1/users/user123/interest-gauges/reset
Alors en base de données, la table users est mise à jour:
| user_id | interest_gauges_reset_at | reset_count |
| user123 | 2026-02-02T14:00:00Z | 1 |
Et un compteur permet de tracker les réinitialisations multiples
Scénario: API permet réinitialisations multiples
Étant donné qu'un utilisateur "user123" a déjà réinitialisé une fois
Quand je POST /api/v1/users/user123/interest-gauges/reset une 2ème fois
Alors le statut de réponse est 200
Et le reset_count passe à 2
Et toutes les jauges reviennent à 50%
Scénario: API n'envoie jamais de suggestion de réinitialisation
Étant donné qu'un utilisateur "user123" n'a pas utilisé l'app depuis 1 an
Quand je GET /api/v1/users/user123/notifications
Alors le statut de réponse est 200
Et aucune notification "Réinitialiser vos centres d'intérêt" n'est présente
Et le système ne suggère jamais de réinitialisation automatique
Scénario: API GET statistiques respect historique utilisateur
Étant donné qu'un utilisateur "user123" aime "Cryptomonnaie" depuis 2 ans
Et que sa jauge est à 90%
Et qu'il n'a pas écouté de contenu "Cryptomonnaie" depuis 6 mois
Quand je GET /api/v1/users/user123/interest-gauges
Alors le statut de réponse est 200
Et la jauge "Cryptomonnaie" est toujours à 90%
Et la réponse contient:
"""json
{
"gauges": [
{
"category": "Cryptomonnaie",
"level": 90,
"last_updated": "2025-08-02T10:00:00Z",
"days_since_update": 183,
"preserved": true
}
]
}
"""
Et le système respecte l'historique des goûts
Scénario: API métrique temps écoulé sans modifier la jauge
Étant donné qu'un utilisateur "user123" a une jauge "Sport" à 65%
Et que la dernière modification date de 90 jours
Quand je GET /api/v1/users/user123/interest-gauges/sport
Alors le statut de réponse est 200
Et la réponse contient:
"""json
{
"category": "Sport",
"level": 65,
"last_updated": "2025-11-03T12:00:00Z",
"days_since_update": 90
}
"""
Et la métrique days_since_update est informative uniquement
Et elle ne modifie jamais la jauge
Scénario: Requête SQL n'utilise jamais de calcul temporel
Étant donné que je trace les requêtes SQL du backend
Quand je GET /api/v1/users/user123/interest-gauges
Alors la requête SQL exécutée est:
"""sql
SELECT category, level, updated_at
FROM interest_gauges
WHERE user_id = $1
"""
Et aucune clause WHERE avec date/timestamp n'est présente
Et aucune fonction NOW(), CURRENT_TIMESTAMP, ou DATEDIFF n'est utilisée
Et le calcul est minimal (simple SELECT)
Scénario: API coût CPU minimal - pas de calcul de dates
Étant donné que 10000 utilisateurs consultent leurs jauges simultanément
Quand les requêtes /api/v1/users/{id}/interest-gauges sont exécutées
Alors aucun calcul de date n'est nécessaire
Et aucun appel à time.Now() ou time.Since() n'est fait
Et le coût CPU par requête est < 1ms
Et aucune dégradation de performance liée aux dates
Scénario: API pas de risque de bug fuseau horaire
Étant donné qu'aucune logique temporelle n'existe
Quand un utilisateur change de fuseau horaire (Paris Tokyo)
Alors ses jauges ne sont pas affectées
Et aucun bug de conversion UTC/local ne peut survenir
Et le comportement reste déterministe
Scénario: Audit log réinitialisation manuelle
Étant donné qu'un utilisateur "user123" réinitialise ses jauges
Quand je POST /api/v1/users/user123/interest-gauges/reset
Alors une ligne est insérée dans audit_log:
| user_id | action | timestamp | details |
| user123 | interest_gauges_reset | 2026-02-02T14:00:00 | {"previous_snapshot_id": 42} |
Et l'audit permet de tracer toutes les réinitialisations
Scénario: API empêche réinitialisation trop fréquente
Étant donné qu'un utilisateur "user123" a réinitialisé il y a 10 minutes
Quand je POST /api/v1/users/user123/interest-gauges/reset
Alors le statut de réponse est 429
Et la réponse contient:
"""json
{
"error": "RATE_LIMIT_EXCEEDED",
"message": "Vous ne pouvez réinitialiser qu'une fois par heure",
"retry_after_seconds": 3000
}
"""
Scénario: API documentation endpoints réinitialisation
Quand je GET /api/v1/openapi.json
Alors le endpoint POST /api/v1/users/{id}/interest-gauges/reset est documenté:
"""yaml
/users/{id}/interest-gauges/reset:
post:
summary: Réinitialise toutes les jauges à 50%
description: |
Remet toutes les jauges d'intérêt à leur valeur par défaut (50%).
Cette action est manuelle et requiert une confirmation.
Les jauges précédentes sont sauvegardées.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
confirmation:
type: boolean
description: Doit être true
responses:
200:
description: Réinitialisation réussie
400:
description: Confirmation manquante
429:
description: Trop de réinitialisations
"""

View File

@@ -0,0 +1,567 @@
# language: fr
Fonctionnalité: API - Évolution des jauges d'intérêt
En tant qu'API backend
Je veux calculer et persister les évolutions de jauges d'intérêt
Afin d'alimenter l'algorithme de recommandation
Contexte:
Étant donné que l'API RoadWave est disponible
Et que la base de données PostgreSQL est accessible
Et qu'un utilisateur "user123" existe avec token JWT valide
Scénario: API calcule like automatique renforcé (≥80% écoute)
Étant donné que l'utilisateur "user123" a une jauge "Automobile" à 45% en base
Et qu'un contenu "content456" de 5 minutes est tagué "Automobile"
Quand je POST /api/v1/listening-events
"""json
{
"user_id": "user123",
"content_id": "content456",
"listened_duration_seconds": 270,
"total_duration_seconds": 300,
"completion_percentage": 90
}
"""
Alors le statut de réponse est 201
Et la réponse contient:
"""json
{
"like_type": "automatic_reinforced",
"gauge_updates": [
{
"category": "Automobile",
"previous_value": 45,
"delta": 2,
"new_value": 47
}
]
}
"""
Et en base de données, la jauge "Automobile" de "user123" est à 47%
Scénario: API calcule like automatique standard (30-79% écoute)
Étant donné que l'utilisateur "user123" a une jauge "Voyage" à 60% en base
Et qu'un contenu "content789" de 10 minutes est tagué "Voyage"
Quand je POST /api/v1/listening-events
"""json
{
"user_id": "user123",
"content_id": "content789",
"listened_duration_seconds": 300,
"total_duration_seconds": 600,
"completion_percentage": 50
}
"""
Alors le statut de réponse est 201
Et la réponse contient:
"""json
{
"like_type": "automatic_standard",
"gauge_updates": [
{
"category": "Voyage",
"previous_value": 60,
"delta": 1,
"new_value": 61
}
]
}
"""
Et en base de données, la jauge "Voyage" de "user123" est à 61%
Scénario: API applique like manuel explicite (+2%)
Étant donné que l'utilisateur "user123" a une jauge "Musique" à 55% en base
Et qu'un contenu "content999" est tagué "Musique"
Quand je POST /api/v1/likes
"""json
{
"user_id": "user123",
"content_id": "content999",
"like_type": "manual"
}
"""
Alors le statut de réponse est 201
Et la réponse contient:
"""json
{
"like_id": "<uuid>",
"gauge_updates": [
{
"category": "Musique",
"previous_value": 55,
"delta": 2,
"new_value": 57
}
]
}
"""
Et en base de données, la jauge "Musique" de "user123" est à 57%
Scénario: API applique unlike (retire like manuel)
Étant donné que l'utilisateur "user123" a une jauge "Sport" à 57% en base
Et qu'il a liké manuellement le contenu "content888" tagué "Sport"
Et que le like a l'ID "like_abc123"
Quand je DELETE /api/v1/likes/like_abc123
Alors le statut de réponse est 204
Et en base de données, la jauge "Sport" de "user123" est à 55%
Et le like "like_abc123" est supprimé de la table likes
Scénario: API refuse unlike d'un like automatique
Étant donné que l'utilisateur "user123" a écouté un contenu à 90%
Et qu'il a reçu un like automatique "like_auto456"
Quand je DELETE /api/v1/likes/like_auto456
Alors le statut de réponse est 403
Et la réponse contient:
"""json
{
"error": "CANNOT_UNLIKE_AUTOMATIC",
"message": "Les likes automatiques ne peuvent pas être retirés"
}
"""
Et en base de données, le like "like_auto456" existe toujours
Scénario: API cumule like automatique + like manuel
Étant donné que l'utilisateur "user123" a une jauge "Technologie" à 50% en base
Et qu'un contenu "content777" de 10 minutes est tagué "Technologie"
Quand je POST /api/v1/listening-events
"""json
{
"user_id": "user123",
"content_id": "content777",
"completion_percentage": 50
}
"""
Alors la jauge "Technologie" passe à 51% (+1% auto)
Quand je POST ensuite /api/v1/likes
"""json
{
"user_id": "user123",
"content_id": "content777",
"like_type": "manual"
}
"""
Alors la jauge "Technologie" passe à 53% (+2% manuel)
Et le delta total est de 3%
Scénario: API applique bonus abonnement créateur (+5% tous tags)
Étant donné que l'utilisateur "user123" a les jauges suivantes:
| catégorie | niveau |
| Automobile | 50% |
| Technologie | 45% |
Et qu'un créateur "creator456" publie des contenus tagués "Automobile" et "Technologie"
Quand je POST /api/v1/subscriptions
"""json
{
"user_id": "user123",
"creator_id": "creator456"
}
"""
Alors le statut de réponse est 201
Et en base de données:
| catégorie | niveau |
| Automobile | 55% |
| Technologie | 50% |
Et l'abonnement est créé avec bonus appliqué
Scénario: API retire bonus désabonnement créateur (-5% tous tags)
Étant donné que l'utilisateur "user123" a les jauges suivantes:
| catégorie | niveau |
| Voyage | 65% |
| Culture | 58% |
Et qu'il est abonné au créateur "creator789" qui publie "Voyage" et "Culture"
Quand je DELETE /api/v1/subscriptions/creator789
Alors le statut de réponse est 204
Et en base de données:
| catégorie | niveau |
| Voyage | 60% |
| Culture | 53% |
Scénario: API applique pénalité skip rapide (<10s)
Étant donné que l'utilisateur "user123" a une jauge "Politique" à 50% en base
Et qu'un contenu "content555" de 300 secondes est tagué "Politique"
Quand je POST /api/v1/skip-events
"""json
{
"user_id": "user123",
"content_id": "content555",
"listened_duration_seconds": 5,
"total_duration_seconds": 300
}
"""
Alors le statut de réponse est 201
Et la réponse contient:
"""json
{
"skip_type": "early",
"gauge_updates": [
{
"category": "Politique",
"previous_value": 50,
"delta": -0.5,
"new_value": 49.5
}
]
}
"""
Et en base de données, la jauge "Politique" de "user123" est à 49.5%
Scénario: API n'applique pas de pénalité pour skip ≥10s et <30%
Étant donné que l'utilisateur "user123" a une jauge "Économie" à 60% en base
Et qu'un contenu "content333" de 600 secondes est tagué "Économie"
Quand je POST /api/v1/skip-events
"""json
{
"user_id": "user123",
"content_id": "content333",
"listened_duration_seconds": 120,
"total_duration_seconds": 600,
"completion_percentage": 20
}
"""
Alors le statut de réponse est 201
Et la réponse contient:
"""json
{
"skip_type": "neutral",
"gauge_updates": []
}
"""
Et en base de données, la jauge "Économie" de "user123" reste à 60%
Scénario: API n'applique pas de pénalité pour skip ≥30%
Étant donné que l'utilisateur "user123" a une jauge "Sport" à 55% en base
Et qu'un contenu "content222" de 600 secondes est tagué "Sport"
Quand je POST /api/v1/skip-events
"""json
{
"user_id": "user123",
"content_id": "content222",
"listened_duration_seconds": 300,
"total_duration_seconds": 600,
"completion_percentage": 50
}
"""
Alors le statut de réponse est 201
Et la réponse contient:
"""json
{
"skip_type": "late",
"gauge_updates": []
}
"""
Et en base de données, la jauge "Sport" de "user123" reste à 55%
Scénario: API applique évolution sur plusieurs tags simultanément
Étant donné que l'utilisateur "user123" a les jauges suivantes:
| catégorie | niveau |
| Automobile | 45% |
| Voyage | 60% |
Et qu'un contenu "content111" est tagué "Automobile" et "Voyage"
Quand je POST /api/v1/listening-events
"""json
{
"user_id": "user123",
"content_id": "content111",
"completion_percentage": 90
}
"""
Alors le statut de réponse est 201
Et la réponse contient:
"""json
{
"like_type": "automatic_reinforced",
"gauge_updates": [
{
"category": "Automobile",
"previous_value": 45,
"delta": 2,
"new_value": 47
},
{
"category": "Voyage",
"previous_value": 60,
"delta": 2,
"new_value": 62
}
]
}
"""
Et en base de données:
| catégorie | niveau |
| Automobile | 47% |
| Voyage | 62% |
Scénario: API respecte la borne maximum 100%
Étant donné que l'utilisateur "user123" a une jauge "Cryptomonnaie" à 99% en base
Et qu'un contenu "content_crypto" est tagué "Cryptomonnaie"
Quand je POST /api/v1/listening-events
"""json
{
"user_id": "user123",
"content_id": "content_crypto",
"completion_percentage": 95
}
"""
Alors le statut de réponse est 201
Et la réponse contient:
"""json
{
"like_type": "automatic_reinforced",
"gauge_updates": [
{
"category": "Cryptomonnaie",
"previous_value": 99,
"delta": 2,
"new_value": 100,
"capped": true
}
]
}
"""
Et en base de données, la jauge "Cryptomonnaie" de "user123" est à 100%
Et la jauge n'a pas dépassé 100%
Scénario: API respecte la borne minimum 0%
Étant donné que l'utilisateur "user123" a une jauge "Politique" à 0.3% en base
Et qu'un contenu "content_pol" est tagué "Politique"
Quand je POST /api/v1/skip-events
"""json
{
"user_id": "user123",
"content_id": "content_pol",
"listened_duration_seconds": 3,
"total_duration_seconds": 300
}
"""
Alors le statut de réponse est 201
Et la réponse contient:
"""json
{
"skip_type": "early",
"gauge_updates": [
{
"category": "Politique",
"previous_value": 0.3,
"delta": -0.5,
"new_value": 0,
"capped": true
}
]
}
"""
Et en base de données, la jauge "Politique" de "user123" est à 0%
Et la jauge n'est pas devenue négative
Scénario: API respecte borne minimum lors désabonnement
Étant donné que l'utilisateur "user123" a une jauge "Économie" à 3% en base
Et qu'il est abonné au créateur "creator_eco" qui publie "Économie"
Quand je DELETE /api/v1/subscriptions/creator_eco
Alors le statut de réponse est 204
Et en base de données, la jauge "Économie" de "user123" est à 0% (et non -2%)
Scénario: API GET retourne toutes les jauges utilisateur
Étant donné que l'utilisateur "user123" a les jauges suivantes en base:
| catégorie | niveau |
| Automobile | 67% |
| Voyage | 82% |
| Économie | 34% |
| Sport | 50% |
| Musique | 45% |
| Technologie | 71% |
Quand je GET /api/v1/users/user123/interest-gauges
Alors le statut de réponse est 200
Et la réponse contient les 12 catégories avec leurs niveaux:
"""json
{
"user_id": "user123",
"gauges": [
{"category": "Automobile", "level": 67},
{"category": "Voyage", "level": 82},
{"category": "Économie", "level": 34},
{"category": "Sport", "level": 50},
{"category": "Musique", "level": 45},
{"category": "Technologie", "level": 71}
]
}
"""
Scénario: API calcule évolution immédiate (pas de batch différé)
Étant donné que l'utilisateur "user123" a une jauge "Voyage" à 50% en base
Quand je POST /api/v1/listening-events à 12:00:00
"""json
{
"user_id": "user123",
"content_id": "content_travel",
"completion_percentage": 85
}
"""
Alors le statut de réponse est 201
Quand je GET /api/v1/users/user123/interest-gauges à 12:00:01 (1 seconde après)
Alors la jauge "Voyage" est à 52%
Et la mise à jour est visible immédiatement
Scénario: API rejette token JWT invalide
Quand je POST /api/v1/listening-events sans token JWT
Alors le statut de réponse est 401
Et la réponse contient:
"""json
{
"error": "UNAUTHORIZED",
"message": "Token JWT manquant ou invalide"
}
"""
Scénario: API valide format des données d'entrée
Quand je POST /api/v1/listening-events
"""json
{
"user_id": "user123",
"content_id": "content456",
"completion_percentage": 150
}
"""
Alors le statut de réponse est 400
Et la réponse contient:
"""json
{
"error": "VALIDATION_ERROR",
"message": "completion_percentage doit être entre 0 et 100"
}
"""
Scénario: API gère contenu avec tags inexistants en base
Étant donné qu'un contenu "content_new" est tagué "NouvelleCategorie" (non encore en base)
Quand je POST /api/v1/listening-events
"""json
{
"user_id": "user123",
"content_id": "content_new",
"completion_percentage": 90
}
"""
Alors le statut de réponse est 201
Et une nouvelle ligne est créée dans la table interest_gauges:
| user_id | category | level |
| user123 | NouvelleCategorie | 52 |
Et l'initialisation démarre à 50% + 2% de like auto = 52%
Scénario: API persiste historique des modifications de jauges
Étant donné que l'utilisateur "user123" a une jauge "Sport" à 50%
Quand je POST /api/v1/listening-events qui applique +2%
Alors une ligne est insérée dans interest_gauge_history:
| user_id | category | previous_value | delta | new_value | event_type | event_id | timestamp |
| user123 | Sport | 50 | 2 | 52 | listening_event| <event_uuid> | 2026-02-02T12:00:00 |
Et cet historique permet d'auditer les évolutions
Scénario: API retourne métriques d'évolution utilisateur
Étant donné que l'utilisateur "user123" a un historique d'évolution en base
Quand je GET /api/v1/users/user123/interest-gauges/evolution?since=7d
Alors le statut de réponse est 200
Et la réponse contient:
"""json
{
"period": "7d",
"evolution": [
{
"category": "Automobile",
"start_value": 60,
"end_value": 67,
"delta": 7,
"events_count": 15
},
{
"category": "Voyage",
"start_value": 80,
"end_value": 82,
"delta": 2,
"events_count": 3
}
]
}
"""
# Architecture backend - Services séparés
Scénario: Gauge Calculation Service calcule l'ajustement (stateless)
Étant donné un événement d'écoute avec 85% de complétion
Quand le Gauge Calculation Service calcule l'ajustement
Alors le service retourne:
"""json
{
"adjustment_type": "automatic_reinforced",
"adjustment_value": 2.0,
"reason": "completion_percentage >= 80%"
}
"""
Et le service est stateless (aucune lecture DB)
Et le service est testable unitairement
Scénario: Gauge Update Service applique l'ajustement (stateful)
Étant donné qu'un ajustement de +2% a été calculé
Et que la jauge "Automobile" de "user123" est à 45%
Quand le Gauge Update Service applique l'ajustement
Alors la nouvelle valeur est 47% (45 + 2)
Et la borne [0, 100] est respectée via fonction clamp
Et la mise à jour est persistée en Redis (immédiat)
Et la mise à jour est persistée en PostgreSQL (batch async)
Scénario: Pattern de calcul - Addition de points absolus
Étant donné qu'une jauge est à 60%
Et qu'un ajustement de +2% est calculé
Quand le calcul est effectué
Alors la formule est: newValue = currentValue + adjustment
Et la formule est: newValue = clamp(newValue, 0.0, 100.0)
Et la nouvelle valeur est 62%
Scénario: Pattern de calcul - Éviter multiplication relative
Étant donné qu'une jauge est à 50%
Et qu'un ajustement de +2% est calculé
Quand le calcul est effectué
Alors la formule utilisée est: 50 + 2 = 52
Et la formule utilisée n'est PAS: 50 * (1 + 2/100) = 51
Car l'ajustement est en points absolus, pas relatifs
Scénario: Multi-tags - Mise à jour de N jauges simultanément
Étant donné qu'un contenu a 3 tags: ["Automobile", "Voyage", "Technologie"]
Et qu'un ajustement de +2% est calculé (écoute 85%)
Quand le Gauge Update Service applique
Alors 3 jauges sont mises à jour:
| catégorie | ajustement |
| Automobile | +2% |
| Voyage | +2% |
| Technologie | +2% |
Et toutes les mises à jour sont effectuées en une seule transaction
Scénario: Persistance Redis immédiate (latence <10ms)
Étant donné qu'un ajustement doit être persisté
Quand le Gauge Update Service écrit en Redis
Alors la latence est < 10ms
Et la jauge est immédiatement disponible pour recommandations
Et Redis sert de cache haute performance
Scénario: Persistance PostgreSQL asynchrone (batch 5 min)
Étant donné que 100 ajustements ont été appliqués en Redis
Quand le batch async s'exécute toutes les 5 minutes
Alors les 100 ajustements sont écrits en PostgreSQL en batch
Et la cohérence finale est garantie
Et l'application reste performante (pas de write sync)
Scénario: Séparation responsabilités - Calculation vs Update
Étant donné un événement d'écoute
Quand il est traité
Alors le Gauge Calculation Service calcule l'ajustement (logique métier pure)
Et le Gauge Update Service applique l'ajustement (persistance)
Et les deux services sont indépendants
Et chaque service a une responsabilité unique (SRP)
Scénario: Réutilisabilité Calculation Service
Étant donné le Gauge Calculation Service
Quand il est utilisé pour:
| contexte |
| Like automatique |
| Skip rapide |
| Like manuel |
| Abonnement créateur |
Alors le même service calcule tous les ajustements
Et la logique métier est centralisée
Et il n'y a pas de duplication de code

View File

@@ -0,0 +1,337 @@
# language: fr
Fonctionnalité: API - Jauge initiale et cold start
En tant qu'API backend
Je veux initialiser toutes les jauges à 50% lors de l'inscription
Afin de garantir un démarrage neutre et équitable
Contexte:
Étant donné que l'API RoadWave est disponible
Et que la base de données PostgreSQL est accessible
Scénario: API initialise toutes les jauges à 50% lors inscription
Quand je POST /api/v1/auth/register
"""json
{
"email": "nouveau@example.com",
"password": "SecureP@ss123",
"birth_date": "1990-01-15",
"username": "nouveau_user"
}
"""
Alors le statut de réponse est 201
Et la réponse contient:
"""json
{
"user_id": "<uuid>",
"email": "nouveau@example.com",
"username": "nouveau_user"
}
"""
Et en base de données, la table interest_gauges contient 12 lignes pour ce user_id:
| category | level |
| Automobile | 50 |
| Voyage | 50 |
| Famille | 50 |
| Amour | 50 |
| Musique | 50 |
| Économie | 50 |
| Cryptomonnaie | 50 |
| Politique | 50 |
| Culture générale | 50 |
| Sport | 50 |
| Technologie | 50 |
| Santé | 50 |
Scénario: API retourne les 12 catégories disponibles
Quand je GET /api/v1/interest-gauges/categories
Alors le statut de réponse est 200
Et la réponse contient:
"""json
{
"categories": [
{"id": "automobile", "name": "Automobile", "icon": "car"},
{"id": "voyage", "name": "Voyage", "icon": "plane"},
{"id": "famille", "name": "Famille", "icon": "users"},
{"id": "amour", "name": "Amour", "icon": "heart"},
{"id": "musique", "name": "Musique", "icon": "music"},
{"id": "economie", "name": "Économie", "icon": "chart"},
{"id": "cryptomonnaie", "name": "Cryptomonnaie", "icon": "bitcoin"},
{"id": "politique", "name": "Politique", "icon": "landmark"},
{"id": "culture-generale", "name": "Culture générale", "icon": "book"},
{"id": "sport", "name": "Sport", "icon": "running"},
{"id": "technologie", "name": "Technologie", "icon": "cpu"},
{"id": "sante", "name": "Santé", "icon": "heart-pulse"}
]
}
"""
Scénario: API GET retourne jauges utilisateur nouvellement inscrit
Étant donné qu'un utilisateur "user_new" vient de s'inscrire
Quand je GET /api/v1/users/user_new/interest-gauges
Alors le statut de réponse est 200
Et la réponse contient 12 jauges toutes à 50%:
"""json
{
"user_id": "user_new",
"gauges": [
{"category": "Automobile", "level": 50, "evolution_since_signup": 0},
{"category": "Voyage", "level": 50, "evolution_since_signup": 0},
{"category": "Famille", "level": 50, "evolution_since_signup": 0},
{"category": "Amour", "level": 50, "evolution_since_signup": 0},
{"category": "Musique", "level": 50, "evolution_since_signup": 0},
{"category": "Économie", "level": 50, "evolution_since_signup": 0},
{"category": "Cryptomonnaie", "level": 50, "evolution_since_signup": 0},
{"category": "Politique", "level": 50, "evolution_since_signup": 0},
{"category": "Culture générale", "level": 50, "evolution_since_signup": 0},
{"category": "Sport", "level": 50, "evolution_since_signup": 0},
{"category": "Technologie", "level": 50, "evolution_since_signup": 0},
{"category": "Santé", "level": 50, "evolution_since_signup": 0}
]
}
"""
Scénario: API calcule recommandations avec jauges à 50% - pas de biais
Étant donné qu'un utilisateur "user_new" vient de s'inscrire
Et que toutes ses jauges sont à 50%
Et qu'il est à la position GPS (48.8566, 2.3522) - Paris
Quand je POST /api/v1/recommendations
"""json
{
"user_id": "user_new",
"latitude": 48.8566,
"longitude": 2.3522,
"limit": 10
}
"""
Alors le statut de réponse est 200
Et la réponse contient 10 contenus
Et le scoring est basé uniquement sur:
| critère | poids |
| Distance géographique| 100% |
| Intérêts (50% égal) | 0% |
Et aucune catégorie n'a d'avantage initial
Scénario: API permet ajout de nouvelles catégories
Étant donné qu'un admin ajoute une nouvelle catégorie "Gastronomie"
Quand je POST /api/v1/admin/interest-gauges/categories
"""json
{
"id": "gastronomie",
"name": "Gastronomie",
"icon": "utensils"
}
"""
Alors le statut de réponse est 201
Et pour tous les utilisateurs existants:
| action |
| Une ligne est créée dans interest_gauges |
| category = "Gastronomie" |
| level = 50 |
Et les nouveaux utilisateurs auront aussi cette catégorie à 50%
Scénario: API calcule évolution depuis inscription
Étant donné qu'un utilisateur "user123" s'est inscrit il y a 30 jours
Et qu'il a les jauges suivantes en base:
| catégorie | niveau | initial |
| Automobile | 67% | 50% |
| Voyage | 82% | 50% |
| Économie | 34% | 50% |
| Sport | 50% | 50% |
Quand je GET /api/v1/users/user123/interest-gauges
Alors le statut de réponse est 200
Et la réponse contient:
"""json
{
"user_id": "user123",
"signup_date": "2026-01-03T10:00:00Z",
"gauges": [
{
"category": "Automobile",
"level": 67,
"evolution_since_signup": 17
},
{
"category": "Voyage",
"level": 82,
"evolution_since_signup": 32
},
{
"category": "Économie",
"level": 34,
"evolution_since_signup": -16
},
{
"category": "Sport",
"level": 50,
"evolution_since_signup": 0
}
]
}
"""
Scénario: API transaction atomique lors inscription
Quand je POST /api/v1/auth/register
"""json
{
"email": "test@example.com",
"password": "SecureP@ss123",
"birth_date": "1995-03-20",
"username": "test_user"
}
"""
Alors l'insertion en base de données est atomique:
| action |
| INSERT INTO users |
| INSERT INTO interest_gauges (12 lignes) |
| Tout ou rien (transaction) |
Et si une erreur survient, aucune donnée partielle n'est créée
Scénario: API rollback si initialisation jauges échoue
Étant donné que la table interest_gauges a une contrainte violée
Quand je POST /api/v1/auth/register avec données valides
Alors le statut de réponse est 500
Et aucune ligne n'est créée dans la table users
Et aucune ligne n'est créée dans la table interest_gauges
Et la transaction est rollback complètement
Scénario: API POST questionnaire optionnel post-MVP
Étant donné qu'un utilisateur "user123" a écouté 3 contenus
Et qu'il décide de remplir le questionnaire optionnel
Quand je POST /api/v1/users/user123/interest-gauges/quick-setup
"""json
{
"selected_categories": ["Automobile", "Voyage", "Sport"]
}
"""
Alors le statut de réponse est 200
Et en base de données:
| catégorie | niveau |
| Automobile | 70 |
| Voyage | 70 |
| Sport | 70 |
| Musique | 30 |
| Économie | 30 |
| Cryptomonnaie | 30 |
| Politique | 30 |
| Culture générale | 30 |
| Technologie | 30 |
| Santé | 30 |
| Famille | 30 |
| Amour | 30 |
Et un flag quick_setup_completed = true est enregistré
Scénario: API rejette questionnaire optionnel si déjà rempli
Étant donné qu'un utilisateur "user123" a déjà rempli le questionnaire optionnel
Quand je POST /api/v1/users/user123/interest-gauges/quick-setup
"""json
{
"selected_categories": ["Musique", "Technologie"]
}
"""
Alors le statut de réponse est 409
Et la réponse contient:
"""json
{
"error": "QUICK_SETUP_ALREADY_COMPLETED",
"message": "Le questionnaire a déjà été rempli"
}
"""
Scénario: API valide nombre de catégories sélectionnées
Quand je POST /api/v1/users/user123/interest-gauges/quick-setup
"""json
{
"selected_categories": ["Automobile"]
}
"""
Alors le statut de réponse est 400
Et la réponse contient:
"""json
{
"error": "VALIDATION_ERROR",
"message": "Vous devez sélectionner entre 2 et 5 catégories"
}
"""
Scénario: API déterministe - deux users identiques
Étant donné que l'utilisateur "userA" s'inscrit à 10:00:00
Et que l'utilisateur "userB" s'inscrit à 10:00:01
Quand je GET /api/v1/users/userA/interest-gauges
Et je GET /api/v1/users/userB/interest-gauges
Alors les deux réponses ont des jauges identiques (toutes à 50%)
Et le comportement est déterministe
Scénario: API retourne statistiques cold start
Étant donné qu'un utilisateur "user_new" vient de s'inscrire
Quand je GET /api/v1/users/user_new/stats
Alors le statut de réponse est 200
Et la réponse contient:
"""json
{
"user_id": "user_new",
"signup_date": "2026-02-02T14:00:00Z",
"total_listened_content": 0,
"gauges_summary": {
"all_at_default": true,
"default_value": 50,
"total_categories": 12,
"personalization_level": "none"
}
}
"""
Scénario: API recommandations cold start priorité géo
Étant donné qu'un utilisateur "user_new" vient de s'inscrire
Et qu'il est à Paris avec 100 contenus disponibles dans un rayon de 5km
Et que ces contenus ont des catégories variées
Quand je POST /api/v1/recommendations
"""json
{
"user_id": "user_new",
"latitude": 48.8566,
"longitude": 2.3522,
"limit": 10
}
"""
Alors le statut de réponse est 200
Et les 10 contenus retournés sont les plus proches géographiquement
Et les catégories sont variées (pas de biais intérêts)
Et la réponse contient:
"""json
{
"recommendations": [
{
"content_id": "<uuid>",
"distance_meters": 150,
"interest_match": 50,
"final_score": 0.95
}
],
"cold_start": true,
"personalization_level": "none"
}
"""
Scénario: API index optimisé pour lecture jauges
Étant donné que la table interest_gauges a 1 million de lignes
Quand je GET /api/v1/users/user123/interest-gauges
Alors la requête SQL utilise l'index (user_id, category)
Et le temps de réponse est < 50ms
Et le plan d'exécution confirme l'utilisation de l'index
Scénario: API cache jauges utilisateur en Redis
Étant donné qu'un utilisateur "user123" a ses jauges en base
Quand je GET /api/v1/users/user123/interest-gauges
Alors le backend vérifie d'abord Redis avec clé "user:user123:gauges"
Et si absent, lit depuis PostgreSQL
Et met en cache dans Redis avec TTL = 300 secondes
Quand je GET à nouveau dans les 5 minutes
Alors la réponse vient directement de Redis
Et aucune requête PostgreSQL n'est faite
Scénario: API invalide cache Redis lors mise à jour jauge
Étant donné que les jauges de "user123" sont en cache Redis
Quand je POST /api/v1/listening-events qui modifie une jauge
Alors le cache Redis "user:user123:gauges" est supprimé
Et le prochain GET recharge depuis PostgreSQL
Et remet en cache avec nouvelles valeurs

View File

@@ -0,0 +1,204 @@
# language: fr
Fonctionnalité: Neutralisation des pénalités de skip pour abonnés
En tant que système de jauges d'intérêt
Je veux neutraliser les pénalités de skip pour les abonnés d'un créateur
Afin de reconnaître l'affinité globale malgré des skips ponctuels contextuels
Contexte:
Étant donné qu'un utilisateur existe avec les jauges suivantes:
| catégorie | niveau |
| Automobile | 45% |
| Voyage | 60% |
Et qu'un créateur "CreateurA" publie des contenus tagués "Automobile"
# Skip <10s - Utilisateur NON abonné
Scénario: Skip rapide <10s par non-abonné - Pénalité -0.5%
Étant donné que l'utilisateur n'est PAS abonné à "CreateurA"
Et qu'un contenu "Podcast Auto" de "CreateurA" est tagué "Automobile"
Et que la jauge "Automobile" est à 45%
Quand l'utilisateur skip le contenu après 5 secondes
Alors la jauge "Automobile" descend de -0.5%
Et la jauge "Automobile" passe de 45% à 44.5%
Et cela indique un désintérêt marqué pour ce contenu
Scénario: Skip rapide <10s par non-abonné - Colonne is_subscribed=false
Étant donné que l'utilisateur n'est PAS abonné à "CreateurA"
Et qu'un contenu est skippé après 8 secondes
Quand l'événement est enregistré dans user_listening_history
Alors la colonne is_subscribed = false
Et la colonne completion_rate = 0.05 (8s sur 160s)
Et la colonne source = "recommendation"
Et ce skip compte dans les métriques d'engagement du contenu
# Skip <10s - Utilisateur ABONNÉ
Scénario: Skip rapide <10s par abonné - Pénalité neutre 0%
Étant donné que l'utilisateur EST abonné à "CreateurA"
Et qu'un contenu "Podcast Auto" de "CreateurA" est tagué "Automobile"
Et que la jauge "Automobile" est à 45%
Quand l'utilisateur skip le contenu après 5 secondes
Alors la jauge "Automobile" reste à 45% (pénalité 0%)
Et aucune pénalité n'est appliquée
Et cela reflète que l'abonnement indique une affinité globale
Scénario: Skip rapide <10s par abonné - Colonne is_subscribed=true
Étant donné que l'utilisateur EST abonné à "CreateurA"
Et qu'un contenu est skippé après 7 secondes
Quand l'événement est enregistré dans user_listening_history
Alors la colonne is_subscribed = true
Et la colonne completion_rate = 0.04 (7s sur 180s)
Et la colonne source = "recommendation"
Et ce skip NE compte PAS dans les métriques d'engagement du contenu
# Calcul métriques engagement créateur
Scénario: Métriques engagement - Skip d'abonné ne pénalise pas
Étant donné qu'un contenu "Podcast A" de "CreateurA" a reçu:
| utilisateur | abonné ? | action | source | completion_rate |
| User1 | Non | Skip <10s | recommendation | 0.05 |
| User2 | Oui | Skip <10s | recommendation | 0.04 |
| User3 | Non | Écoute complète | recommendation | 0.90 |
| User4 | Oui | Skip <10s | recommendation | 0.03 |
| User5 | Non | Écoute partielle | recommendation | 0.60 |
Quand le système calcule les métriques d'engagement du contenu
Alors les écoutes pertinentes comptabilisées sont:
| utilisateur | comptabilisé ? | raison |
| User1 | Oui | Non-abonné, source pertinente |
| User2 | Non | Abonné, skip contextuel |
| User3 | Oui | Non-abonné, source pertinente |
| User4 | Non | Abonné, skip contextuel |
| User5 | Oui | Non-abonné, source pertinente |
Et le total écoutes pertinentes = 3 (User1, User3, User5)
Et le taux de complétion = 2 complètes (User3, User5 >80%) / 3 = 66.7%
# Sources d'écoute et neutralisation
Scénario: Skip d'abonné via "recommendation" - Ne compte pas
Étant donné que l'utilisateur EST abonné à "CreateurA"
Et qu'un contenu est recommandé via l'algorithme (source="recommendation")
Quand l'utilisateur skip après 5s
Alors l'écoute NE compte PAS dans "total écoutes" pour métriques
Et la pénalité jauge est neutralisée (0%)
Scénario: Skip d'abonné via "live_notification" - Ne compte pas
Étant donné que l'utilisateur EST abonné à "CreateurA"
Et que "CreateurA" publie un contenu en live
Et qu'une notification live est envoyée (source="live_notification")
Quand l'utilisateur skip après 6s
Alors l'écoute NE compte PAS dans "total écoutes" pour métriques
Et la pénalité jauge est neutralisée (0%)
Scénario: Skip d'abonné via "search" - Ne compte pas (indépendamment abonnement)
Étant donné que l'utilisateur EST abonné à "CreateurA"
Et qu'il trouve un contenu via la recherche (source="search")
Quand l'utilisateur skip après 4s
Alors l'écoute NE compte PAS dans "total écoutes" (source non pertinente)
Et la pénalité jauge est neutralisée (0%)
Et cela s'applique à tous users (abonnés ou non) pour source="search"
Scénario: Skip non-abonné via "direct_link" - Ne compte pas
Étant donné que l'utilisateur N'est PAS abonné à "CreateurA"
Et qu'il clique sur un lien direct partagé (source="direct_link")
Quand l'utilisateur skip après 3s
Alors l'écoute NE compte PAS dans "total écoutes" (source non pertinente)
Mais la pénalité jauge s'applique quand même (-0.5%) pour non-abonné
# Reproposition
Scénario: Skip <10s non-abonné - Pas de reproposition
Étant donné que l'utilisateur N'est PAS abonné à "CreateurA"
Et qu'un contenu est skippé après 8 secondes
Quand l'algorithme calcule les prochaines recommandations
Alors ce contenu n'est jamais reproposé à cet utilisateur
Car c'est un signal négatif clair de désintérêt
Scénario: Skip <10s abonné - Reproposition possible
Étant donné que l'utilisateur EST abonné à "CreateurA"
Et qu'un contenu est skippé après 7 secondes
Quand l'algorithme calcule les prochaines recommandations plusieurs jours plus tard
Alors ce contenu PEUT être reproposé à cet utilisateur
Car l'abonnement indique une affinité globale
Et le skip peut être contextuel ("pas maintenant", "pas ce sujet")
Scénario: Stockage is_subscribed dans user_content_history
Étant donné qu'un utilisateur EST abonné à "CreateurA" au moment de l'écoute
Quand un contenu de "CreateurA" est écouté/skippé
Alors la table user_content_history enregistre:
| colonne | valeur |
| user_id | 123 |
| content_id | 456 |
| creator_id | 789 (CreateurA) |
| is_subscribed | true |
| completion_rate| 0.05 |
| source | "recommendation" |
| listened_at | 2026-02-07 10:30:00 |
# Cohérence UX
Scénario: Abonnement = Signal affinité fort malgré skip ponctuel
Étant donné que l'utilisateur est abonné à "CreateurA" depuis 6 mois
Et qu'il a écouté 50 contenus de "CreateurA" avec 90% de complétion moyenne
Quand il skip 1 contenu après 5 secondes aujourd'hui
Alors ce skip ponctuel ne pénalise pas:
| aspect | impact |
| Jauges d'intérêt user | 0% (neutre) |
| Métriques engagement créateur | Ne compte pas dans total écoutes |
| Reproposition future | Contenu peut être reproposé |
Et cela reflète que le skip est contextuel, pas un rejet du créateur
# Anti-raid naturel
Scénario: Raid malveillant via liens directs - Inefficace
Étant donné qu'un groupe malveillant veut nuire à "CreateurA"
Et qu'ils partagent des liens directs pour inciter au skip massif
Quand 1000 personnes cliquent sur le lien et skip après 2s
Alors ces 1000 skips NE comptent PAS dans les métriques engagement
Car source="direct_link" n'est pas une source pertinente
Et "CreateurA" est protégé contre ce type de raid
Scénario: Raid malveillant via recherche - Inefficace
Étant donné qu'un groupe cherche à nuire à "CreateurA"
Et qu'ils trouvent le contenu via recherche et skip massivement
Quand 500 skips rapides arrivent via source="search"
Alors ces 500 skips NE comptent PAS dans les métriques engagement
Car source="search" n'est pas une source pertinente
Et "CreateurA" est protégé
# Cas limites
Scénario: Utilisateur s'abonne pendant l'écoute d'un contenu
Étant donné qu'un utilisateur N'est PAS abonné à "CreateurA"
Et qu'il démarre l'écoute d'un contenu de "CreateurA"
Et que is_subscribed=false est enregistré au démarrage
Quand l'utilisateur s'abonne à "CreateurA" pendant l'écoute
Et qu'il skip le contenu après 8 secondes
Alors is_subscribed=false reste enregistré (état au moment du démarrage)
Et la pénalité -0.5% s'applique (car non-abonné au démarrage)
Scénario: Utilisateur se désabonne puis écoute ancien contenu
Étant donné qu'un utilisateur ÉTAIT abonné à "CreateurA"
Et qu'il se désabonne aujourd'hui
Quand il écoute un ancien contenu de "CreateurA" demain
Et qu'il skip après 6 secondes
Alors is_subscribed=false (état au moment de l'écoute)
Et la pénalité -0.5% s'applique
Et l'écoute compte dans les métriques d'engagement
# Comparaison tableaux sources
Scénario: Table récapitulative sources et abonnements
Étant donné les règles de comptabilisation définies
Quand on résume le comportement par source et abonnement
Alors le tableau complet est:
| Source | Abonné ? | Skip <10s pénalise ? | Compte "total écoutes" ? |
| recommendation | Non | Oui (-0.5%) | Oui |
| recommendation | Oui | Non (0%) | Non |
| search | Peu imp. | Variable* | Non |
| direct_link | Peu imp. | Variable* | Non |
| profile | Peu imp. | Variable* | Non |
| history | Peu imp. | Variable* | Non |
| live_notification | Non | Oui (-0.5%) | Oui |
| live_notification | Oui | Non (0%) | Non |
| audio_guide | Peu imp. | Non | Non |
(* Variable = -0.5% si non-abonné, 0% si abonné, mais source non pertinente donc pas dans métriques)

View File

@@ -0,0 +1,95 @@
# language: fr
@ui @search @filters @mvp
Fonctionnalité: Filtres avancés de recherche
En tant qu'utilisateur
Je veux filtrer les résultats de recherche
Afin de trouver précisément le contenu qui m'intéresse
Scénario: Filtres de base toujours visibles
Étant donné un utilisateur sur la page de recherche
Quand il consulte les filtres
Alors il voit les filtres de base:
| Filtre | Options |
| Catégorie | Tourisme, Culture, Gastronomie, etc. |
| Durée | < 30min, 30min-1h, 1h-2h, 2h+ |
| Prix | Gratuit, Payant |
| Note | 4+ étoiles, 3+ étoiles |
| Distance | < 5km, 5-10km, 10-50km, 50km+ |
Et un événement "SEARCH_FILTERS_DISPLAYED" est enregistré
Scénario: Filtres avancés dépliables
Étant donné un utilisateur qui clique sur "Filtres avancés"
Alors des filtres supplémentaires apparaissent:
| Filtre | Options |
| Langue | Français, Anglais, etc. |
| Accessibilité PMR | Oui / Non |
| Mode de déplacement | Piéton, Voiture, Vélo |
| Créateur vérifié | Oui / Non |
| Date de publication | Dernière semaine, mois, année |
| Nombre de séquences | 1-5, 6-10, 11-20, 20+ |
Et un événement "ADVANCED_FILTERS_EXPANDED" est enregistré
Scénario: Application des filtres en temps réel
Étant donné un utilisateur qui sélectionne:
| Filtre | Valeur choisie |
| Catégorie | Tourisme |
| Durée | 1h-2h |
| Distance | < 10km |
Quand il applique les filtres
Alors les résultats se mettent à jour instantanément (< 500ms)
Et le compteur affiche: "23 résultats trouvés"
Et un événement "SEARCH_FILTERS_APPLIED" est enregistré
Scénario: Sauvegarde des filtres préférés
Étant donné un utilisateur "alice@roadwave.fr" connecté
Quand elle configure des filtres spécifiques
Et clique sur "Sauvegarder ces filtres"
Alors les filtres sont sauvegardés dans son profil
Et automatiquement appliqués à sa prochaine recherche
Et un événement "SEARCH_FILTERS_SAVED" est enregistré
Scénario: Suggestions de filtres intelligentes
Étant donné un utilisateur qui recherche "Louvre"
Quand les résultats s'affichent
Alors des filtres suggérés apparaissent:
"Peut aussi vous intéresser: Musées à Paris, Art classique"
Et un clic applique automatiquement ces filtres
Et un événement "SMART_FILTERS_SUGGESTED" est enregistré
Scénario: Compteur de résultats par filtre
Étant donné un utilisateur qui survole un filtre
Alors un badge affiche le nombre de résultats:
| Filtre | Badge |
| Tourisme | (45) |
| Culture | (23) |
| Gastronomie | (12) |
| Gratuit | (34) |
| Payant | (28) |
Et aide à la décision de filtrage
Et un événement "FILTER_COUNTS_DISPLAYED" est enregistré
Scénario: Réinitialisation des filtres
Étant donné un utilisateur avec plusieurs filtres actifs
Quand il clique sur "Réinitialiser les filtres"
Alors tous les filtres sont désactivés
Et tous les résultats sont affichés
Et un événement "SEARCH_FILTERS_RESET" est enregistré
Scénario: Filtres persistants dans l'URL
Étant donné un utilisateur qui applique des filtres
Quand l'URL se met à jour
Alors elle contient: /search?category=tourisme&duration=1-2h&distance=10km
Et le lien peut être partagé avec les filtres actifs
Et un événement "SEARCH_URL_UPDATED_WITH_FILTERS" est enregistré
Scénario: Métriques d'utilisation des filtres
Étant donné que 10 000 recherches ont été effectuées
Alors les indicateurs suivants sont disponibles:
| Métrique | Valeur |
| % d'utilisateurs utilisant filtres| 68% |
| Nombre moyen de filtres/recherche | 2.3 |
| Filtre le plus utilisé | Distance|
| Filtre le moins utilisé | PMR |
Et les métriques sont exportées vers le monitoring

View File

@@ -0,0 +1,134 @@
# language: fr
@ui @search @map @mvp
Fonctionnalité: Page de résultats avec carte interactive
En tant qu'utilisateur
Je veux visualiser les résultats sur une carte
Afin de choisir des contenus proches de ma position ou d'une zone
Scénario: Affichage par défaut en mode liste + carte
Étant donné un utilisateur qui effectue une recherche
Quand les résultats s'affichent
Alors l'écran est divisé en 2 parties:
| Section | Largeur | Contenu |
| Liste | 40% | Résultats scrollables |
| Carte | 60% | Marqueurs des résultats |
Et la carte est synchronisée avec la liste
Et un événement "SEARCH_RESULTS_MAP_VIEW" est enregistré
Scénario: Bascule entre vue liste, carte, et mixte
Étant donné un utilisateur sur la page de résultats
Quand il clique sur les boutons de vue:
| Bouton | Vue résultante |
| [Liste] | Liste 100%, carte masquée |
| [Carte] | Carte 100%, liste masquée |
| [Mixte] | Liste 40% + Carte 60% |
Alors la vue change instantanément
Et la préférence est sauvegardée
Et un événement "SEARCH_VIEW_MODE_CHANGED" est enregistré
Scénario: Marqueurs groupés par zone (clustering)
Étant donné 50 résultats dans une zone de 10km
Quand la carte est affichée en zoom large
Alors les marqueurs sont regroupés en clusters:
| Cluster | Nombre de contenus |
| Paris 1 | 15 |
| Paris 2 | 12 |
| Paris 5 | 23 |
Et un clic sur un cluster zoome sur la zone
Et un événement "MAP_CLUSTERING_DISPLAYED" est enregistré
Scénario: Survol d'un marqueur affiche une preview
Étant donné un utilisateur qui survole un marqueur sur la carte
Alors une popup s'affiche avec:
| Élément | Contenu |
| Image miniature | Photo de couverture |
| Titre | Visite du Quartier Latin |
| Durée | 2h 30min |
| Note | 4.8/5 (1,234 avis) |
| Prix | Gratuit |
| Bouton | [Voir détails] |
Et un événement "MAP_MARKER_PREVIEW_SHOWN" est enregistré
Scénario: Clic sur un marqueur ouvre la fiche
Étant donné un utilisateur qui clique sur un marqueur
Alors la fiche complète du contenu s'ouvre en modal
Et la carte reste visible en arrière-plan
Et un événement "MAP_MARKER_CLICKED" est enregistré
Scénario: Synchronisation liste-carte bidirectionnelle
Étant donné un utilisateur en vue mixte (liste + carte)
Quand il scroll dans la liste
Alors la carte se centre automatiquement sur les contenus visibles
Et inversement, quand il déplace la carte
Alors la liste affiche les contenus de la zone visible
Et un événement "LIST_MAP_SYNC" est enregistré
Scénario: Recherche par zone dessinée sur la carte
Étant donné un utilisateur qui clique sur "Dessiner une zone"
Quand il dessine un polygone sur la carte
Alors seuls les contenus dans ce polygone sont affichés
Et le filtre "Zone personnalisée" s'active
Et un événement "MAP_CUSTOM_ZONE_DRAWN" est enregistré
Scénario: Calcul d'itinéraire depuis la carte
Étant donné un utilisateur qui clique sur un marqueur
Quand il clique sur "Itinéraire"
Alors un calcul d'itinéraire démarre depuis sa position
Et s'affiche sur la carte avec:
| Information | Exemple |
| Distance | 3.2 km |
| Temps piéton | 40 min |
| Temps voiture | 12 min |
| Temps vélo | 18 min |
Et un événement "MAP_ROUTE_CALCULATED" est enregistré
Scénario: Sauvegarde des contenus depuis la carte
Étant donné un utilisateur qui consulte la carte
Quand il clique sur l'icône "" d'un marqueur
Alors le contenu est ajouté à ses favoris
Et le marqueur change de couleur (rouge)
Et un événement "CONTENT_SAVED_FROM_MAP" est enregistré
Scénario: Affichage de la position utilisateur en temps réel
Étant donné un utilisateur avec géolocalisation activée
Quand il consulte la carte
Alors sa position est affichée par un point bleu
Et se met à jour en temps réel si il se déplace
Et un cercle indique la précision GPS (±10m)
Et un événement "USER_LOCATION_TRACKED_ON_MAP" est enregistré
Scénario: Légende de la carte avec codes couleur
Étant donné un utilisateur sur la carte
Alors une légende affiche:
| Couleur | Signification |
| Vert | Gratuit |
| Bleu | Payant |
| Or | Créateur vérifié |
| Rouge | Favoris |
Et un événement "MAP_LEGEND_DISPLAYED" est enregistré
Scénario: Export de la carte en image
Étant donné un utilisateur qui clique sur "Exporter la carte"
Alors une image PNG de la carte actuelle est générée
Et téléchargeable avec résultats visibles
Et un événement "MAP_EXPORTED" est enregistré
Scénario: Mode hors ligne de la carte
Étant donné un utilisateur qui télécharge une zone
Quand il active le mode hors ligne
Alors les tuiles de carte sont disponibles localement
Et les contenus téléchargés sont accessibles
Et un événement "MAP_OFFLINE_MODE_ENABLED" est enregistré
Scénario: Métriques d'utilisation de la carte
Étant donné que 10 000 utilisateurs ont consulté la carte
Alors les indicateurs suivants sont disponibles:
| Métrique | Valeur |
| % d'utilisations en mode carte | 42% |
| % d'utilisations en mode mixte | 48% |
| % d'utilisations en mode liste | 10% |
| Nombre moyen de clics sur marqueurs| 3.2 |
| Taux de conversion depuis carte | 18% |
Et les métriques sont exportées vers le monitoring

View File

@@ -0,0 +1,472 @@
# language: fr
Fonctionnalité: Recherche de contenu
En tant qu'utilisateur de RoadWave
Je veux rechercher des contenus audio par mots-clés, localisation et filtres
Afin de trouver facilement le contenu qui m'intéresse
Contexte:
Étant donné que l'application RoadWave est démarrée
Et que l'utilisateur "jean@example.com" est connecté
# 15.3.1 - Recherche par mot-clé
Scénario: Recherche full-text basique
Étant donné que la base contient les contenus suivants:
| titre | description | créateur |
| Balade à Paris | Visite du quartier Latin | @paris_stories |
| Secrets de Montmartre | Histoire de la butte | @explore_paris |
| Voyage en Normandie | Découverte des plages | @voyages_fr |
Quand l'utilisateur recherche "paris"
Alors 2 résultats sont retournés
Et les résultats incluent "Balade à Paris"
Et les résultats incluent "Secrets de Montmartre"
Scénario: Recherche avec stemming français
Étant donné un contenu avec le titre "Voyage en Bretagne"
Quand l'utilisateur recherche "voyages"
Alors le contenu "Voyage en Bretagne" est trouvé
Et le stemming a transformé "voyages" en racine "voyag"
Plan du Scénario: Stemming français sur différentes formes
Étant donné un contenu avec le mot "<mot_original>"
Quand l'utilisateur recherche "<recherche>"
Alors le contenu est trouvé grâce au stemming français
Exemples:
| mot_original | recherche |
| voyage | voyages |
| voyager | voyage |
| balades | balade |
| historique | histoire |
Scénario: Recherche avec accents ignorés
Étant donné un contenu avec le titre "Découverte de l'Élysée"
Quand l'utilisateur recherche "decouverte elysee"
Alors le contenu est trouvé
Et les accents sont normalisés automatiquement
Scénario: Champs indexés avec pondération
Étant donné les contenus suivants:
| titre | description | créateur | tags |
| Voyage Paris | Balade sympa | @user1 | Tourisme |
| Balade Lyon | Voyage en ville | @paris_guide | Voyage |
Quand l'utilisateur recherche "paris"
Alors "Voyage Paris" est en première position
Parce que le titre a un poids × 3
Et "@paris_guide" apparaît en second
Parce que le créateur a un poids × 2
Scénario: Ranking par pertinence et popularité
Étant donné les contenus suivants:
| titre | écoutes | rang_texte |
| Balade Paris | 50000 | 0.8 |
| Paris la nuit | 1000 | 0.9 |
Quand l'utilisateur recherche "paris"
Alors le score final combine rang_texte × (1 + log(écoutes + 1))
Et "Balade Paris" est mieux classé grâce à sa popularité
Scénario: Autocomplete pendant la frappe
Étant donné que l'utilisateur commence à taper "par"
Quand 3 caractères sont saisis
Alors des suggestions apparaissent:
| suggestion |
| paris |
| parc naturel |
| parvis notre-dame |
Et le top 5 des suggestions est affiché
Scénario: Historique des 10 dernières recherches
Étant donné que l'utilisateur a effectué les recherches suivantes:
| recherche | date |
| voyage paris | 2026-01-20 |
| audio-guide louvre | 2026-01-19 |
| podcast automobile | 2026-01-18 |
Quand l'utilisateur ouvre la barre de recherche
Alors les 10 dernières recherches sont affichées
Et elles sont triées par date décroissante
Scénario: Correction automatique si aucun résultat
Étant donné que l'utilisateur recherche "ballade paris" (faute d'orthographe)
Et qu'aucun résultat n'est trouvé
Quand la page de résultats s'affiche
Alors une suggestion "Essayez plutôt : balade paris" est affichée
Scénario: Recherches populaires suggérées
Étant donné qu'aucun résultat n'est trouvé pour une recherche
Quand la page s'affiche
Alors des suggestions populaires sont affichées:
| suggestion |
| balade paris |
| audio-guide louvre |
| visite montmartre |
# 15.3.2 - Recherche géographique
Scénario: Saisie d'un lieu avec autocomplete
Étant donné que l'utilisateur ouvre le filtre "Lieu"
Quand il tape "Louv"
Alors Nominatim retourne des suggestions:
| suggestion | type |
| Musée du Louvre, Paris | monument |
| Louvres, Val-d'Oise | commune |
Scénario: Sélection d'un lieu et définition du rayon
Étant donné que l'utilisateur sélectionne "Paris, France"
Et que les coordonnées sont (48.8566, 2.3522)
Quand il définit un rayon de 50 km
Alors la recherche PostGIS utilise ST_DWithin avec 50000 mètres
Plan du Scénario: Recherche géographique avec différents rayons
Étant donné un contenu à 30 km de Paris
Quand l'utilisateur recherche autour de Paris avec un rayon de <rayon>
Alors le contenu est <résultat>
Exemples:
| rayon | résultat |
| 20 km | non trouvé |
| 50 km | trouvé |
| 100 km | trouvé |
Scénario: Utilisation de "Autour de moi" (GPS actuel)
Étant donné que l'utilisateur active le GPS
Et que sa position est (48.8566, 2.3522)
Quand il sélectionne "Autour de moi"
Alors la recherche utilise ses coordonnées GPS actuelles
Et un rayon par défaut de 10 km est appliqué
Scénario: Curseur de rayon avec limites
Étant donné que l'utilisateur ouvre le curseur de rayon
Quand il ajuste le curseur
Alors les valeurs disponibles vont de 5 km à 500 km
Et la valeur s'affiche en temps réel "50 km"
Scénario: Affichage de la distance dans les résultats
Étant donné une recherche géographique autour de Paris
Et un contenu à 2.3 km de distance
Quand les résultats sont affichés
Alors la distance "À 2.3 km" est indiquée pour chaque résultat
Plan du Scénario: Tri par proximité géographique
Étant donné des contenus à différentes distances de Paris:
| contenu | distance |
| Louvre Guide | 0.5 km |
| Tour Eiffel | 2.0 km |
| Versailles | 20 km |
Quand l'utilisateur trie par "Proximité"
Alors les résultats sont affichés dans l'ordre:
| position | contenu |
| 1 | Louvre Guide |
| 2 | Tour Eiffel |
| 3 | Versailles |
Scénario: Géocodage avec Nominatim (MVP)
Étant donné que l'application est en phase MVP
Quand une requête de géocodage est effectuée
Alors l'API publique Nominatim est utilisée
Et le rate limit de 1 req/s est respecté
Scénario: Géocodage avec fallback Mapbox
Étant donné que Nominatim ne retourne aucun résultat
Quand l'application tente un fallback
Alors l'API Mapbox Geocoding est utilisée
Et le coût de 0.50 / 1000 requêtes est appliqué
# 15.3.3 - Filtres avancés
Scénario: Ouverture du panneau de filtres
Étant donné que l'utilisateur est sur la page de recherche
Quand il clique sur "Filtres"
Alors un panneau latéral s'ouvre
Et 7 catégories de filtres sont affichées:
| catégorie |
| Type de contenu |
| Durée |
| Classification âge |
| Géo-pertinence |
| Tags |
| Date de publication |
| Abonnement |
Scénario: Filtre par type de contenu (multi-sélection)
Étant donné que l'utilisateur ouvre les filtres
Quand il sélectionne:
| type |
| Contenu court |
| Audio-guide |
Alors seuls ces types de contenus sont recherchés
Et les podcasts et radios live sont exclus
Plan du Scénario: Filtre par durée
Étant donné un contenu de <durée> minutes
Quand l'utilisateur filtre par "<tranche>"
Alors le contenu est <résultat>
Exemples:
| durée | tranche | résultat |
| 3 | <5 min | trouvé |
| 3 | 5-15 min | non trouvé |
| 10 | 5-15 min | trouvé |
| 20 | 15-30 min | trouvé |
| 45 | >30 min | trouvé |
Scénario: Filtre par classification âge
Étant donné des contenus avec différentes classifications:
| contenu | classification |
| Conte enfants | Tout public |
| Podcast news | 13+ |
| Débat politique | 16+ |
Quand l'utilisateur filtre "Tout public"
Alors seul "Conte enfants" est affiché
Scénario: Filtre par géo-pertinence
Étant donné des contenus avec différents types géo:
| contenu | type_geo |
| Guide Louvre | Ancré |
| Podcast Paris | Contextuel |
| News nationales | Neutre |
Quand l'utilisateur filtre "Ancré, Contextuel"
Alors "Guide Louvre" et "Podcast Paris" sont affichés
Et "News nationales" est exclu
Scénario: Filtre par tags (multi-sélection)
Étant donné des contenus taggés:
| contenu | tags |
| Voyage en Italie | Voyage, Gastronomie |
| Histoire de Rome | Voyage, Histoire |
| Économie italienne | Économie |
Quand l'utilisateur sélectionne les tags "Voyage, Histoire"
Alors "Histoire de Rome" est en priorité (2 tags correspondants)
Et "Voyage en Italie" est affiché (1 tag correspondant)
Et "Économie italienne" est exclu
Plan du Scénario: Filtre par date de publication
Étant donné un contenu publié il y a <délai>
Quand l'utilisateur filtre par "<période>"
Alors le contenu est <résultat>
Exemples:
| délai | période | résultat |
| 12 heures | Dernières 24h | trouvé |
| 3 jours | Cette semaine | trouvé |
| 15 jours | Ce mois | trouvé |
| 8 mois | Cette année | trouvé |
| 2 ans | Toutes dates | trouvé |
| 2 ans | Cette année | non trouvé |
Scénario: Filtre par type d'abonnement
Étant donné des contenus gratuits et Premium:
| contenu | type |
| Balade Paris | Gratuit |
| Visite VIP Louvre | Premium |
Quand l'utilisateur filtre "Premium uniquement 👑"
Alors seul "Visite VIP Louvre" est affiché
Scénario: Combinaison de filtres multiples (AND logic)
Étant donné que l'utilisateur applique les filtres:
| filtre | valeur |
| Type | Audio-guide |
| Durée | 5-15 min |
| Tags | Voyage |
| Classification | Tout public |
Quand la recherche est lancée
Alors seuls les contenus respectant TOUS les critères sont affichés
Scénario: Réinitialisation des filtres
Étant donné que l'utilisateur a appliqué 5 filtres différents
Quand il clique sur "Réinitialiser"
Alors tous les filtres sont désactivés
Et la recherche affiche tous les résultats
Scénario: Sauvegarde d'une recherche
Étant donné que l'utilisateur a appliqué plusieurs filtres
Quand il clique sur "💾 Sauvegarder cette recherche"
Et qu'il entre le nom "Podcasts voyage Paris"
Alors la recherche est sauvegardée
Et elle apparaît dans l'onglet "Recherches sauvegardées"
Scénario: Limite de 5 recherches sauvegardées
Étant donné que l'utilisateur a déjà 5 recherches sauvegardées
Quand il tente de sauvegarder une 6ème recherche
Alors un message d'erreur s'affiche
Et il doit supprimer une recherche existante avant d'en ajouter une nouvelle
Scénario: Notifications pour recherches sauvegardées
Étant donné une recherche sauvegardée "Podcasts voyage Paris"
Et que l'utilisateur a activé les notifications
Quand 3 nouveaux contenus correspondants sont publiés
Alors une notification "3 nouveaux contenus dans 'Podcasts voyage Paris'" est envoyée
Plan du Scénario: Options de tri des résultats
Étant donné une recherche avec plusieurs résultats
Quand l'utilisateur sélectionne le tri "<option>"
Alors les résultats sont triés selon <algorithme>
Exemples:
| option | algorithme |
| Pertinence | Score recherche × (1 + log(écoutes + 1)) |
| Popularité | Écoutes complètes derniers 30j DESC |
| Récent | Date publication DESC |
| Proximité | Distance GPS ASC (si recherche géo) |
| Durée | Durée audio ASC ou DESC |
# 15.3.4 - Page de résultats
Scénario: Structure d'un résultat de recherche
Étant donné un résultat de recherche
Quand la page est affichée
Alors chaque résultat contient:
| élément | exemple |
| Cover image | 120×68 px (16:9) |
| Titre | Balade à Paris (2 lignes max) |
| Créateur | @paris_stories |
| Durée | 12 min |
| Écoutes | 🎧 2.3K |
| Localisation | 📍 Paris 5e · Ancré |
| Tags | 🏷 #Voyage #Histoire |
| Badge Premium | 👑 (si applicable) |
| Distance | À 2.3 km (si recherche géo) |
| Bouton lecture | Écouter |
| Menu contextuel | |
Scénario: Lazy loading des images
Étant donné une page avec 20 résultats de recherche
Quand la page se charge
Alors seules les 5 premières images sont chargées
Et les images suivantes se chargent au scroll
Scénario: Troncature du titre sur 2 lignes maximum
Étant donné un contenu avec un titre de 120 caractères
Quand le résultat est affiché
Alors le titre est tronqué après 2 lignes
Et "..." est ajouté à la fin
Scénario: Lien cliquable vers le profil créateur
Étant donné un résultat de recherche pour "@paris_stories"
Quand l'utilisateur clique sur "@paris_stories"
Alors il est redirigé vers "https://roadwave.fr/@paris_stories"
Scénario: Menu contextuel d'un résultat [⋮]
Étant donné que l'utilisateur clique sur [] pour un résultat
Quand le menu s'ouvre
Alors les actions suivantes sont disponibles:
| action |
| Partager |
| Ajouter à une playlist |
| Télécharger (offline) |
| Signaler |
Scénario: Pagination avec 20 résultats par page
Étant donné une recherche retournant 100 résultats
Quand la page est affichée
Alors 20 résultats sont chargés initialement
Et un indicateur "1-20 sur 100 résultats" est visible
Scénario: Infinite scroll automatique
Étant donné que l'utilisateur scroll dans les résultats
Quand il atteint 80% de la page
Alors les 20 résultats suivants sont chargés automatiquement
Et un loader est affiché pendant le chargement
Scénario: Bouton fallback "Charger 20 suivants"
Étant donné que l'infinite scroll est désactivé (paramètres)
Quand l'utilisateur atteint la fin de la page
Alors un bouton "Charger 20 suivants" est affiché
Et les résultats se chargent au clic
# Vue carte
Scénario: Basculement entre vue liste et vue carte
Étant donné que l'utilisateur est sur la page de résultats
Quand il clique sur le toggle "Liste / Carte"
Alors la vue carte Leaflet s'affiche
Et les résultats sont affichés comme markers sur la carte
Scénario: Affichage de la carte Leaflet
Étant donné que la vue carte est activée
Quand la carte se charge
Alors la carte utilise les tuiles OpenStreetMap
Et le centre est la position de recherche (ou GPS utilisateur)
Et le zoom initial montre tous les résultats
Scénario: Markers cliquables sur la carte
Étant donné que 10 résultats sont affichés sur la carte
Quand l'utilisateur clique sur un marker
Alors une popup s'affiche avec:
| élément |
| Titre |
| Créateur |
| Durée |
| Distance |
| Bouton Écouter|
Scénario: Clustering des markers proches
Étant donné que 50 résultats sont très proches géographiquement
Quand la carte est affichée
Alors les markers proches sont groupés en clusters
Et le nombre de contenus est affiché sur le cluster
Et le cluster se décompose au zoom
Scénario: Synchronisation liste / carte
Étant donné que l'utilisateur est en vue carte
Quand il clique sur un marker et écoute le contenu
Et qu'il rebascule en vue liste
Alors le contenu écouté est marqué dans la liste
Et la position de scroll est maintenue
# Performances et index
Scénario: Index PostgreSQL full-text pour performances
Étant donné que la base contient 100K contenus
Quand une recherche full-text est effectuée
Alors l'index GIN sur to_tsvector est utilisé
Et la requête retourne en moins de 100ms
Scénario: Index PostGIS GIST pour recherche géo
Étant donné une recherche géographique avec rayon 50 km
Quand la requête PostGIS ST_DWithin est exécutée
Alors l'index GIST sur la colonne location est utilisé
Et la requête retourne en moins de 50ms
Scénario: Index composites pour filtres
Étant donné une recherche avec filtres multiples
Quand les filtres type, durée, âge, géo, date sont appliqués
Alors l'index composite idx_content_filters est utilisé
Et les performances restent optimales
Scénario: Index GIN pour recherche par tags
Étant donné une recherche filtrée par tags "Voyage, Histoire"
Quand la requête est exécutée
Alors l'index GIN sur la colonne tags est utilisé
Et la recherche est performante même avec 500K contenus
# Cas d'erreur
Scénario: Aucun résultat trouvé
Étant donné que l'utilisateur recherche "xyzabc123"
Quand aucun résultat n'est trouvé
Alors un message "Aucun résultat pour 'xyzabc123'" s'affiche
Et des suggestions de recherches populaires sont proposées
Scénario: Recherche vide
Étant donné que l'utilisateur clique sur "Rechercher" sans saisir de texte
Quand la recherche est lancée
Alors un message "Veuillez entrer au moins 2 caractères" s'affiche
Scénario: Erreur de géocodage Nominatim
Étant donné que l'API Nominatim est indisponible
Quand l'utilisateur tente une recherche géographique
Alors un message "Service de localisation temporairement indisponible" s'affiche
Et la recherche continue sans filtre géographique
Scénario: GPS désactivé pour "Autour de moi"
Étant donné que l'utilisateur a désactivé le GPS
Quand il sélectionne "Autour de moi"
Alors un message "Veuillez activer la localisation" s'affiche
Et un bouton "Activer" ouvre les paramètres système
Scénario: Timeout de recherche après 10 secondes
Étant donné qu'une recherche complexe est lancée
Quand la requête dépasse 10 secondes
Alors la recherche est annulée
Et un message "La recherche a pris trop de temps, veuillez réessayer" s'affiche

View File

@@ -0,0 +1,240 @@
# Tests Gherkin - Algorithme de Recommandation
Tests BDD pour la section [04-algorithme-recommandation.md](../rules/algorithme-recommandation.md)
## Fichiers de tests
### [classification-geo.feature](classification-geo.feature)
**Couverture** : Section 2.1 des règles métier
- ✅ Classification 3 types (Géo-ancré 70%, Géo-contextuel 50%, Géo-neutre 20%)
- ✅ Choix par créateur
- ✅ Reclassification par modérateur
- ✅ Modification après publication
- ✅ Impact sur pondération algorithme
**Scénarios** : 11
---
### [scoring-recommandation.feature](scoring-recommandation.feature)
**Couverture** : Sections 2.2, 2.3, 2.4 des règles métier
- ✅ Calcul score géographique linéaire (1 - distance/200km)
- ✅ Calcul score d'intérêts (moyenne jauges tags)
- ✅ Calcul score engagement (complétion 50%, likes 30%, abonnements 20%)
- ✅ Seuil minimum 50 écoutes
- ✅ Score final combiné selon type contenu
- ✅ Bonus aléatoire 10% configurable
- ✅ Contenu viral peut être recommandé loin
- ✅ Pré-calcul 5 contenus suivants
- ✅ Recalcul si >10 km ou >10 min
**Scénarios** : 23
---
### [contenu-politique.feature](contenu-politique.feature)
**Couverture** : Section 2.5 des règles métier (MVP simplifié)
- ✅ Tag simple "Politique" sans classification gauche/droite
- ✅ Filtrage utilisateur "Masquer contenu politique"
- ✅ Par défaut tous contenus visibles
- ✅ Mode Kids filtre automatiquement le politique
- ✅ Pas d'équilibrage imposé en MVP
**Scénarios** : 13
---
### [mode-kids.feature](mode-kids.feature)
**Couverture** : Section 2.6 des règles métier
- ✅ Activation manuelle (pas automatique car âge min 13 ans)
- ✅ Filtrage contenus "Tous publics" uniquement
- ✅ Exclusion automatique contenu politique
- ✅ Pas de publicité (ou validée manuellement)
- ✅ Interface standard (pas d'UI enfant)
- ✅ Désactivation possible à tout moment
**Scénarios** : 15
---
### [declenchement-geo.feature](declenchement-geo.feature)
**Couverture** : Section 2.7 des règles métier
- ✅ Notification sonore + visuelle au passage <500m
- ✅ Délai réaction 5 secondes
- ✅ Pas d'interruption contenu en cours
- ✅ Logos différenciés (📍🏛️🍴🎭)
- ✅ Publicité uniquement entre contenus
- ✅ Gestion demi-tour (pas de répétition avant 24h)
- ✅ Rayon configurable par admin
**Scénarios** : 17
---
### [historique-reproposition.feature](historique-reproposition.feature)
**Couverture** : Section 2.8 des règles métier
- ✅ Contenu >80% jamais reproposé (sauf replayable=true)
- ✅ Contenu <10s ne pas reproposer (signal négatif)
- ✅ Contenu 10-80% reproposer avec reprise position
- ✅ Stockage illimité PostgreSQL
- ✅ Algorithme considère 100 derniers pour performance
- ✅ Export complet RGPD
**Scénarios** : 17
---
### [parametrabilite-admin.feature](parametrabilite-admin.feature)
**Couverture** : Section 2.9 des règles métier
- ✅ Dashboard admin avec tous paramètres configurables à chaud
- ✅ Validation plages de valeurs
- ✅ Aucun recalcul batch (économie CPU)
- ✅ Versioning configurations (git-like)
- ✅ Rollback 1 clic
- ✅ A/B testing avec split 50/50
- ✅ Métriques comparatives temps réel
- ✅ Graphiques évolution engagement
- ✅ Export CSV analyse externe
**Scénarios** : 17
---
### [parametrabilite-utilisateur.feature](parametrabilite-utilisateur.feature)
**Couverture** : Section 2.10 des règles métier
- ✅ 3 curseurs (Géolocalisation, Découverte, Politique)
- ✅ Profils sauvegardables (Trajet quotidien, Road trip, Enfants)
- ✅ Synchronisation multi-devices
- ✅ Auto-switch selon contexte GPS
- ✅ Blocage modification si vitesse >10 km/h
- ✅ Warning avant de prendre la route
- ✅ Limite 10 profils par utilisateur
**Scénarios** : 22
---
### [medias-traditionnels.feature](medias-traditionnels.feature)
**Couverture** : Section 2.11 des règles métier
- ✅ Compte média vérifié (badge ✓)
- ✅ Pas de validation 3 premiers contenus
- ✅ Modération a posteriori uniquement
- ✅ Formats: flash info, chroniques, éditos, reportages
- ✅ Classification âge obligatoire
- ✅ Monétisation standard (3€/1000 écoutes)
- ✅ Sponsoring direct autorisé
- ✅ Statistiques détaillées
**Scénarios** : 19
---
## Statistiques
| Métrique | Valeur |
|----------|--------|
| **Fichiers** | 9 |
| **Scénarios** | 154 |
| **Règles métier** | 100% couverture section 2 |
## Formules mathématiques testées
### Score géographique
```
score_geo = 1 - (distance_km / distance_max_km)
```
### Score intérêts
```
score_interets = moyenne(jauges_tags_correspondants)
```
### Score engagement
```
score_engagement = (taux_completion × 0.5) + (ratio_likes × 0.3) + (ratio_abonnements × 0.2)
```
### Score final
```
score_final = (score_geo × poids_geo_type)
+ (score_interets × poids_interets_type)
+ (score_engagement × 0.2)
+ bonus_aleatoire
```
## Paramètres par défaut
| Paramètre | Défaut | Plage |
|-----------|--------|-------|
| poids_geo_ancre | 0.7 | 0.5 - 1.0 |
| poids_geo_contextuel | 0.5 | 0.3 - 0.7 |
| poids_geo_neutre | 0.2 | 0.0 - 0.4 |
| poids_engagement | 0.2 | 0.0 - 0.5 |
| part_aleatoire_global | 0.1 | 0.0 - 0.3 |
| distance_max_km | 200 | 50 - 500 |
| rayon_gps_point_m | 500 | 100 - 2000 |
| seuil_min_ecoutes_engagement | 50 | 10 - 200 |
## Exécution des tests
### Tous les tests de recommandation
```bash
godog run features/recommendation/
```
### Un fichier spécifique
```bash
godog run features/recommendation/scoring-recommandation.feature
```
### Tests calculs mathématiques uniquement
```bash
godog run features/recommendation/scoring-recommandation.feature --tags=@calcul
```
## CI/CD
Ces tests sont exécutés :
- ✅ Avant chaque release
- ✅ Sur les PRs modifiant l'algorithme de recommandation
- ✅ Nightly builds (tous les tests)
## Implémentation des steps
Les steps definitions seront implémentées dans :
```
features/steps/recommendation_steps.go
```
Exemple avec calculs :
```go
func (s *RecommendationSteps) lalgorithmeCalculeLeScoreGeo(expectedScore float64) error {
actualScore := 1.0 - (s.distance / s.distanceMax)
if math.Abs(actualScore - expectedScore) > 0.01 {
return fmt.Errorf("score_geo: attendu %.2f, obtenu %.2f", expectedScore, actualScore)
}
return nil
}
```
## Prochaines étapes
1. ⏳ Implémenter les steps definitions en Go
2. ⏳ Tester les formules mathématiques avec valeurs edge cases
3. ⏳ Configurer Testcontainers pour PostgreSQL + PostGIS
4. ⏳ Tests de performance (calcul score pour 1000 contenus <100ms)
5. ⏳ Créer les Gherkin pour les 14 autres sections
---
**Statut** : ✅ Spécifications complètes
**Dernière mise à jour** : 2026-01-21

View File

@@ -0,0 +1,100 @@
# language: fr
Fonctionnalité: Classification de géo-pertinence des contenus
En tant que plateforme de contenu géolocalisé
Je veux classifier les contenus selon leur pertinence géographique
Afin d'adapter l'algorithme de recommandation
Contexte:
Étant donné que l'API RoadWave est disponible
Scénario: Créateur choisit le type géo-ancré pour un audio-guide
Étant donné que je suis un créateur connecté
Quand je publie un audio-guide de la Tour Eiffel
Et que je choisis la classification "Géo-ancré"
Alors le contenu est enregistré avec:
| champ | valeur |
| type_geo | geo_ancre |
| ponderation_geo | 0.7 |
| ponderation_interets | 0.1 |
Scénario: Créateur choisit le type géo-contextuel pour actualité régionale
Étant donné que je suis un créateur connecté
Quand je publie une actualité régionale en Bretagne
Et que je choisis la classification "Géo-contextuel"
Alors le contenu est enregistré avec:
| champ | valeur |
| type_geo | geo_contextuel |
| ponderation_geo | 0.5 |
| ponderation_interets | 0.3 |
Scénario: Créateur choisit le type géo-neutre pour un podcast philosophie
Étant donné que je suis un créateur connecté
Quand je publie un podcast de philosophie
Et que je choisis la classification "Géo-neutre"
Alors le contenu est enregistré avec:
| champ | valeur |
| type_geo | geo_neutre |
| ponderation_geo | 0.2 |
| ponderation_interets | 0.6 |
Scénario: Publication impossible sans classification géographique
Étant donné que je crée un contenu audio
Quand j'essaie de publier sans sélectionner de type géographique
Alors la publication échoue
Et je vois le message "Vous devez sélectionner un type de géo-pertinence"
Scénario: Modérateur reclassifie un contenu mal catégorisé
Étant donné qu'un contenu podcast générique est classifié "Géo-ancré"
Et que le modérateur examine le contenu
Quand le modérateur le reclassifie en "Géo-neutre"
Alors la nouvelle classification est appliquée immédiatement
Et l'algorithme utilise la pondération géo = 0.2
Et le créateur reçoit une notification de reclassification
Scénario: Créateur modifie la classification après publication
Étant donné que j'ai publié un contenu classifié "Géo-contextuel"
Et que je réalise qu'il devrait être "Géo-neutre"
Quand je modifie la classification en "Géo-neutre"
Alors la modification est enregistrée
Et l'algorithme utilise la nouvelle pondération
Et je vois le message "Classification modifiée avec succès"
Scénario: Statistiques de classification dans le profil créateur
Étant donné que je suis un créateur
Et que j'ai publié 30 contenus:
| type | nombre |
| Géo-ancré | 10 |
| Géo-contextuel | 15 |
| Géo-neutre | 5 |
Quand je consulte mes statistiques
Alors je vois la répartition de mes classifications
Et des suggestions pour optimiser la portée
Scénario: Contenu géo-ancré fortement pondéré par la proximité
Étant donné qu'un audio-guide "Géo-ancré" existe à la Tour Eiffel
Et qu'un utilisateur est à 100m de la Tour Eiffel
Quand l'algorithme calcule le score
Alors la pondération géo est de 0.7
Et le score géo est proche de 1 (très proche)
Et le contenu a un score final élevé
Scénario: Contenu géo-neutre moins sensible à la distance
Étant donné qu'un podcast philosophie "Géo-neutre" existe à Paris
Et qu'un utilisateur est à Marseille (750 km)
Quand l'algorithme calcule le score
Alors la pondération géo est de 0.2
Et le score géo est bas (distance élevée)
Mais le score intérêts (0.6) peut compenser
Et le contenu peut quand même être recommandé si intérêts match
Scénario: Comparaison scores entre types géo pour même distance
Étant donné 3 contenus au même endroit (Paris):
| type | ponderation_geo |
| Géo-ancré | 0.7 |
| Géo-contextuel | 0.5 |
| Géo-neutre | 0.2 |
Et qu'un utilisateur est à 50 km de Paris
Quand l'algorithme calcule les scores
Alors le contenu "Géo-ancré" a le score géo le plus élevé
Et le contenu "Géo-neutre" a le score géo le plus faible
Mais peut avoir un score final plus élevé si forte correspondance intérêts

View File

@@ -0,0 +1,100 @@
# language: fr
Fonctionnalité: Gestion du contenu politique (MVP simplifié)
En tant qu'utilisateur
Je veux pouvoir filtrer le contenu politique
Afin de contrôler mon exposition à ce type de contenu
Contexte:
Étant donné que l'API RoadWave est disponible
Scénario: Créateur tagge son contenu comme "Politique"
Étant donné que je suis un créateur connecté
Quand je publie un contenu sur un débat politique
Et que je sélectionne le tag "Politique"
Alors le contenu est enregistré avec le tag "Politique"
Et aucune classification gauche/droite n'est demandée (MVP)
Scénario: Tag "Politique" au même niveau que les autres tags
Étant donné que je crée un contenu
Quand je consulte la liste des tags disponibles
Alors je vois les tags suivants au même niveau:
| tag |
| Économie |
| Sport |
| Culture |
| Politique |
| Automobile |
| Voyage |
| Musique |
Scénario: Par défaut, tous les contenus politiques sont visibles
Étant donné que je suis un nouvel utilisateur
Et que je n'ai pas modifié les paramètres de contenu politique
Quand je demande des recommandations
Alors les contenus tagués "Politique" sont inclus normalement
Et aucun filtrage n'est appliqué
Scénario: Activer le filtrage "Masquer contenu politique"
Étant donné que je suis connecté
Quand j'active l'option "Masquer contenu politique" dans les paramètres
Alors tous les contenus tagués "Politique" sont exclus de mes recommandations
Et je vois le message "Contenu politique masqué"
Scénario: Filtrage politique actif - aucun contenu politique recommandé
Étant donné que j'ai activé "Masquer contenu politique"
Et qu'il existe 100 contenus dont 20 tagués "Politique"
Quand je demande 50 recommandations
Alors je reçois 50 contenus parmi les 80 non-politiques
Et 0% de contenus politiques sont proposés
Scénario: Désactiver le filtrage "Masquer contenu politique"
Étant donné que j'ai activé "Masquer contenu politique"
Quand je désactive cette option dans les paramètres
Alors les contenus politiques sont à nouveau inclus dans mes recommandations
Et le filtrage est levé immédiatement
Scénario: Mode Kids filtre automatiquement le contenu politique
Étant donné que je suis un utilisateur de 14 ans
Et que le mode Kids est activé
Quand je demande des recommandations
Alors tous les contenus tagués "Politique" sont automatiquement exclus
Et ce indépendamment du paramètre "Masquer contenu politique"
Scénario: Statistiques créateur sur contenu politique
Étant donné que je suis un créateur
Et que j'ai publié 20 contenus dont 5 tagués "Politique"
Quand je consulte mes statistiques
Alors je vois le nombre d'utilisateurs ayant masqué le contenu politique
Et le taux d'engagement comparé aux autres tags
Scénario: Recherche avec tag "Politique"
Étant donné que je recherche du contenu
Quand je filtre par tag "Politique"
Alors seuls les contenus tagués "Politique" sont affichés
Et ce même si j'ai activé "Masquer contenu politique" (recherche explicite)
Scénario: Partage de contenu politique avec filtre actif
Étant donné que j'ai activé "Masquer contenu politique"
Et qu'un ami me partage un lien vers un contenu tagué "Politique"
Quand j'ouvre le lien
Alors je peux accéder au contenu (partage explicite)
Et je vois un avertissement "Ce contenu est tagué Politique"
Scénario: Pas de classification gauche/droite en MVP
Étant donné que je suis un créateur
Quand je publie un contenu tagué "Politique"
Alors aucune option de classification idéologique n'est proposée
Et je ne peux pas indiquer "Gauche", "Droite", "Centre", etc.
Scénario: Pas d'équilibrage imposé en MVP
Étant donné qu'un utilisateur écoute majoritairement du contenu politique de gauche
Quand l'algorithme génère des recommandations
Alors aucun équilibrage droite/gauche n'est appliqué
Et les recommandations suivent l'algorithme standard (intérêts, géo, engagement)
Scénario: Notification post-MVP pour classification avancée
Étant donné que RoadWave passe en phase post-MVP
Et que la classification politique avancée est activée
Quand je me connecte
Alors je reçois une notification m'informant des nouvelles options
Et je peux configurer mes préférences d'équilibrage politique

View File

@@ -0,0 +1,327 @@
# language: fr
Fonctionnalité: Contenus géolocalisés en mode voiture
En tant qu'utilisateur en voiture
Je veux recevoir des notifications de contenus géolocalisés au bon moment
Afin de découvrir du contenu contextuel sans distraction au volant
Contexte:
Étant donné que l'API RoadWave est disponible
Et que l'application est ouverte (premier plan)
Et que le GPS est activé
Et que l'utilisateur est en mode voiture (vitesse 5 km/h)
# 17.2 - Détection et notification (Calcul ETA)
Scénario: Calcul ETA et notification 7 secondes avant le point GPS
Étant donné qu'un contenu géolocalisé existe à la Tour Eiffel (48.8584, 2.2945)
Et que je me déplace à 50 km/h vers ce point
Et que je suis à 98 mètres du point (ETA = 7 secondes)
Quand le système calcule l'ETA
Alors une notification est déclenchée immédiatement
Et le compteur "7" s'affiche avec l'icône 🏛
Et une notification sonore (bip court) est jouée
Plan du Scénario: Calcul ETA à différentes vitesses
Étant donné qu'un contenu géolocalisé existe à un point GPS
Et que je me déplace à <vitesse> km/h
Quand je suis à <distance> mètres du point
Alors l'ETA calculé est <eta> secondes
Et la notification est déclenchée : <notification>
Exemples:
| vitesse | distance | eta | notification |
| 10 | 19 | 7 | Oui |
| 50 | 98 | 7 | Oui |
| 130 | 252 | 7 | Oui |
| 50 | 200 | 14 | Non |
| 10 | 50 | 18 | Non |
Scénario: Notification immédiate si vitesse <5 km/h ET distance <50m
Étant donné qu'un contenu géolocalisé existe à 30m de ma position
Et que ma vitesse est 3 km/h (arrêté à un feu rouge)
Quand le système détecte cette situation
Alors une notification est déclenchée immédiatement
Et je n'ai pas besoin d'attendre le calcul ETA
# 17.2.2 - Format de notification minimaliste
Scénario: Notification minimaliste sans texte (sécurité routière)
Étant donné qu'une notification géolocalisée est déclenchée
Quand la notification s'affiche
Alors les éléments suivants sont visibles:
| élément | présent |
| Icône du tag | |
| Compteur 71 | |
| Son bref (bip) | |
| Titre texte | |
| Description | |
| Cover image | |
| Bouton Annuler | |
Plan du Scénario: Icônes selon le tag du contenu
Étant donné qu'un contenu géolocalisé avec le tag <tag> est disponible
Quand la notification s'affiche
Alors l'icône <icone> est affichée
Exemples:
| tag | icone |
| Culture générale | 🏛 |
| Histoire | 📜 |
| Voyage | |
| Famille | 👨👩👧 |
| Musique | 🎵 |
| Sport | |
| Technologie | 💻 |
| Automobile | 🚗 |
Scénario: Compteur décrémentant de 7 à 1
Étant donné qu'une notification géolocalisée s'affiche
Quand le compteur démarre
Alors le compteur affiche "7"
Et après 1 seconde, il affiche "6"
Et après 2 secondes, il affiche "5"
Et après 3 secondes, il affiche "4"
Et après 4 secondes, il affiche "3"
Et après 5 secondes, il affiche "2"
Et après 6 secondes, il affiche "1"
Et après 7 secondes, la notification disparaît
# 17.2.2b - Conformité CarPlay / Android Auto
Scénario: Notification sonore uniquement en mode CarPlay
Étant donné que l'application est connectée à CarPlay
Et qu'un contenu géolocalisé est détecté (ETA 7s)
Quand la notification est déclenchée
Alors seule la notification sonore (bip) est jouée
Et aucun overlay visuel n'est affiché (icône, compteur)
Et l'utilisateur peut valider via le bouton "Suivant" au volant
Scénario: Notification sonore uniquement en mode Android Auto
Étant donné que l'application est connectée à Android Auto
Et qu'un contenu géolocalisé est détecté (ETA 7s)
Quand la notification est déclenchée
Alors seule la notification sonore (bip) est jouée
Et aucun overlay visuel n'est affiché
Et l'utilisateur peut valider via le bouton "Suivant" au volant
Scénario: Notification complète (sonore + visuelle) en mode normal
Étant donné que l'application n'est PAS connectée à CarPlay/Android Auto
Et qu'un contenu géolocalisé est détecté
Quand la notification est déclenchée
Alors la notification sonore (bip) est jouée
Et l'overlay visuel s'affiche (icône + compteur 71)
# 17.2.3 - Décompte après validation
Scénario: Validation via bouton "Suivant" et décompte 5 secondes
Étant donné qu'une notification géolocalisée est affichée (compteur à 5)
Et que j'écoute un podcast
Quand j'appuie sur le bouton "Suivant"
Alors le compteur bascule à "5" (décompte final)
Et le contenu actuel continue de jouer normalement
Et le compteur décrémente: 54321
Et après 5 secondes, le contenu géolocalisé démarre (fade in 0.3s)
Scénario: Transition fluide avec fade out/in
Étant donné que le décompte atteint "0"
Quand le contenu géolocalisé doit démarrer
Alors le contenu actuel fait un fade out de 0.3s
Et le contenu géolocalisé fait un fade in de 0.3s
Et il n'y a pas de silence entre les deux
Scénario: Contenu actuel se termine pendant le décompte
Étant donné que j'ai validé la notification (décompte 5s démarre)
Et que mon contenu actuel se termine après 2 secondes
Quand le contenu actuel se termine
Alors le contenu suivant du buffer démarre immédiatement
Et le décompte continue (321)
Et à la fin du décompte, le contenu géolocalisé remplace le buffer
Scénario: Ignorance de la notification (pas de clic pendant 7s)
Étant donné qu'une notification géolocalisée s'affiche (compteur 7)
Quand 7 secondes s'écoulent sans que j'appuie sur "Suivant"
Alors la notification disparaît automatiquement
Et le contenu géolocalisé est perdu (pas d'insertion dans la file)
Et un cooldown de 10 minutes est activé
# 17.3 - Limitation anti-spam
Scénario: Quota de 6 contenus géolocalisés par heure
Étant donné que j'ai validé 6 notifications géolocalisées dans la dernière heure
Quand un 7ème contenu géolocalisé est détecté
Alors aucune notification n'est envoyée
Et le contenu n'est pas inséré dans la file
Scénario: Fenêtre glissante de 60 minutes
Étant donné que j'ai validé 6 contenus géolocalisés
Et que le premier contenu a été validé il y a 61 minutes
Quand un nouveau contenu géolocalisé est détecté
Alors la notification est envoyée (quota libéré : 5/6)
Et le compteur horaire est mis à jour
Plan du Scénario: Gestion du quota horaire
Étant donné que <nb_valides> notifications ont été validées dans la dernière heure
Quand un nouveau contenu géolocalisé est détecté
Alors la notification est <action>
Exemples:
| nb_valides | action |
| 0 | envoyée |
| 3 | envoyée |
| 5 | envoyée |
| 6 | non envoyée |
| 7 | non envoyée |
Scénario: Exception audio-guides multi-séquences (comptent comme 1)
Étant donné que j'ai démarré un audio-guide avec 8 séquences
Et que cet audio-guide compte comme 1 contenu dans le quota
Quand toutes les séquences de l'audio-guide sont lues
Alors mon quota reste à 1/6
Et je peux encore valider 5 contenus géolocalisés simples
# 17.3.2 - Cooldown après ignorance
Scénario: Cooldown de 10 minutes après notification ignorée
Étant donné qu'une notification géolocalisée a été ignorée (pas de clic)
Et qu'un cooldown de 10 minutes est activé
Quand 5 minutes s'écoulent
Et qu'un nouveau contenu géolocalisé est détecté
Alors aucune notification n'est envoyée (cooldown actif)
Scénario: Cooldown expire après 10 minutes
Étant donné qu'un cooldown a été activé il y a 10 minutes
Quand un nouveau contenu géolocalisé est détecté
Alors la notification est envoyée (cooldown expiré)
Scénario: Pas de cooldown si notification validée
Étant donné qu'une notification géolocalisée est affichée
Quand j'appuie sur "Suivant" dans les 7 secondes
Alors aucun cooldown n'est activé
Et la prochaine notification pourra être envoyée normalement
# 17.4 - Navigation avec contenus géolocalisés
Scénario: Contenu géolocalisé dans l'historique de navigation
Étant donné que j'écoute un contenu du buffer
Et que j'ai validé un contenu géolocalisé "Tour Eiffel"
Et que j'ai écouté 42 secondes du contenu géolocalisé
Quand j'appuie sur "Suivant" (skip)
Et que j'appuie ensuite sur "Précédent"
Alors le contenu géolocalisé reprend à 42 secondes
Scénario: Contenu ignoré n'entre pas dans l'historique
Étant donné qu'une notification géolocalisée a été ignorée
Quand j'appuie sur "Précédent"
Alors le contenu géolocalisé ignoré n'apparaît PAS dans l'historique
Et je reviens au contenu d'avant
Scénario: Skip pendant le décompte annule l'insertion
Étant donné que j'ai validé une notification (décompte 5s en cours)
Et que le compteur affiche "3"
Quand j'appuie à nouveau sur "Suivant"
Alors le décompte est annulé
Et le contenu suivant du buffer démarre
Et le contenu géolocalisé n'entre PAS dans l'historique
# 17.5 - Basculement automatique voiture ↔ piéton
Scénario: Détection mode piéton (vitesse <5 km/h stable 10s)
Étant donné que je suis en mode voiture
Et que ma vitesse passe à 3 km/h
Quand cette vitesse reste stable pendant 10 secondes
Alors le mode piéton est activé automatiquement
Et les notifications passent en mode push arrière-plan (si permission accordée)
Scénario: Détection mode voiture (vitesse ≥5 km/h stable 10s)
Étant donné que je suis en mode piéton
Et que ma vitesse passe à 15 km/h
Quand cette vitesse reste stable pendant 10 secondes
Alors le mode voiture est activé automatiquement
Et les notifications passent en mode sonore + icône (app premier plan requise)
Scénario: Hysteresis pour éviter basculements intempestifs
Étant donné que ma vitesse passe de 20 km/h à 3 km/h (arrêt feu rouge)
Et que ma vitesse remonte à 20 km/h après 8 secondes
Quand le système vérifie le mode
Alors aucun basculement n'a lieu (hysteresis de 10s non atteinte)
Et je reste en mode voiture
Plan du Scénario: Effets du basculement voiture → piéton
Étant donné que je bascule de voiture à piéton
Quand le basculement est effectué
Alors les paramètres suivants changent:
| paramètre | voiture | piéton |
| App requise | Premier plan | Arrière-plan OK |
| Notification | Sonore + icône + compteur| Push système |
| Rayon détection | ETA 7s (variable) | 200m fixes |
| Type contenu | Tous géolocalisés | Audio-guides uniquement |
# 17.6 - Edge cases
Scénario: Haute vitesse (130 km/h sur autoroute)
Étant donné que je roule à 130 km/h (36.1 m/s)
Et qu'un contenu géolocalisé est à 252 mètres
Quand l'ETA de 7s est atteint
Et que je valide la notification
Alors le décompte 5s démarre
Et le contenu géolocalisé démarre encore avant le point GPS (72m avant)
Scénario: Multiples points géolocalisés proches (route touristique)
Étant donné que 3 châteaux sont espacés de 800m chacun
Et que je valide la notification du Château A
Quand j'arrive près du Château B (57s plus tard à 50 km/h)
Alors la notification du Château B est envoyée (quota 2/6, pas de cooldown)
Scénario: Mode stationnement (vitesse <1 km/h pendant 2 min)
Étant donné que je me gare à 30m d'un château
Et que ma vitesse est <1 km/h pendant 2 minutes
Quand le mode stationnement est détecté
Alors aucune notification de contenu géolocalisé n'est envoyée
Et le système bascule automatiquement en mode piéton
Scénario: Reprise conduite après stationnement
Étant donné que je suis en mode stationnement
Et que ma vitesse passe à 20 km/h pendant 10 secondes
Quand le système détecte la reprise de conduite
Alors le mode voiture est réactivé
Et les notifications géolocalisées reprennent (si quota non atteint)
# Distinction contenus géolocalisés simples vs audio-guides
Scénario: Contenu géolocalisé simple (1 séquence unique)
Étant donné qu'un contenu géolocalisé simple existe à un point GPS
Quand la notification est déclenchée (ETA 7s)
Et que je valide
Alors le contenu démarre après décompte 5s
Et à la fin du contenu, le buffer normal reprend
Et ce contenu compte 1/6 dans le quota
Scénario: Audio-guide multi-séquences (2+ séquences enchaînées)
Étant donné qu'un audio-guide avec 8 séquences existe
Quand je démarre l'audio-guide
Et que les séquences s'enchaînent automatiquement (GPS ou manuel)
Alors l'audio-guide entier compte 1/6 dans le quota
Et les séquences ne déclenchent PAS de notification avec compteur 7s
Et elles se déclenchent au point GPS exact (rayon 30m)
# Gestion erreurs
Scénario: GPS désactivé en mode voiture
Étant donné que je suis en mode voiture
Quand le GPS est désactivé
Alors aucune notification géolocalisée ne peut être envoyée
Et un message d'erreur s'affiche: "GPS requis pour les contenus géolocalisés"
Scénario: App en arrière-plan en mode voiture
Étant donné que je suis en mode voiture
Et que l'app passe en arrière-plan
Quand un contenu géolocalisé est détecté
Alors aucune notification n'est envoyée (app premier plan requise)
Et le contenu n'est pas perdu (sera proposé si app rouverte dans le rayon)
Scénario: Permission "Always Location" refusée (mode piéton indisponible)
Étant donné que je refuse la permission "Always Location"
Quand ma vitesse passe <5 km/h
Alors le mode piéton n'est PAS activé
Et le mode voiture reste actif (avec permission "When In Use")
Et aucune notification arrière-plan n'est envoyée

View File

@@ -0,0 +1,140 @@
# language: fr
Fonctionnalité: Gestion de l'historique et reproposition
En tant que système de recommandation
Je veux gérer l'historique d'écoute intelligemment
Afin d'éviter les répétitions et offrir une découverte maximale
Contexte:
Étant donné que l'API RoadWave est disponible
Scénario: Contenu écouté complètement (>80%) - jamais reproposé
Étant donné qu'un utilisateur a écouté un contenu à 85%
Quand l'algorithme génère les recommandations
Alors ce contenu n'est jamais reproposé
Et il est marqué comme "écouté" dans l'historique
Scénario: Contenu écouté à 80% exactement - jamais reproposé
Étant donné qu'un utilisateur a écouté un contenu exactement à 80%
Quand l'algorithme génère les recommandations
Alors ce contenu n'est pas reproposé (seuil >= 80%)
Scénario: Contenu skippé rapidement (<10s) - ne pas reproposer
Étant donné qu'un utilisateur a skippé un contenu après 8 secondes
Quand l'algorithme génère les recommandations
Alors ce contenu n'est pas reproposé (signal négatif fort)
Et la jauge d'intérêt correspondante est réduite de 0.5%
Scénario: Contenu skippé exactement à 10s - ne pas reproposer
Étant donné qu'un utilisateur a skippé un contenu après exactement 10 secondes
Quand l'algorithme génère les recommandations
Alors ce contenu n'est pas reproposé (seuil < 10s strict)
Scénario: Contenu partiellement écouté (10-80%) - reproposer avec reprise
Étant donné qu'un utilisateur a écouté un contenu à 45%
Et qu'il est arrivé à la position 2:30 (150 secondes)
Quand l'algorithme propose à nouveau ce contenu
Alors le contenu peut être reproposé
Et la position de reprise est 150 secondes
Et l'utilisateur voit "Reprendre à 2:30"
Scénario: Contenu écouté à 11% - reproposition possible
Étant donné qu'un utilisateur a écouté un contenu à 11%
Quand l'algorithme génère les recommandations
Alors ce contenu peut être reproposé (>10%)
Et la position de reprise est sauvegardée
Scénario: Contenu écouté à 79% - reproposition possible
Étant donné qu'un utilisateur a écouté un contenu à 79%
Quand l'algorithme génère les recommandations
Alors ce contenu peut être reproposé (<80%)
Et l'utilisateur peut terminer l'écoute
Scénario: Audio-guide avec flag replayable=true
Étant donné qu'un audio-guide a le flag "replayable = true"
Et qu'un utilisateur l'a écouté à 95%
Quand l'algorithme génère les recommandations
Alors l'audio-guide peut être reproposé
Et il est marqué comme "Écouté - Rejouable"
Scénario: Podcast standard sans flag replayable
Étant donné qu'un podcast n'a pas de flag replayable
Et qu'un utilisateur l'a écouté à 90%
Quand l'algorithme génère les recommandations
Alors le podcast n'est jamais reproposé
Scénario: Stockage dans user_content_history
Étant donné qu'un utilisateur écoute un contenu
Quand l'écoute se termine ou est skippée
Alors les données suivantes sont enregistrées:
| champ | exemple |
| user_id | user-123 |
| content_id | content-456 |
| completion_rate | 0.45 (45%) |
| last_position | 150 (secondes) |
| listened_at | 2026-01-21 14:30:00 |
Scénario: Historique illimité stocké
Étant donné qu'un utilisateur a écouté 5000 contenus
Quand il consulte son historique
Alors tous les 5000 contenus sont disponibles
Et aucun contenu n'est supprimé automatiquement
Scénario: Algorithme considère les 100 derniers pour performance
Étant donné qu'un utilisateur a écouté 500 contenus
Quand l'algorithme génère les recommandations
Alors il vérifie uniquement les 100 derniers contenus pour exclusion
Et cette limite est une optimisation de requête SQL
Scénario: Export historique complet (RGPD)
Étant donné qu'un utilisateur demande l'export RGPD
Quand l'export est généré
Alors l'historique complet est inclus avec:
| information | inclus |
| Tous les contenus | |
| Dates d'écoute | |
| Taux complétion | |
| Positions reprise | |
Scénario: Reprise automatique d'un contenu partiellement écouté
Étant donné que j'ai écouté un podcast à 60% (position 10:00)
Quand ce podcast est reproposé par l'algorithme
Et que je lance la lecture
Alors l'écoute reprend automatiquement à 10:00
Et je vois une notification "Reprise à 10:00"
Scénario: Option "Reprendre du début" pour contenu partiellement écouté
Étant donné que j'ai écouté un podcast à 60%
Quand ce podcast est reproposé
Alors je vois deux options:
| option | action |
| Reprendre à 10:00 | Lecture à partir de 10:00 |
| Depuis le début | Lecture à partir de 0:00 |
Scénario: Contenu écouté il y a 6 mois - toujours en historique
Étant donné qu'un utilisateur a écouté un contenu il y a 6 mois à 90%
Quand l'algorithme génère les recommandations
Alors ce contenu n'est toujours pas reproposé
Et l'historique n'a pas de limite temporelle
Scénario: Nouveau contenu du même créateur après écoute complète
Étant donné qu'un utilisateur a écouté un contenu de "Créateur A" à 90%
Et que "Créateur A" publie un nouveau contenu
Quand l'algorithme génère les recommandations
Alors le nouveau contenu peut être recommandé
Et seul l'ancien contenu est exclu (pas tout le créateur)
Scénario: Statistiques personnelles d'historique
Étant donné que je consulte mon profil
Quand j'accède à la section "Historique"
Alors je vois:
| métrique | exemple |
| Nombre total d'écoutes | 1,234 |
| Heures écoutées | 456h |
| Taux complétion moyen | 72% |
| Top 5 catégories | Voyage, Sport |
Scénario: Filtrer l'historique par date
Étant donné que je consulte mon historique
Quand je filtre par "Dernière semaine"
Alors seuls les contenus écoutés dans les 7 derniers jours sont affichés
Et je peux exporter cette sélection

View File

@@ -0,0 +1,162 @@
# language: fr
Fonctionnalité: Médias traditionnels sur RoadWave
En tant que média établi
Je veux publier du contenu géolocalisé sur RoadWave
Afin d'atteindre une audience locale et mobile
Contexte:
Étant donné que l'API RoadWave est disponible
Scénario: Création d'un compte média vérifié
Étant donné que je représente Le Monde
Quand je crée un compte média
Et que je fournis les justificatifs (SIRET, documents officiels)
Alors mon compte est créé en attente de vérification
Et l'équipe RoadWave examine ma demande sous 48-72h
Scénario: Validation compte média par l'équipe RoadWave
Étant donné qu'un compte média "Le Parisien" est en attente
Quand l'équipe RoadWave valide le compte
Alors le compte reçoit le badge vérifié
Et le média peut publier sans validation des 3 premiers contenus
Et je vois le message "Compte média vérifié avec succès"
Scénario: Badge vérifié visible sur profil média
Étant donné que "France Inter" a un compte vérifié
Quand un utilisateur consulte le profil
Alors il voit le badge à côté du nom
Et une mention "Média vérifié"
Scénario: Pas de validation des 3 premiers contenus pour médias
Étant donné que je suis un média vérifié
Quand je publie mon premier contenu
Alors le contenu est publié immédiatement sans validation
Et il est visible pour tous les utilisateurs
Et je ne passe pas par la modération initiale
Scénario: Modération a posteriori uniquement
Étant donné que "Libération" publie un contenu
Quand le contenu est publié
Alors il est immédiatement disponible
Mais peut être signalé et modéré a posteriori
Et suit les mêmes règles de modération que les créateurs
Scénario: Publication flash info géolocalisé
Étant donné que je suis "Ouest-France" (média régional)
Quand je publie un flash info sur un événement à Rennes
Et que je le géolocalise en Bretagne (géo-contextuel)
Alors le contenu est publié immédiatement
Et il est recommandé aux utilisateurs en Bretagne
Scénario: Publication chronique thématique
Étant donné que je suis "France Culture"
Quand je publie une chronique philosophie (géo-neutre)
Alors le contenu est disponible partout en France
Et suit l'algorithme de recommandation standard
Scénario: Publication édito politique
Étant donné que je suis "Le Figaro"
Quand je publie un édito politique
Et que je le tague "Politique"
Alors le contenu est publié immédiatement
Et la classification politique MVP s'applique (pas gauche/droite)
Et les utilisateurs ayant activé "Masquer politique" ne le voient pas
Scénario: Formats de contenu autorisés pour médias
Étant donné que je suis un média vérifié
Quand je publie du contenu
Alors je peux publier:
| format | exemple |
| Flash info géolocalisé | Actualité régionale 2-5 min |
| Chronique thématique | Culture, économie, sport 5-15min|
| Édito et débats | Opinion 10-30 min |
| Reportage | Investigation 15-45 min |
Scénario: Médias suivent les règles standard de classification âge
Étant donné que je suis "RTL"
Quand je publie un contenu sensible
Alors je dois obligatoirement classifier par âge:
| classification | type contenu |
| Tout public | Info générale |
| 13+ | Actualité avec sujets sensibles |
| 16+ | Débats avec violence verbale |
| 18+ | Sujets adultes |
Scénario: Monétisation médias - partage revenus pub standard
Étant donné que je suis un média vérifié
Et que mes contenus génèrent des écoutes
Quand le mois se termine
Alors je reçois 3 / 1000 écoutes complètes (même taux que créateurs)
Et le paiement suit les mêmes règles (seuil 50, mensuel)
Scénario: Sponsoring direct non géré par plateforme
Étant donné que je suis "Europe 1"
Et que je veux intégrer un sponsor dans mon contenu
Quand je mentionne le sponsor dans l'audio
Alors c'est autorisé (sponsoring éditorial)
Mais RoadWave ne gère pas la transaction
Et je gère la relation sponsor directement
Scénario: Médias peuvent avoir plusieurs comptes créateurs
Étant donné que je suis "Le Monde"
Quand je veux créer des sous-comptes par rubrique
Alors je peux créer:
| compte | description |
| @lemonde_politique | Actualité politique |
| @lemonde_economie | Économie et entreprises |
| @lemonde_culture | Culture et spectacles |
Et tous sont liés au compte média principal
Scénario: Médias régionaux privilégiés localement
Étant donné que "Sud-Ouest" publie du contenu géo-contextuel en Nouvelle-Aquitaine
Et qu'un utilisateur est à Bordeaux
Quand l'algorithme calcule les recommandations
Alors le contenu de "Sud-Ouest" a un score géo élevé
Et il est privilégié pour l'audience locale
Scénario: Médias nationaux accessibles partout
Étant donné que "France Inter" publie un podcast géo-neutre
Quand des utilisateurs à Paris, Lyon, Marseille demandent des recommandations
Alors le podcast est accessible partout sans distinction géographique
Et suit l'algorithme de recommandation standard
Scénario: Statistiques détaillées pour médias
Étant donné que je suis un média vérifié
Quand je consulte mes statistiques
Alors je vois:
| métrique | exemple |
| Écoutes par région | Île-de-France: 45% |
| Taux complétion | 72% |
| Démographie auditeurs | 25-34 ans: 35% |
| Top contenus | Flash info Paris|
| Revenus générés | 1,234 |
Scénario: Médias peuvent exporter analytics
Étant donné que je suis "Libération"
Quand je clique sur "Exporter analytics"
Alors je reçois un CSV avec données détaillées
Et je peux analyser les données avec mes outils internes
Scénario: Contact prioritaire équipe RoadWave
Étant donné que je suis un média vérifié
Quand j'ai un problème technique ou question
Alors je peux contacter le support média prioritaire
Et j'obtiens une réponse sous 24h (vs 48-72h standard)
Scénario: Médias peuvent programmer la publication
Étant donné que je suis "France Culture"
Quand je prépare un contenu à l'avance
Alors je peux programmer la publication pour une date/heure future
Et le contenu sera publié automatiquement au moment choisi
Scénario: API dédiée pour médias (post-MVP)
Étant donné que je suis un grand média avec beaucoup de contenus
Quand RoadWave développe l'API médias
Alors je peux automatiser la publication via API
Et intégrer RoadWave dans mon workflow de production
Scénario: Signalement d'un contenu média traité en priorité
Étant donné qu'un contenu de "Le Monde" est signalé
Quand le signalement arrive en modération
Alors il est traité avec la même priorité qu'un créateur standard
Et le badge vérifié ne donne pas d'immunité modération

View File

@@ -0,0 +1,115 @@
# language: fr
Fonctionnalité: Mode Kids pour utilisateurs 13-15 ans
En tant que parent ou adolescent
Je veux activer un mode Kids avec filtrage de contenu
Afin de protéger les mineurs des contenus inappropriés
Contexte:
Étant donné que l'API RoadWave est disponible
Scénario: Activation manuelle du mode Kids
Étant donné que je suis un utilisateur de 14 ans
Et que le mode Kids n'est pas activé par défaut
Quand j'active le mode Kids dans les paramètres
Alors le mode Kids est activé sur mon compte
Et je vois le message "Mode Kids activé - Contenus filtrés pour 13-15 ans"
Scénario: Parent active le mode Kids pour son enfant
Étant donné que je suis le parent d'un utilisateur de 13 ans
Et que j'ai accès au compte de mon enfant
Quand j'active le mode Kids
Alors le mode Kids est activé sur le compte enfant
Et seuls les contenus "Tous publics" sont accessibles
Scénario: Filtrage contenu - uniquement "Tous publics"
Étant donné que le mode Kids est activé sur mon compte
Et qu'il existe des contenus avec les classifications:
| classification | nombre |
| Tous publics | 100 |
| 13+ | 50 |
| 16+ | 30 |
| 18+ | 20 |
Quand je demande des recommandations
Alors seuls les 100 contenus "Tous publics" sont proposés
Et les contenus 13+, 16+, 18+ sont exclus
Scénario: Exclusion automatique du contenu politique
Étant donné que le mode Kids est activé
Et qu'il existe 20 contenus "Tous publics" dont 5 tagués "Politique"
Quand je demande des recommandations
Alors seuls les 15 contenus non-politiques sont proposés
Et les 5 contenus politiques sont automatiquement exclus
Scénario: Pas de publicité en mode Kids
Étant donné que le mode Kids est activé
Et que je suis un utilisateur gratuit
Quand j'écoute du contenu
Alors aucune publicité n'est diffusée
Et je n'ai pas d'insertion publicitaire (règle 1/5 désactivée)
Scénario: Publicité validée manuellement en mode Kids (post-MVP)
Étant donné que le mode Kids est activé
Et qu'une publicité a été validée manuellement pour le mode Kids
Quand j'écoute du contenu
Alors cette publicité peut être diffusée
Mais la fréquence reste inférieure au mode standard
Scénario: Interface standard même en mode Kids
Étant donné que le mode Kids est activé
Quand j'ouvre l'application
Alors l'interface est identique au mode normal
Et seul le filtrage de contenu est actif (pas d'UI enfant)
Scénario: Désactivation du mode Kids
Étant donné que le mode Kids est activé
Quand je désactive le mode Kids dans les paramètres
Alors tous les contenus sont à nouveau accessibles selon mon âge
Et je vois le message "Mode Kids désactivé"
Scénario: Utilisateur 16 ans ne peut pas activer le mode Kids 13-15 ans
Étant donné que je suis un utilisateur de 16 ans
Quand j'essaie d'activer le mode Kids
Alors l'activation réussit
Et le mode Kids filtre les contenus 16+ et 18+ (pas seulement 13+)
Et je vois uniquement les contenus "Tous publics"
Scénario: Tentative d'accès direct à contenu 16+ en mode Kids
Étant donné que le mode Kids est activé
Et qu'un ami me partage un contenu 16+
Quand j'essaie d'accéder au contenu via le lien
Alors l'accès est refusé
Et je vois le message "Ce contenu n'est pas accessible en mode Kids"
Scénario: Recherche en mode Kids filtre automatiquement
Étant donné que le mode Kids est activé
Quand je recherche "débat"
Alors seuls les contenus "Tous publics" apparaissent dans les résultats
Et les contenus 13+, 16+, 18+ sont exclus de la recherche
Scénario: Audio-guide en mode Kids
Étant donné que le mode Kids est activé
Et qu'un audio-guide "Tous publics" existe au musée du Louvre
Quand je suis à proximité du Louvre
Alors l'audio-guide est proposé normalement
Et toutes les séquences sont accessibles
Scénario: Statistiques créateur - audience mode Kids
Étant donné que je suis un créateur
Et que mes contenus "Tous publics" sont écoutés par des utilisateurs mode Kids
Quand je consulte mes statistiques
Alors je vois le pourcentage d'écoutes en mode Kids
Et je peux adapter mes contenus en conséquence
Scénario: Notification lors de l'activation du mode Kids
Quand j'active le mode Kids
Alors je reçois une notification explicative:
| information | description |
| Contenu | Seuls les contenus "Tous publics" accessibles |
| Politique | Contenus politiques automatiquement masqués |
| Publicité | Aucune publicité affichée |
Scénario: Badge mode Kids visible dans le profil
Étant donné que le mode Kids est activé
Quand je consulte mon profil
Alors je vois un badge "Mode Kids actif 🛡"
Et je peux le désactiver en un clic

View File

@@ -0,0 +1,163 @@
# language: fr
Fonctionnalité: Paramétrabilité admin et A/B testing
En tant qu'administrateur RoadWave
Je veux configurer les paramètres de l'algorithme à chaud
Afin d'optimiser l'engagement sans redéploiement
Contexte:
Étant donné que l'API RoadWave est disponible
Et que je suis connecté en tant qu'admin
Scénario: Accès au dashboard admin
Quand j'accède au dashboard admin
Alors je vois tous les paramètres configurables de l'algorithme
Et je vois les valeurs actuelles et par défaut
Scénario: Modifier le poids géo pour contenu ancré
Étant donné que le poids_geo_ancre est à 0.7 (défaut)
Quand je modifie le poids_geo_ancre à 0.8
Et que je sauvegarde
Alors la nouvelle valeur est appliquée immédiatement
Et tous les nouveaux calculs utilisent 0.8
Et je vois le message "Paramètre mis à jour avec succès"
Scénario: Validation des plages de valeurs
Quand j'essaie de configurer poids_geo_ancre à 1.5 (hors plage 0.5-1.0)
Alors la modification échoue
Et je vois le message "Valeur hors plage autorisée (0.5 - 1.0)"
Plan du Scénario: Modification de tous les paramètres configurables
Quand je modifie "<parametre>" à "<nouvelle_valeur>"
Alors la modification est appliquée immédiatement
Et la nouvelle valeur respecte la plage "<plage>"
Exemples:
| parametre | nouvelle_valeur | plage |
| poids_geo_ancre | 0.8 | 0.5 - 1.0 |
| poids_geo_contextuel | 0.6 | 0.3 - 0.7 |
| poids_geo_neutre | 0.3 | 0.0 - 0.4 |
| poids_engagement | 0.3 | 0.0 - 0.5 |
| part_aleatoire_global | 0.15 | 0.0 - 0.3 |
| distance_max_km | 150 | 50 - 500 |
| rayon_gps_point_m | 1000 | 100 - 2000 |
| seuil_min_ecoutes_engagement | 100 | 10 - 200 |
Scénario: Aucun recalcul batch après modification
Étant donné que le poids_geo_ancre est modifié de 0.7 à 0.8
Quand la modification est appliquée
Alors aucun recalcul batch n'est lancé (économie CPU)
Et seuls les nouveaux calculs utilisent la valeur 0.8
Scénario: Versioning des configurations
Étant donné que je modifie plusieurs paramètres
Quand je sauvegarde la configuration
Alors une nouvelle version est créée (ex: v1.2.3)
Et je peux voir l'historique des versions
Et je peux comparer deux versions
Scénario: Rollback en 1 clic
Étant donné que la configuration actuelle est v1.2.3
Et que la version précédente était v1.2.2
Quand je clique sur "Restaurer v1.2.2"
Alors tous les paramètres de v1.2.2 sont réappliqués
Et je vois le message "Configuration restaurée à v1.2.2"
Scénario: Créer une variante A/B testing
Quand je crée une nouvelle variante "Test engagement élevé"
Et que je configure:
| parametre | valeur |
| poids_engagement | 0.4 |
| poids_geo_ancre | 0.6 |
Et que je lance le test A/B
Alors 50% des utilisateurs reçoivent la Config A (défaut)
Et 50% des utilisateurs reçoivent la Config B (test)
Scénario: Split utilisateurs aléatoire pour A/B test
Étant donné qu'un test A/B est actif
Quand 1000 nouveaux utilisateurs se connectent
Alors environ 500 sont assignés à la Config A
Et environ 500 sont assignés à la Config B
Et l'assignation est aléatoire et équilibrée
Scénario: Utilisateur reste dans la même variante
Étant donné qu'un utilisateur est assigné à la Config B
Quand il se reconnecte plusieurs fois
Alors il reste toujours dans la Config B
Et il ne change pas de variante pendant le test
Scénario: Métriques comparatives A/B testing
Étant donné qu'un test A/B est actif depuis 7 jours
Quand je consulte le dashboard A/B testing
Alors je vois les métriques suivantes pour chaque config:
| metrique | Config A | Config B |
| Taux complétion moyen | 68% | 72% |
| Engagement (likes) | 15% | 18% |
| Durée session moyenne | 23 min | 27 min |
| Taux skip rapide (<10s) | 12% | 9% |
Scénario: Dashboard graphique temps réel
Étant donné qu'un test A/B est actif
Quand je consulte le dashboard
Alors je vois des graphiques temps réel:
| graphique | type |
| Évolution engagement | Ligne |
| Répartition utilisateurs| Camembert |
| Taux complétion | Barres |
| Durée session | Ligne |
Scénario: Terminer un test A/B et appliquer la meilleure config
Étant donné qu'un test A/B montre que Config B est meilleure
Quand je clique sur "Appliquer Config B pour tous"
Alors la Config B devient la configuration par défaut
Et tous les utilisateurs utilisent maintenant Config B
Et l'ancien test est archivé
Scénario: Audit engagement global
Quand je consulte la section "Audit engagement"
Alors je vois:
| metrique | valeur |
| Temps écoute moyen/session | 25 min |
| Temps écoute médian/session | 18 min |
| Taux complétion moyen | 70% |
| % sessions avec 1 like | 35% |
Scénario: Graphiques évolution engagement selon config
Étant donné que plusieurs modifications de config ont été faites
Quand je consulte les graphiques d'évolution
Alors je vois l'impact de chaque changement de config
Et je peux corréler changements config avec métriques
Scénario: Export CSV pour analyse externe
Quand je clique sur "Exporter données"
Alors je peux télécharger un CSV avec:
| colonne | exemple |
| date | 2026-01-21 |
| version_config | v1.2.3 |
| taux_completion | 0.72 |
| engagement_moyen | 0.45 |
| duree_session_min | 27 |
Scénario: Alerte automatique si métrique critique baisse
Étant donné que le taux de complétion moyen est à 70%
Quand une nouvelle config fait baisser le taux à 55%
Alors je reçois une alerte email "Baisse critique du taux de complétion"
Et je peux rollback rapidement
Scénario: Prévisualisation impact avant application
Étant donné que je modifie poids_geo_ancre de 0.7 à 0.9
Quand je clique sur "Prévisualiser impact"
Alors je vois une simulation sur échantillon de 1000 utilisateurs
Et je vois l'estimation d'impact sur les métriques clés
Scénario: Notes et commentaires sur modifications config
Quand je modifie une configuration
Alors je peux ajouter une note "Test pour améliorer contenu local"
Et cette note est visible dans l'historique des versions
Et l'équipe peut comprendre le contexte des changements
Scénario: Permissions admin pour modification config
Étant donné que je suis un admin junior
Quand j'essaie de modifier un paramètre critique
Alors l'accès est refusé
Et je vois "Permission admin senior requise"
Et seuls les admins seniors peuvent modifier les paramètres

View File

@@ -0,0 +1,188 @@
# language: fr
Fonctionnalité: Paramétrabilité utilisateur et profils
En tant qu'utilisateur
Je veux personnaliser mon expérience de recommandation
Afin d'adapter l'application à mes différents contextes d'usage
Contexte:
Étant donné que l'API RoadWave est disponible
Et que je suis connecté
Scénario: Accès aux paramètres de personnalisation
Quand j'ouvre les paramètres de personnalisation
Alors je vois trois curseurs disponibles:
| curseur | description |
| Géolocalisation | Local slider National |
| Découverte | 0% slider 50% |
| Politique | Masquer / Équilibré / Mes préférences |
Scénario: Modifier le curseur Géolocalisation vers Local
Étant donné que le curseur Géolocalisation est au centre (défaut)
Quand je déplace le curseur vers "Local" (gauche)
Alors l'algorithme privilégie fortement les contenus proches
Et la pondération géographique augmente
Et je vois le message "Recommandations locales privilégiées"
Scénario: Modifier le curseur Géolocalisation vers National
Étant donné que le curseur Géolocalisation est au centre
Quand je déplace le curseur vers "National" (droite)
Alors l'algorithme privilégie la découverte nationale
Et la pondération géographique diminue
Et je reçois des contenus de toute la France
Scénario: Curseur Découverte à 0% - aucun aléatoire
Quand je règle le curseur Découverte à 0%
Alors 0% de contenus aléatoires dans mes recommandations
Et 100% de contenus calculés selon score combiné
Et je vois le message "Personnalisation maximale"
Scénario: Curseur Découverte à 10% - défaut équilibré
Quand je règle le curseur Découverte à 10%
Alors 10% de contenus aléatoires
Et 90% de contenus calculés
Et je vois le message "Équilibre découverte/personnalisation"
Scénario: Curseur Découverte à 30% - découverte élevée
Quand je règle le curseur Découverte à 30%
Alors 30% de contenus aléatoires
Et 70% de contenus calculés
Et je vois le message "Découverte élevée activée"
Scénario: Curseur Découverte à 50% - découverte maximale
Quand je règle le curseur Découverte à 50%
Alors 50% de contenus aléatoires
Et 50% de contenus calculés
Et je vois le message "Découverte maximale (équivaut à national)"
Scénario: Créer un profil personnalisé "Trajet quotidien"
Quand je crée un nouveau profil nommé "🚗 Trajet quotidien"
Et que je configure:
| parametre | valeur |
| Géolocalisation | Local |
| Découverte | 5% |
| Politique | Masquer |
Et que je sauvegarde
Alors le profil "🚗 Trajet quotidien" est créé
Et je peux l'activer en un clic
Scénario: Créer un profil "Road trip"
Quand je crée un profil "🛣 Road trip"
Et que je configure:
| parametre | valeur |
| Géolocalisation | Régional |
| Découverte | 30% |
| Politique | Équilibré |
Alors le profil est sauvegardé
Et je peux switcher entre profils facilement
Scénario: Créer un profil "Enfants"
Quand je crée un profil "👶 Enfants"
Et que j'active le Mode Kids
Alors tous les paramètres sont adaptés pour enfants:
| parametre | valeur |
| Mode Kids | Activé |
| Politique | Masquer (forcé) |
| Publicité | Aucune |
Scénario: Activer un profil existant
Étant donné que j'ai créé un profil "🚗 Trajet quotidien"
Quand je clique sur "Activer" pour ce profil
Alors tous les paramètres du profil sont appliqués
Et je vois le message "Profil 'Trajet quotidien' activé"
Et l'algorithme utilise ces paramètres immédiatement
Scénario: Synchronisation profils entre devices
Étant donné que j'ai créé 3 profils sur mon iPhone
Quand je me connecte sur mon iPad
Alors mes 3 profils sont automatiquement synchronisés
Et je peux les utiliser sur l'iPad
Scénario: Modification d'un profil synchronisée
Étant donné que j'ai un profil "Road trip" sur iPhone
Quand je modifie ce profil sur iPhone
Alors la modification est synchronisée sur tous mes devices
Et le profil est mis à jour partout en temps réel
Scénario: Pas de partage de profils entre utilisateurs
Étant donné que j'ai créé des profils personnalisés
Et que ma conjointe a un compte RoadWave
Quand elle se connecte sur son compte
Alors elle ne voit pas mes profils
Et chaque utilisateur a ses propres profils
Scénario: Auto-switch selon contexte (détection trajet récurrent)
Étant donné que j'utilise toujours le profil "Trajet quotidien"
Et que je pars de chez moi vers mon travail tous les matins à 8h
Quand le système détecte ce trajet récurrent
Alors le profil "Trajet quotidien" est activé automatiquement
Et je reçois une notification "Profil 'Trajet quotidien' activé"
Scénario: Désactiver l'auto-switch
Étant donné que l'auto-switch de profil est actif
Quand je désactive cette option dans les paramètres
Alors les profils ne changent plus automatiquement
Et je dois les activer manuellement
Scénario: Blocage modification si vitesse GPS >10 km/h
Étant donné que je conduis à 50 km/h
Quand j'essaie de modifier un curseur
Alors la modification est bloquée
Et je vois le message "Modification impossible pendant la conduite"
Et je dois m'arrêter ou être passager pour modifier
Scénario: Modification possible si vitesse <10 km/h
Étant donné que je suis arrêté à un feu rouge (5 km/h)
Quand j'essaie de modifier un curseur
Alors la modification est autorisée
Et je peux ajuster les paramètres
Scénario: Warning au lancement app
Quand je lance l'application pour la première fois
Alors je vois un warning "Configurez vos préférences avant de prendre la route"
Et un bouton "Configurer maintenant"
Et je peux accéder rapidement aux paramètres
Scénario: Modification uniquement app arrêtée ou mode passager
Étant donné que je suis passager dans une voiture
Et que le mode passager est activé
Quand j'essaie de modifier les paramètres
Alors la modification est autorisée
Et le blocage vitesse GPS ne s'applique pas
Scénario: Statistiques d'utilisation des profils
Étant donné que j'utilise plusieurs profils
Quand je consulte mes statistiques
Alors je vois:
| metrique | exemple |
| Profil le plus utilisé | Trajet quotidien |
| Heures par profil | 25h / 10h / 5h |
| Dernier profil actif | Road trip |
Scénario: Supprimer un profil
Étant donné que j'ai créé un profil "Test"
Quand je supprime ce profil
Alors le profil est définitivement supprimé
Et je vois le message "Profil 'Test' supprimé"
Et il disparaît de tous mes devices
Scénario: Limite de profils par utilisateur
Étant donné que j'ai créé 10 profils
Quand j'essaie de créer un 11ème profil
Alors la création échoue
Et je vois le message "Maximum 10 profils par utilisateur"
Scénario: Dupliquer un profil existant
Étant donné que j'ai un profil "Trajet quotidien"
Quand je clique sur "Dupliquer"
Alors un nouveau profil "Trajet quotidien (copie)" est créé
Et il a les mêmes paramètres que l'original
Et je peux le modifier indépendamment
Scénario: Réinitialiser un profil aux valeurs par défaut
Étant donné que j'ai modifié un profil
Quand je clique sur "Réinitialiser"
Alors tous les paramètres reviennent aux valeurs par défaut:
| parametre | valeur défaut |
| Géolocalisation | Équilibré |
| Découverte | 10% |
| Politique | Équilibré |

View File

@@ -0,0 +1,221 @@
# language: fr
Fonctionnalité: Formule de scoring et recommandation
En tant que système de recommandation
Je veux calculer un score combiné pour chaque contenu
Afin de proposer les contenus les plus pertinents à l'utilisateur
Contexte:
Étant donné que l'API RoadWave est disponible
Scénario: Calcul du score géographique linéaire
Étant donné qu'un contenu existe à Paris
Et que la distance_max_km est configurée à 200 km
Quand un utilisateur est à 50 km du contenu
Alors le score_geo = 1 - (50 / 200) = 0.75
Scénario: Score géo à distance nulle (sur place)
Étant donné qu'un contenu existe à un point GPS précis
Quand un utilisateur est exactement au même point (0 km)
Alors le score_geo = 1.0 (maximum)
Scénario: Score géo à distance_max (200 km)
Étant donné qu'un contenu existe à Paris
Quand un utilisateur est à 200 km du contenu
Alors le score_geo = 1 - (200 / 200) = 0.0
Scénario: Score géo au-delà de distance_max
Étant donné qu'un contenu existe à Paris
Quand un utilisateur est à 250 km du contenu (au-delà de 200 km max)
Alors le score_geo = 0.0 (minimum)
Et le contenu a peu de chances d'être recommandé sauf engagement très élevé
Scénario: Calcul du score d'intérêts avec jauges utilisateur
Étant donné qu'un utilisateur a les jauges suivantes:
| categorie | niveau |
| Automobile | 80% |
| Voyage | 60% |
| Musique | 40% |
Et qu'un contenu est tagué "Automobile" et "Voyage"
Quand l'algorithme calcule le score_interets
Alors score_interets = (0.8 + 0.6) / 2 = 0.7
Scénario: Score d'intérêts avec un seul tag
Étant donné qu'un utilisateur a la jauge "Économie" à 90%
Et qu'un contenu est tagué uniquement "Économie"
Quand l'algorithme calcule le score_interets
Alors score_interets = 0.9
Scénario: Score d'intérêts avec tags non matchés
Étant donné qu'un utilisateur a des jauges "Sport" et "Politique" élevées
Et qu'un contenu est tagué "Musique" et "Philosophie"
Et que l'utilisateur n'a pas ces catégories
Quand l'algorithme calcule le score_interets
Alors score_interets = 0.5 (neutre par défaut pour catégories inconnues)
Scénario: Calcul du score d'engagement avec métriques
Étant donné qu'un contenu a:
| metrique | valeur |
| ecoutes | 1000 |
| ecoutes_completes | 700 |
| likes | 300 |
| abonnements_apres | 50 |
Quand l'algorithme calcule le score_engagement
Alors taux_completion = 700 / 1000 = 0.7
Et ratio_likes = 300 / 1000 = 0.3
Et ratio_abonnements = 50 / 1000 = 0.05
Et score_engagement = (0.7 × 0.5) + (0.3 × 0.3) + (0.05 × 0.2) = 0.35 + 0.09 + 0.01 = 0.45
Scénario: Contenu avec moins de 50 écoutes - score neutre
Étant donné qu'un contenu a seulement 30 écoutes
Quand l'algorithme calcule le score_engagement
Alors score_engagement = 0.5 (neutre par défaut)
Et le contenu n'est pas pénalisé pour manque de données
Scénario: Contenu avec exactement 50 écoutes - calcul réel
Étant donné qu'un contenu a exactement 50 écoutes
Et des métriques d'engagement complètes
Quand l'algorithme calcule le score_engagement
Alors le score est calculé normalement (pas de seuil neutre)
Scénario: Bonus aléatoire - 10% des recommandations
Étant donné qu'un utilisateur demande 10 recommandations
Et que la part_aleatoire_global est à 10%
Quand l'algorithme génère les recommandations
Alors 1 contenu sur 10 est tiré aléatoirement
Et 9 contenus sont calculés avec le score combiné
Et le contenu aléatoire n'est pas dans l'historique déjà écouté
Scénario: Curseur utilisateur découverte à 0% - aucun aléatoire
Étant donné qu'un utilisateur configure le curseur découverte à 0%
Quand l'utilisateur demande 20 recommandations
Alors les 20 contenus sont calculés avec le score combiné
Et aucun contenu aléatoire n'est proposé
Scénario: Curseur utilisateur découverte à 50% - découverte max
Étant donné qu'un utilisateur configure le curseur découverte à 50%
Quand l'utilisateur demande 20 recommandations
Alors 10 contenus sont tirés aléatoirement
Et 10 contenus sont calculés avec le score combiné
Scénario: Score final combiné pour contenu géo-ancré
Étant donné qu'un contenu "Géo-ancré" a:
| parametre | valeur |
| score_geo | 0.9 |
| score_interets | 0.6 |
| score_engagement | 0.45 |
| poids_geo | 0.7 |
| poids_interets | 0.1 |
| poids_engagement | 0.2 |
Quand l'algorithme calcule le score_final
Alors score_final = (0.9 × 0.7) + (0.6 × 0.1) + (0.45 × 0.2)
Et score_final = 0.63 + 0.06 + 0.09 = 0.78
Scénario: Score final combiné pour contenu géo-neutre
Étant donné qu'un contenu "Géo-neutre" a:
| parametre | valeur |
| score_geo | 0.3 |
| score_interets | 0.9 |
| score_engagement | 0.6 |
| poids_geo | 0.2 |
| poids_interets | 0.6 |
| poids_engagement | 0.2 |
Quand l'algorithme calcule le score_final
Alors score_final = (0.3 × 0.2) + (0.9 × 0.6) + (0.6 × 0.2)
Et score_final = 0.06 + 0.54 + 0.12 = 0.72
Et le contenu peut être recommandé malgré la distance
Scénario: Contenu viral lointain peut être recommandé
Étant donné qu'un contenu viral existe à Paris
Et qu'il a un score_engagement très élevé de 0.95
Et qu'un utilisateur est à Marseille (score_geo = 0.1)
Quand l'algorithme calcule le score_final
Alors le score_engagement élevé compense le score_geo faible
Et le contenu peut apparaître dans les recommandations
Scénario: Ordre de recommandation par score décroissant
Étant donné 5 contenus avec les scores suivants:
| contenu | score_final |
| Contenu A | 0.85 |
| Contenu B | 0.72 |
| Contenu C | 0.90 |
| Contenu D | 0.65 |
| Contenu E | 0.78 |
Quand l'utilisateur demande des recommandations
Alors l'ordre de proposition est:
| position | contenu |
| 1 | Contenu C |
| 2 | Contenu A |
| 3 | Contenu E |
| 4 | Contenu B |
| 5 | Contenu D |
Scénario: Exclusion de l'historique déjà écouté >80%
Étant donné qu'un utilisateur a écouté les contenus suivants:
| contenu | completion |
| Contenu A | 85% |
| Contenu B | 95% |
| Contenu C | 30% |
Quand l'algorithme génère les recommandations
Alors "Contenu A" et "Contenu B" ne sont jamais proposés
Mais "Contenu C" peut être reproposé
Scénario: Pré-calcul de 5 contenus suivants
Étant donné qu'un utilisateur écoute un contenu
Quand l'algorithme prépare les contenus suivants
Alors 5 contenus sont pré-calculés selon le score
Et ces contenus sont mis en cache pour performance
Scénario: Recalcul si déplacement >10 km
Étant donné que 5 contenus suivants sont pré-calculés
Et que l'utilisateur se déplace de 12 km
Quand l'utilisateur demande le contenu suivant
Alors l'algorithme recalcule les scores avec la nouvelle position
Et propose de nouveaux contenus plus pertinents géographiquement
Scénario: Recalcul après 10 minutes d'inactivité
Étant donné que 5 contenus suivants sont pré-calculés
Et que 11 minutes se sont écoulées sans action
Quand l'utilisateur demande le contenu suivant
Alors l'algorithme recalcule les scores
Et prend en compte les nouveaux contenus publiés
# Règle: Score géo excellent + intérêts nuls = recommandation possible (MVP)
Scénario: Contenu géo-ancré proche avec intérêts nuls reste recommandable
Étant donné qu'un contenu géo-ancré "Info trafic local" est à 100m de l'utilisateur
Et que le contenu est tagué "Actualités" et "Trafic"
Et que l'utilisateur a des jauges à 0% pour ces tags (aucun intérêt marqué)
Et que le score_geo = 1.0 (distance 100m, excellent)
Et que le score_interets = 0.0 (jauges nulles)
Et que le score_engagement = 0.6 (contenu récent, peu d'historique)
Quand l'algorithme calcule le score_final pour un contenu géo-ancré
Alors score_final = (1.0 × 0.7) + (0.0 × 0.1) + (0.6 × 0.2)
Et score_final = 0.7 + 0.0 + 0.12 = 0.82
Et le contenu peut être recommandé malgré l'intérêt nul
Et ce comportement est accepté pour MVP car:
| justification |
| Le quota 6 contenus géolocalisés/h protège du spam |
| L'info peut être utile contextuellement |
| La distinction info/divertissement est reportée post-MVP|
Scénario: Contenu géo-neutre loin avec intérêts élevés recommandé
Étant donné qu'un contenu géo-neutre "Podcast philosophie" est à 150 km
Et que le contenu est tagué "Philosophie" et "Culture"
Et que l'utilisateur a des jauges à 90% pour ces tags
Et que le score_geo = 0.25 (150 km de distance)
Et que le score_interets = 0.9 (jauges élevées)
Et que le score_engagement = 0.7
Quand l'algorithme calcule le score_final pour un contenu géo-neutre
Alors score_final = (0.25 × 0.2) + (0.9 × 0.6) + (0.7 × 0.2)
Et score_final = 0.05 + 0.54 + 0.14 = 0.73
Et le contenu est bien recommandé grâce aux intérêts élevés
Scénario: Comparaison scores - géo proche vs intérêts élevés
Étant donné deux contenus:
| contenu | type | distance | score_geo | tags | jauges_user | score_interets | score_engagement |
| Info trafic locale | Géo-ancré | 100m | 1.0 | Trafic | 0% | 0.0 | 0.6 |
| Podcast philosophie | Géo-neutre | 150 km | 0.25 | Philosophie | 90% | 0.9 | 0.7 |
Quand l'algorithme calcule les scores finaux
Alors score_final("Info trafic locale") = 0.82
Et score_final("Podcast philosophie") = 0.73
Et "Info trafic locale" sera proposé avant "Podcast philosophie"
Et les deux contenus sont recommandables selon leurs critères différents

View File

@@ -0,0 +1,325 @@
# language: fr
@api @search @geolocation @mvp
Fonctionnalité: Recherche géographique de contenus avec Nominatim
En tant qu'utilisateur de RoadWave
Je veux rechercher des contenus audio dans une zone géographique spécifique
En utilisant un nom de lieu (ville, monument, région) et un rayon de recherche
Afin de découvrir des contenus avant de me déplacer ou planifier un trajet
Contexte:
Étant donné un utilisateur authentifié
Et l'API Nominatim est accessible
# ============================================================================
# GEOCODAGE AVEC NOMINATIM (lieu → coordonnées GPS)
# ============================================================================
Scénario: Recherche par nom de ville (cas simple)
Étant donné l'utilisateur saisit "Paris" dans la recherche géographique
Quand le système interroge Nominatim avec la requête "Paris, France"
Alors Nominatim doit retourner les coordonnées :
| lat | 48.8566 |
| lon | 2.3522 |
| type | city |
| bbox | (48.815, 48.902, 2.224, 2.470) |
Et le système doit utiliser ces coordonnées comme centre de recherche
Scénario: Recherche par monument ou POI (Point of Interest)
Étant donné l'utilisateur saisit "Tour Eiffel"
Quand le système interroge Nominatim
Alors Nominatim doit retourner :
| lat | 48.8584 |
| lon | 2.2945 |
| display_name| Tour Eiffel, Paris, France |
| type | tourism |
Et ces coordonnées doivent être utilisées pour la recherche de contenus
Scénario: Recherche avec ambiguïté (plusieurs résultats)
Étant donné l'utilisateur saisit "Montmartre"
Quand le système interroge Nominatim
Alors plusieurs résultats doivent être retournés :
| display_name | lat | lon |
| Montmartre, Paris 18e, France | 48.8867 | 2.3431 |
| Montmartre, Saskatchewan, Canada | 49.3000 | -106.0 |
| Montmartre-de-Bretagne, Ille-et-Vilaine | 48.1867 | -1.1833|
Et l'utilisateur doit choisir dans une liste déroulante
Et le choix par défaut doit être le résultat français si détecté
Scénario: Recherche avec contexte géographique (biais local)
Étant donné l'utilisateur est actuellement à Lyon (GPS actif)
Et l'utilisateur saisit "Bellecour"
Quand le système interroge Nominatim avec viewbox="Lyon area"
Alors Nominatim doit prioriser les résultats proches de Lyon
Et "Place Bellecour, Lyon" doit apparaître en premier
Avant "Bellecour, Jura" ou d'autres homonymes
Scénario: Recherche par code postal
Étant donné l'utilisateur saisit "75001"
Quand le système interroge Nominatim
Alors Nominatim doit retourner le centre du 1er arrondissement de Paris
Et un rayon par défaut de 1km doit être appliqué
Scénario: Recherche invalide ou introuvable
Étant donné l'utilisateur saisit "Azertyuiopqsdfghjklm"
Quand le système interroge Nominatim
Alors Nominatim doit retourner 0 résultat
Et un message d'erreur doit être affiché :
"""
Aucun lieu trouvé pour "Azertyuiopqsdfghjklm".
Essayez un nom de ville, monument ou adresse.
"""
# ============================================================================
# RAYON DE RECHERCHE CONFIGURABLE
# ============================================================================
Scénario: Recherche avec rayon par défaut (5 km)
Étant donné l'utilisateur cherche "Louvre, Paris"
Et Nominatim retourne les coordonnées (48.8606, 2.3376)
Et aucun rayon n'est spécifié
Quand le système effectue la recherche de contenus
Alors un rayon par défaut de 5 km doit être appliqué
Et une requête PostGIS doit être exécutée :
"""
SELECT * FROM contents
WHERE ST_DWithin(location::geography, ST_MakePoint(2.3376, 48.8606)::geography, 5000)
"""
Scénario: Recherche avec rayon personnalisé (slider 1-50 km)
Étant donné l'utilisateur cherche "Lyon"
Et l'utilisateur ajuste le slider de rayon à 15 km
Quand le système effectue la recherche de contenus
Alors une requête PostGIS avec rayon 15000m doit être exécutée
Et tous les contenus dans un rayon de 15 km autour de Lyon doivent être retournés
Scénario: Rayon minimum (1 km) - recherche ultra locale
Étant donné l'utilisateur cherche "Place de la Concorde"
Et l'utilisateur définit le rayon à 1 km (minimum)
Quand le système effectue la recherche
Alors seuls les contenus très proches (<1 km) doivent être retournés
Et le nombre de résultats peut être très faible (0-10)
Scénario: Rayon maximum (50 km) - recherche large
Étant donné l'utilisateur cherche "Versailles"
Et l'utilisateur définit le rayon à 50 km (maximum)
Quand le système effectue la recherche
Alors tous les contenus dans un rayon de 50 km doivent être retournés
Et cela peut inclure Paris, banlieue et zones périphériques
Et un avertissement "Résultats nombreux, affinez votre recherche" peut s'afficher si >500 résultats
Scénario: Mise à jour temps réel du rayon avec slider
Étant donné l'utilisateur visualise les résultats pour "Bordeaux" avec rayon 10 km
Quand l'utilisateur ajuste le slider de 10 km à 20 km
Alors une nouvelle requête API doit être déclenchée automatiquement
Et les résultats doivent être mis à jour en temps réel
Et la carte (si affichée) doit ajuster le cercle de rayon
# ============================================================================
# FILTRES COMBINÉS AVEC RECHERCHE GÉO
# ============================================================================
Scénario: Recherche géo + filtre catégorie
Étant donné l'utilisateur cherche "Marseille" avec rayon 10 km
Et l'utilisateur filtre par catégorie "Tourisme"
Quand le système effectue la recherche
Alors seuls les contenus touristiques dans le rayon doivent être retournés
Et la requête SQL doit combiner :
"""
WHERE ST_DWithin(...) AND category_id IN (SELECT id FROM categories WHERE name = 'Tourisme')
"""
Scénario: Recherche géo + filtre type de contenu
Étant donné l'utilisateur cherche "Strasbourg" avec rayon 5 km
Et l'utilisateur filtre par type "Audio-guides"
Quand le système effectue la recherche
Alors seuls les audio-guides dans le rayon doivent être retournés
Et les podcasts et radios live doivent être exclus
Scénario: Recherche géo + filtre durée
Étant donné l'utilisateur cherche "Nice" avec rayon 15 km
Et l'utilisateur filtre par durée "Court (<10 min)"
Quand le système effectue la recherche
Alors seuls les contenus de <10 minutes dans le rayon doivent être retournés
Scénario: Recherche géo + mode Kids actif
Étant donné l'utilisateur cherche "Disneyland Paris" avec rayon 5 km
Et le mode Kids est activé (utilisateur 13-15 ans)
Quand le système effectue la recherche
Alors seuls les contenus "Tout public" doivent être retournés
Et les contenus 16+ et 18+ doivent être exclus automatiquement
Scénario: Recherche géo + filtre politique désactivé
Étant donné l'utilisateur cherche "Assemblée Nationale" avec rayon 2 km
Et l'utilisateur a désactivé les contenus politiques dans ses préférences
Quand le système effectue la recherche
Alors les contenus taggés "politique" doivent être exclus
Même s'ils sont géographiquement pertinents
# ============================================================================
# AFFICHAGE DES RÉSULTATS
# ============================================================================
Scénario: Résultats triés par distance croissante
Étant donné l'utilisateur cherche "Arc de Triomphe" avec rayon 3 km
Quand les résultats sont retournés
Alors ils doivent être triés par distance croissante :
| contenu | distance |
| Balade sur les Champs | 0.1 km |
| Histoire de l'Arc | 0.2 km |
| Quartier de l'Étoile | 0.5 km |
| Secrets du 16e | 2.8 km |
Et la distance doit être affichée pour chaque résultat
Scénario: Affichage carte avec marqueurs de contenus
Étant donné l'utilisateur cherche "Toulouse" avec rayon 10 km
Quand les résultats sont affichés en mode "Carte"
Alors une carte Leaflet doit être affichée
Et un marqueur doit être placé pour chaque contenu :
| contenu | lat | lon | icone |
| Capitole de Toulouse | 43.6045 | 1.4442 | pin-tourisme |
| Bords de Garonne | 43.5986 | 1.4330 | pin-nature |
Et un cercle représentant le rayon de 10 km doit être affiché
Et le centre doit être le point géocodé de Toulouse
Scénario: Clustering de marqueurs si résultats nombreux
Étant donné l'utilisateur cherche "Paris" avec rayon 20 km
Et la recherche retourne 350 contenus
Quand la carte est affichée
Alors les marqueurs proches doivent être groupés en clusters :
| cluster_center | nb_contenus |
| Centre Paris | 120 |
| La Défense | 45 |
| Bois de Vincennes | 18 |
Et en cliquant sur un cluster, le zoom doit s'approcher
Et révéler les marqueurs individuels
Scénario: Affichage liste + carte simultanés (vue hybride)
Étant donné l'utilisateur cherche "Nantes" avec rayon 8 km
Quand l'utilisateur active la vue "Hybride"
Alors la liste de résultats doit s'afficher à gauche (60% écran)
Et la carte doit s'afficher à droite (40% écran)
Et en cliquant sur un résultat dans la liste, le marqueur doit être highlighté sur la carte
Et vice-versa
# ============================================================================
# PERFORMANCES & OPTIMISATIONS
# ============================================================================
Scénario: Cache résultats recherche géo fréquente (Paris, Lyon, Marseille)
Étant donné l'utilisateur cherche "Paris" avec rayon 5 km
Quand la requête est exécutée pour la 1ère fois
Alors les résultats doivent être mis en cache Redis pendant 10 minutes
Et les requêtes suivantes identiques doivent utiliser le cache
Et un header "X-Cache: HIT" doit être retourné
Scénario: Index PostGIS pour performances requêtes spatiales
Étant donné la table "contents" contient 100 000 contenus
Quand une recherche géo est exécutée avec ST_DWithin
Alors un index GIST sur la colonne "location" doit être utilisé
Et le temps de réponse doit être <100ms (p95)
Scénario: Pagination résultats nombreux
Étant donné l'utilisateur cherche "Île-de-France" avec rayon 50 km
Et la recherche retourne 1200 contenus
Quand les résultats sont affichés
Alors seuls les 50 premiers résultats doivent être chargés initialement
Et un scroll infini doit charger les résultats suivants par batch de 50
Et le total "1200 contenus trouvés" doit être affiché en haut
# ============================================================================
# EDGE CASES & ERREURS
# ============================================================================
Scénario: Nominatim API indisponible (fallback)
Étant donné l'utilisateur cherche "Lille"
Quand l'API Nominatim retourne une erreur 503 (service unavailable)
Alors un message d'erreur doit être affiché :
"""
Le service de recherche géographique est temporairement indisponible.
Veuillez réessayer dans quelques instants.
"""
Et l'utilisateur doit pouvoir utiliser la recherche textuelle classique
Scénario: Recherche géo sans connexion Internet
Étant donné l'utilisateur est en mode offline
Quand l'utilisateur tente une recherche géographique
Alors un message doit indiquer :
"""
La recherche géographique nécessite une connexion Internet.
Vous pouvez parcourir les contenus téléchargés.
"""
Scénario: Rate limiting Nominatim (1 req/s)
Étant donné Nominatim impose un rate limit de 1 requête/seconde
Et l'utilisateur tape rapidement "Par" "Pari" "Paris"
Quand le système détecte plusieurs requêtes rapides
Alors un debounce de 500ms doit être appliqué
Et seule la dernière requête ("Paris") doit être envoyée à Nominatim
Scénario: Recherche avec caractères spéciaux ou injection SQL
Étant donné l'utilisateur saisit "Paris'; DROP TABLE contents; --"
Quand le système traite la requête
Alors les caractères spéciaux doivent être échappés
Et aucune injection SQL ne doit être possible
Et Nominatim doit retourner 0 résultat (lieu invalide)
# ============================================================================
# SAUVEGARDE DES RECHERCHES (max 5)
# ============================================================================
Scénario: Sauvegarde automatique d'une recherche géo
Étant donné l'utilisateur effectue une recherche "Bordeaux" avec rayon 10 km
Quand la recherche est validée
Alors elle doit être sauvegardée dans l'historique :
| search_query | Bordeaux |
| radius_km | 10 |
| lat | 44.8378 |
| lon | -0.5792 |
| timestamp | 2026-02-03 14:30:00 |
Et l'utilisateur peut la rappeler via "Recherches récentes"
Scénario: Limite de 5 recherches sauvegardées (FIFO)
Étant donné l'utilisateur a déjà 5 recherches sauvegardées
Quand l'utilisateur effectue une 6ème recherche "Montpellier"
Alors la recherche la plus ancienne doit être supprimée
Et "Montpellier" doit être ajoutée en 1ère position
Et la limite de 5 recherches doit être respectée
Scénario: Rejouer une recherche sauvegardée
Étant donné l'utilisateur a une recherche sauvegardée "Lyon - 15 km"
Quand l'utilisateur clique sur cette recherche dans l'historique
Alors la recherche doit être ré-exécutée avec les mêmes paramètres
Et les résultats actualisés doivent être affichés
Et le rayon slider doit être positionné à 15 km
# ============================================================================
# MÉTRIQUES & ANALYTICS
# ============================================================================
Scénario: Logging des recherches géographiques pour analytics
Étant donné l'utilisateur effectue une recherche "Grenoble" avec rayon 12 km
Quand la recherche est exécutée
Alors un événement analytics doit être loggé :
| event_type | geo_search_executed |
| search_query | Grenoble |
| radius_km | 12 |
| results_count | 87 |
| response_time_ms | 145 |
| cache_hit | false |
| user_id | user-123 |
| timestamp | 2026-02-03 14:30:00 |
Et ces données doivent alimenter le dashboard de monitoring
Scénario: Top recherches géographiques (analytics)
Étant donné le système analyse les recherches sur 30 jours
Quand le dashboard analytics est consulté
Alors les lieux les plus recherchés doivent être affichés :
| lieu | nb_recherches |
| Paris | 12450 |
| Lyon | 5632 |
| Marseille | 4521 |
| Toulouse | 3890 |
| Nice | 3124 |
Et ces données doivent guider la création de contenus ciblés