DOCS/test-plan.md
Thomas Fransolet 2c49acf79e Proactif, M3, voix du CMS — et le périmètre de D0 ramené à l'installation neuve
Ce qui a été livré côté mymuseum-visitapp est répercuté dans le plan, STATUS et
le kanban. Trois corrections de docs, toutes vérifiées dans le code :

- Meta-Rayban-Test et AI-Assistant-test sont les branches de travail à jour, pas
  des POC. Le §5bis affirmait le contraire et c'est ce qui avait fabriqué la
  réserve « à clarifier avant K6 ». Il n'y a rien à clarifier.
- « L'APK se construit sans le POC dedans » était faux : les 4 erreurs Dart
  étaient dans deux ancêtres morts, pas dans le POC vivant, qui est bien
  embarqué. Conséquence inverse de celle qui était écrite — « démontrable » ne
  demandait pas de remettre les secrets ElevenLabs.
- « TTS = ElevenLabs, basculer sur Gemini avant de vendre l'add-on » : déjà fait.
  constants.dart dit « ElevenLabs retiré du pipeline (trop cher) ».

Nouveau, et non résolu : les déclenchements proactifs écriraient leur prompt
machine dans VisitorQuestion, donc dans l'onglet « Ce que demandent vos
visiteurs » — et gonfleraient le bloc « questions sans réponse ». Même famille
que WeatherSyncService noyant le journal d'audit. Carte en Bugs ouverts, à
traiter dans manager-service puis mymuseum-visitapp avant d'allumer le mode.

D0 — périmètre ramené aux 15 cas d'origine : aucun visiteur n'a l'app installée
et la consigne est « supprimer puis réinstaller ». Vérifié que _onCreate contient
déjà dateUpdate, donc une installation neuve naît en v4 sans passer par
_onUpgrade. Le code qui gère les .unknown reste, il ne coûte rien. Et 21.2.1 est
réécrit : il testait un scénario impossible, remplacer une image créant un
nouvel id.

Kanban : M3 fermé, la carte proactif devient un bug bloquant, trois entrées en
Fait. Compteurs mesurés — Bugs ouverts 2, Planifié 24, Fait récemment 54.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:58:10 +02:00

909 lines
58 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Plan de test — MyInfoMate
> Légende : ✓ = case à cocher · ❌ Non implémenté (hors scope test) · ⚠️ Partiel
---
## 0. Porte d'entrée — « est-ce que ça build ? »
> **À passer en premier, avant tout test fonctionnel.** Tester une app qui ne compile pas fait perdre
> une demi-journée. Ces 5 commandes sont la condition minimale pour parler de déploiement.
>
> Dernière mesure : **2026-08-06** — les 4 apps du périmètre buildent ; seul `tablet-app` reste cassé.
| # | Repo | Commande | Attendu | Mesuré le 2026-08-06 | ✓ |
|---|---|---|---|---|---|
| 0.1 | manager-service | `dotnet build` | succès | ✅ succès (warnings CA1416 / MSB3277 tolérés) | |
| 0.2 | manager-service | `dotnet test ManagerService.Tests/` | tests verts | ✅ **124/124** (2026-08-06). Réparé : fakes remis à niveau (`FakeEmailService`, `FakeConfiguration`, `IHttpContextAccessor`), tests d'autorisation manquants, méthodes devenues async. **A révélé un vrai bug backend** — voir 0.2bis | |
| 0.2bis | manager-app | `flutter test test/progression_mode_test.dart` | 12/12 | ✅ mapping des 3 questions ↔ booléens du GuidedPath, dont l'aller-retour | |
| 0.3 | manager-app | `flutter build web` | `√ Built build\web` | ✅ après correction du `// @dart=2.18` manquant dans `manager_api_new/lib/api/onboarding_api.dart` (cassait le build avant) | |
| 0.4 | mymuseum-visitapp | `flutter build apk --debug` | APK produits sous `build/app/outputs/apk/<flavor>/debug/` | ✅ après la même correction + typage de `guided_step_challenge.dart:83`. ⚠️ le message « failed to produce an .apk » est **normal** : le projet a 3 flavors (`dev`, `fortsaintheribert`, `mdlf`), l'outil cherche un chemin sans flavor. Vérifier la présence des `.apk`, pas le message | |
| 0.5 | visitapp-web | `npm run build` | build Next OK | ✅ après extraction de `getGeoPointLatLng()` dans `src/lib/geo.ts` (les 7 erreurs venaient d'un `unknown` non narrowé). ⚠️ `next dev` **ne montre pas** ce type d'erreur (Turbopack ne type-checke pas) → toujours valider avec `npm run build`, jamais avec `dev` seul | |
| 0.6 | tablet-app | `flutter build apk --debug` | APK produit | ❌ échec Gradle sur `flutter_tools/gradle/build.gradle.kts:7` — pas lié au client API, à investiguer | |
**Règle** : une ligne rouge ici = pas de déploiement de l'app concernée, quel que soit l'état des
tests fonctionnels ci-dessous.
---
## Table des matières
0. [Porte d'entrée — « est-ce que ça build ? »](#0-porte-dentrée--est-ce-que-ça-build-) ⚠️ **à passer en premier**
1. [Migration DB & prérequis](#1-migration-db--prérequis)
2. [SectionAgenda](#2-sectionagenda)
3. [SectionWeather](#3-sectionweather)
4. [SectionEvent](#4-sectionevent)
5. [Game — Escape & Puzzle](#5-game--escape--puzzle)
6. [SectionMap / Parcours guidés](#6-sectionmap--parcours-guidés)
7. [Notifications push](#7-notifications-push)
8. [Statistiques](#8-statistiques)
9. [Beacons / géofencing](#9-beacons--géofencing)
10. [AI Assistant](#10-ai-assistant)
11. [Quotas & Plans d'abonnement](#11-quotas--plans-dabonnement)
12. [API Keys — date d'expiration](#12-api-keys--date-dexpiration)
13. [Manager-app — GuidedStep champs avancés](#13-manager-app--guidedstep-champs-avancés)
14. [Manager-app — SectionEvent annotations par bloc](#14-manager-app--sectionevent-annotations-par-bloc)
15. [Backend — Bug GuidedPath SectionGameId](#15-backend--bug-guidedpath-sectiongameid)
16. [Non-régression](#16-non-régression)
17. [SectionVideo — multi-sources](#17-sectionvideo--multi-sources)
18. [Onboarding self-service (plan Essentiel)](#18-onboarding-self-service-plan-essentiel)
19. [**Parité manager-app → apps visiteur (par type de section)**](#19-parité-manager-app--apps-visiteur-par-type-de-section)
20. [Écarts de parité connus — à rejouer après correction](#20-écarts-de-parité-connus--à-rejouer-après-correction)
21. [Visite hors ligne — diagnostic terrain](#21-visite-hors-ligne--diagnostic-terrain) ⚠️ **bugs suspectés, à jouer tôt**
---
## 1. Migration DB & prérequis
**Avant de commencer :** `dotnet ef database update` appliqué sur tous les contextes.
| # | Vérification | Résultat attendu | ✓ |
|---|-------------|-----------------|---|
| 1.1 | Table `SubscriptionPlans` existe | Colonnes `Id`, `Name`, `StorageQuotaBytes`, `AiTokensPerMonth` (bigint) | |
| 1.2 | Table `Instances` — nouvelles colonnes | `SubscriptionPlanId`, `AiTokensThisMonth` (bigint), `AiUsageMonthKey` présentes | |
| 1.3 | Table `Resources` — colonne `SizeBytes` | Présente, défaut `0` | |
| 1.4 | Table `EventAgendas` — colonnes vidéo | `IsSynced`, `IdVideoYoutube`, `VideoLink`, `VideoResourceId` présentes | |
| 1.5 | Hangfire dashboard accessible | `/hangfire` — jobs récurrents `agenda-sync-daily`, `weather-sync-morning`, `weather-sync-afternoon` listés | |
---
## 2. SectionAgenda
### 2.1 Synchro Hangfire des events
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 2.1.1 | Déclencher manuellement le job `agenda-sync-daily` depuis Hangfire | Events chargés depuis `agenda.php`, upsert en base avec `IsSynced = true` | |
| 2.1.2 | Modifier une `SectionAgenda` avec `isOnlineAgenda = true` dans manager-app | Job déclenché automatiquement, events mis à jour | |
| 2.1.3 | Créer un event manuel (depuis manager-app) sur une SectionAgenda avec synchro active | Event avec `IsSynced = false` coexiste avec les events synchros | |
### 2.2 Endpoint events upcoming
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 2.2.1 | `GET /api/SectionAgenda/{id}/events/upcoming` | 200 + liste filtrée `dateFrom >= aujourd'hui (début du jour)` | |
| 2.2.2 | Vérifier qu'un event dont `dateFrom` = hier n'apparaît pas | Event passé absent de la réponse | |
| 2.2.3 | Vérifier qu'un event dont `dateFrom` = aujourd'hui apparaît | Event du jour **présent** dans la réponse | |
| 2.2.4 | Visitapp et tablet-app — affichage SectionAgenda | Events chargés via l'endpoint backend (pas l'URL externe directe) | |
### 2.3 EventAgenda — infos complètes + vidéo
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 2.3.1 | Ajouter un `IdVideoYoutube` sur un event dans manager-app | Champ sauvegardé en base | |
| 2.3.2 | Tapper sur l'event dans visitapp/tablet-app | Popup affiche le player YouTube | |
| 2.3.3 | Ajouter un `VideoLink` (lien direct) | Popup affiche le lecteur vidéo via lien direct | |
| 2.3.4 | Associer une ressource vidéo interne (`VideoResourceId`) | Popup affiche la vidéo depuis la ressource | |
| 2.3.5 | Event sans vidéo | Popup normal sans bloc vidéo | |
---
## 3. SectionWeather
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 3.1 | Vérifier les jobs dans Hangfire dashboard | `weather-sync-morning` (6h00) et `weather-sync-afternoon` (13h00) présents et schedulés | |
| 3.2 | Déclencher `weather-sync-morning` manuellement | Toutes les `SectionWeather` avec une ville non-vide sont mises à jour | |
| 3.3 | SectionWeather avec ville vide | Pas de crash, section ignorée | |
---
## 4. SectionEvent
### 4.1 Page dédiée (visitapp)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 4.1.1 | Tapper sur une section de type `SectionEvent` | Page SectionEvent s'ouvre (pas de navigation vide) | |
| 4.1.2 | Header de la page | Titre de l'event, dates `startDate…endDate`, image de fond | |
| 4.1.3 | Onglet **Programme** | Timeline verticale des `ProgrammeBlock` — bloc actif mis en évidence | |
| 4.1.4 | Tapper sur un bloc programme | BottomSheet détail + annotations du bloc | |
| 4.1.5 | Onglet **Carte** | FlutterMap chargé depuis `baseSectionMapId`, couche annotations globales | |
| 4.1.6 | Bloc actif sélectionné sur carte | Annotations du bloc affichées en couleur distincte | |
| 4.1.7 | Onglet **Parcours** | Liste des `GuidedPath` liés à l'event, tap → `GuidedPathMapProgressionPage` | |
| 4.1.8 | Bouton retour | Cerclé `kMainColor`, positionné en `top: 35, left: 10` | |
| 4.1.9 | Titre/description multilingues | Affichés dans la langue active du visiteur | |
### 4.2 Mise en avant home screen
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 4.2.1 | Configuration avec `sectionEventId` renseigné | Bloc "à la une" affiché en haut de `home_3.0` à la place de la photo principale | |
| 4.2.2 | Contenu du bloc | Image de l'event, titre, bouton "Ouvrir" | |
| 4.2.3 | Tapper "Ouvrir" | Navigue vers la page SectionEvent | |
| 4.2.4 | Configuration **sans** `sectionEventId` | Home affiche la photo principale normale | |
---
## 5. Game — Escape & Puzzle
### 5.1 Escape game
> ⚠️ **Obsolète (révisé 2026-08-05)** : `GameTypes.Escape` a été retiré du backend — l'enum ne contient
> plus que `Puzzle` et `SlidingPuzzle`. Un escape game se configure désormais via **SectionParcours**
> avec `IsGameMode = true` sur le GuidedPath. Les tests correspondants sont au **§19.13, cas D et E**.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 5.1.1 | Ouvrir une SectionGame | Aucun onglet / type « Escape » proposé nulle part dans manager-app | |
| 5.1.2 | Escape game | → tester via SectionParcours, §19.13 cas D | |
| 5.1.3 | `GameTypes.Puzzle` ou null | Comportement puzzle inchangé | |
### 5.2 Puzzle sliding
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 5.2.1 | Ouvrir un jeu de type `SlidingPuzzle` | Puzzle affiché avec `SlidingPuzzlePiece` — coins arrondis, ombres | |
| 5.2.2 | Glisser une pièce | Animation de glissement fluide | |
| 5.2.3 | Résoudre le puzzle | Victoire détectée | |
| 5.2.4 | Mélange initial | Toujours solvable (algorithme validé) | |
| 5.2.5 | Aide visuelle | Numéros sur les tuiles visibles | |
---
## 6. SectionMap / Parcours guidés
### 6.1 Affichage des GuidedPaths sur la carte
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 6.1.1 | SectionMap avec `guidedPaths` non vides | Bouton "Parcours" visible dans `map_page.dart` | |
| 6.1.2 | Tapper "Parcours" | `GuidedPathListSheet` — bottom sheet avec liste triée par `order` | |
| 6.1.3 | Tapper sur un parcours | `GuidedPathMapProgressionPage` — carte plein écran avec pins | |
| 6.1.4 | Pins des étapes | ✓ complétée · ◉ courante (animation pulsante) · ○ suivante · 🔒 verrouillée | |
| 6.1.5 | `hideNextStepsUntilComplete = true` | Pins futurs masqués, badge progression affiché | |
### 6.2 Vue liste
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 6.2.1 | SectionMap avec `isListViewEnabled = true` | Bouton toggle "Liste / Carte" en bas à droite | |
| 6.2.2 | Basculer en vue liste | `ListView` des geopoints filtrés (titre, description, miniature) | |
| 6.2.3 | `isListViewEnabled = false` | Bouton absent, carte seule | |
### 6.3 Parcours guidés — UI de progression (GuidedPathMapProgressionPage)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 6.3.1 | Position live sur la carte | Point de position mise à jour en temps réel | |
| 6.3.2 | `GuidedStepTimer` | Countdown MM:SS en mode peek, barre de progression colorée en mode étendu | |
| 6.3.3 | Quiz par étape | `QuestionsListWidget` affiché, réponses évaluées | |
| 6.3.4 | `requireSuccessToAdvance = true` | Bouton Suivant désactivé si quiz raté | |
| 6.3.5 | `isLinear = true` | Navigation séquentielle, pas de bouton Précédent | |
| 6.3.6 | Géodéclenchement | Indicateur "Approchez-vous (Xm)" → "Vous êtes dans la zone ✓" selon `zoneRadiusMeters` | |
| 6.3.7 | `GuidedPathContentProgressionPage` (escape) | Même logique, vue plein écran centrée sur le contenu sans carte | |
### 6.4 Annotations carte — SectionEvent
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 6.4.1 | `globalMapAnnotations` | Affichées en couleur principale dans la preview et `EventMapFullPage` | |
| 6.4.2 | Annotations du bloc actif | Affichées en orange par-dessus les globales | |
| 6.4.3 | `MapAnnotation.geometry.coordinates` | Accessibles (fix client généré `EventAddressDTOGeometry`) | |
---
## 7. Notifications push
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 7.1 | Envoyer une notification depuis manager-app | Notification reçue sur le device visitapp | |
| 7.2 | App en **foreground** | `MaterialBanner` affiché dans `configuration_page.dart` | |
| 7.3 | App en **background** | Notification locale affichée dans la barre système | |
| 7.4 | App **terminée** | Notification affichée, tap rouvre l'app | |
| 7.5 | Tap sur notification | Navigue vers le bon écran | |
| 7.6 | Subscribe/unsubscribe dans `home_3.0` | Préférence sauvegardée, plus de notifs si unsubscribed | |
| 7.7 | ⚠️ **Manuel** | `google-services.json` et `GoogleService-Info.plist` placés dans les bons dossiers | |
---
## 8. Statistiques
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 8.1 | Ouvrir une section puis la quitter | Event `sectionView` + `sectionLeave` (avec durée) envoyé | |
| 8.2 | Compléter un quiz | Event `quizComplete` avec `score` et `totalQuestions` | |
| 8.3 | Terminer un jeu | Event `gameComplete` avec `gameType` et durée | |
| 8.4 | Tapper un POI sur la carte | Event `mapPoiTap` avec `geoPointId` et titre | |
| 8.5 | Lire un article | Event `articleRead` | |
| 8.6 | Vérifier les stats dans manager-app | Events visibles dans l'écran statistiques | |
### 8bis. Écran « Fréquentation » refondu (jamais lancé — livré le 2026-08-07)
Voir [v2/stats-screen-plan.md](v2/stats-screen-plan.md) § État d'implémentation.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 8.7 | Ouvrir l'écran sur une instance **à un seul canal** | Pas de filtre « Canal », pas de carte « Canaux », 4ᵉ KPI = « Contenus consultés » | |
| 8.8 | Ouvrir l'écran sur une instance **multi-canal** | Une chip par canal réellement présent, chacune avec son volume ; aucune chip pour un canal que l'instance n'a pas | |
| 8.9 | Changer de période (7 / 30 / 90 / Année) | Les périodes au-delà de `StatsHistoryDays` sont grisées ; le sous-titre de date suit la sélection | |
| 8.10 | Regarder les variations des KPI sur un plan à historique court (30 j) en période 30 j | **Aucune variation affichée** — la période précédente sortirait de l'historique et serait tronquée par le backend | |
| 8.11 | Contenu au titre long (« La vie quotidienne des soldats ») | Titre complet sur 2 lignes max, jamais tronqué à 8 caractères | |
| 8.12 | Survoler la courbe | Trait vertical + date et nombre de visites ; le pic est cerclé et nommé sous le graphe | |
| 8.13 | Période contenant des jours sans aucun event | La courbe descend à zéro ces jours-là au lieu de les sauter ; les bandes de week-end tombent bien sur samedi/dimanche | |
| 8.14 | Instance **sans** `HasAdvancedStats` | Bloc « plan Premium » à la place des tableaux détaillés, carte « Langues » absente (le backend vide la distribution) | |
| 8.15 | Basculer la langue du manager en NL puis EN | Toutes les chaînes suivent, y compris les deux phrases du bandeau « à retenir » | |
### 8ter. Export PDF à la demande (livré le 2026-08-07)
`flutter test test/statistics_report_test.dart` couvre la génération du document (4 cas). Ce qui suit ne se vérifie que dans un vrai navigateur.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 8.16 | Cliquer sur « Télécharger le PDF » dans le bloc rapport | Le fichier se télécharge, nommé `<instance>-frequentation-<date>.pdf` | |
| 8.17 | Ouvrir le PDF | Page de garde au nom de l'instance, filet à sa couleur principale, période lisible ; les chiffres sont **identiques** à ceux de l'écran | |
| 8.18 | Vérifier les accents et les guillemets (« Fréquentation », « Connais-tu… ») | Rendus correctement — la police OpenSans est embarquée dans le document | |
| 8.19 | Instance dont l'`ApplicationInstance` mobile porte un logo | Logo en page de garde. **S'il manque**, c'est le CORS du bucket Firebase : le rapport doit quand même se générer, sans logo | |
| 8.20 | Instance **sans** `HasAdvancedStats` | Pas de tableaux POI/quiz/jeux/QR dans le PDF, et la 4ᵉ puce du bloc rapport n'est pas affichée | |
| 8.21 | Période sans aucune visite | Le bouton n'est pas atteignable (l'écran affiche l'état vide) — rien à générer | |
### 8quater. Historique unifié à 13 mois (décidé et appliqué le 2026-08-09)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 8.22 | `dotnet ef database update` puis `SELECT "Id","StatsHistoryDays" FROM "SubscriptionPlans"` | 395 sur `plan-essentiel`, `plan-standard`, `plan-premium` | |
| 8.23 | `SELECT "Name","StatsHistoryDays" FROM "Instances" WHERE "HasStats"` | 395 partout — **c'est cette valeur que lit le contrôleur**, pas celle du plan. Si elle est restée à 30, la reprise SQL de la migration n'a pas tourné | |
| 8.24 | Ouvrir l'écran sur un plan Essentiel | Les 4 périodes sont sélectionnables, **« Année » comprise** — elle était grisée avant | |
| 8.25 | Sélectionner « Année » | La courbe s'affiche, mais **aucune variation sur les KPI** : comparer 365 jours exigerait 730 jours d'historique. C'est voulu — mieux vaut rien qu'un chiffre faux | |
| 8.26 | Hangfire `/hangfire` → job `visit-events-purge` | Job listé, cron 3 h. **Le déclencher à la main** : il doit logger « purge ignorée : Stats:RetentionDays non défini » et **ne rien supprimer** | |
| 8.27 | ⛔ N'activer `Stats__RetentionDays=395` **qu'après** avoir mis en place le `pg_dump` quotidien | Suppression définitive — le snapshot OVH ne permet pas de restaurer une table seule | |
---
## 9. Beacons / géofencing
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 9.1 | Visitapp en range d'un iBeacon connu | Notification locale `beaconFound` affichée | |
| 9.2 | Notification beacon | Titre/corps dans la langue active (clé `beaconFound` / `beaconFoundBody`) | |
| 9.3 | Entrée dans une zone geo d'un `GuidedStep` | Notification locale `geoZone` affichée | |
| 9.4 | Filtre par `minorId` + `accuracy` + cooldown | Pas de spam de notifications pour le même beacon | |
| 9.5 | `beacon_scanner: ^0.0.4` | Scanner opérationnel sur Android et iOS | |
---
## 10. AI Assistant
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 10.1 | Ouvrir l'assistant depuis `home_3.0.dart` | `AssistantChatSheet` s'ouvre | |
| 10.2 | Ouvrir depuis `configuration_page.dart` | Idem | |
| 10.3 | Envoyer un message | Réponse de l'assistant affichée en bulle | |
| 10.4 | Réponse avec cards | Cards affichées sous la réponse texte | |
| 10.5 | Tap sur une card de navigation | Navigue vers la bonne section | |
| 10.6 | Instance avec `isAssistant = false` | Bouton assistant absent | |
---
## 11. Quotas & Plans d'abonnement
### 11.1 CRUD SubscriptionPlan (SuperAdmin)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 11.1.1 | `POST /api/SubscriptionPlan``{ "name": "Standard", "storageQuotaBytes": 5368709120, "aiTokensPerMonth": 5000000 }` | 200 + `id` généré | |
| 11.1.2 | `POST /api/SubscriptionPlan``{ "name": "Premium", "storageQuotaBytes": 21474836480, "aiTokensPerMonth": 20000000 }` | 200 | |
| 11.1.3 | `GET /api/SubscriptionPlan` | 200 + liste des plans | |
| 11.1.4 | `PUT /api/SubscriptionPlan` — modifier `aiTokensPerMonth` | 200 + valeur mise à jour | |
| 11.1.5 | `DELETE` d'un plan **non assigné** | 202 | |
| 11.1.6 | `DELETE` d'un plan **assigné** | 409 Conflict | |
| 11.1.7 | Appel avec token non-SuperAdmin | 401 / 403 | |
> `5368709120` = 5 GB · `21474836480` = 20 GB
### 11.2 Assignation d'un plan
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 11.2.1 | `PUT /api/Instance` avec `subscriptionPlanId: "{id}"` | Plan assigné | |
| 11.2.2 | `GET /api/Instance/{id}` | Champ `subscriptionPlan` présent dans la réponse | |
| 11.2.3 | `PUT /api/Instance` avec `subscriptionPlanId: ""` | Plan effacé (null en base) | |
| 11.2.4 | `PUT /api/Instance` sans le champ `subscriptionPlanId` | Plan existant inchangé | |
### 11.3 Endpoint quota
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 11.3.1 | `GET /api/Instance/{id}/quota` — instance sans plan | `storageQuotaBytes: 0`, `aiTokensPerMonth: 0` | |
| 11.3.2 | `GET /api/Instance/{id}/quota` — instance avec plan Standard | `storageQuotaBytes: 5368709120`, `aiTokensPerMonth: 5000000` | |
| 11.3.3 | Appel avec token Viewer | 200 (l'endpoint accepte Viewer) | |
### 11.4 Tracking stockage
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 11.4.1 | Uploader une image | `Resource.SizeBytes` = taille réelle du fichier | |
| 11.4.2 | Uploader un PDF | Idem | |
| 11.4.3 | `GET /quota` après uploads | `storageUsedBytes` = somme des `SizeBytes` | |
| 11.4.4 | Supprimer une ressource + rappeler `/quota` | `storageUsedBytes` diminue | |
| 11.4.5 | Ajouter une `ImageUrl` (externe) | `SizeBytes = 0` — pas comptée | |
### 11.5 Tracking quota IA (en tokens)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 11.5.1 | Envoyer un message IA (`/api/AI/chat`) | `AiTokensThisMonth` en base = `Usage.TotalTokenCount` retourné par Gemini pour cet appel (visible dans `AiChatResponse.TokensUsed`) | |
| 11.5.2 | Envoyer un 2e message | Compteur = somme des `TokensUsed` des deux appels (pas juste `+1`) | |
| 11.5.3 | `GET /quota` | `aiTokensUsed` = valeur cumulée en base | |
| 11.5.4 | Modifier `AiUsageMonthKey``"2025-01"` en base, envoyer un message | Compteur repart au `TokensUsed` du nouvel appel (pas cumulé avec l'ancien mois), `AiUsageMonthKey` = mois courant | |
| 11.5.5 | Instance avec `AiTokensThisMonth` déjà ≥ `AiTokensPerMonth` (quota atteint) | Nouvel appel → `429 "Quota IA mensuel dépassé"`, `AssistantService` **pas appelé** (pas d'incrément supplémentaire) | |
| 11.5.6 | Appel `/api/AI/translate` sur un texte HTML long (plusieurs paragraphes, plusieurs langues cibles) vs un texte court | `TokensUsed` nettement plus élevé pour le texte long — vérifie que le comptage reflète bien le coût réel et pas juste "1 appel = 1 unité" | |
### 11.6 Widget sidebar (Flutter)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 11.6.1 | Se connecter à manager-app | Widget quota visible **au-dessus** de l'email dans la sidebar | |
| 11.6.2 | Instance sans plan | "Illimité" affiché | |
| 11.6.3 | Instance avec plan | "X MB / 5 GB" · tokens IA formatés en K/M, ex. "1.2M / 5M tokens" | |
| 11.6.4 | Stockage < 80% | Barre bleue | |
| 11.6.5 | Stockage ~85% (modifier `SizeBytes` en base) | Barre **orange** | |
| 11.6.6 | Stockage ~97% | Barre **rouge** | |
| 11.6.7 | `isAssistant = false` | Bloc IA absent | |
| 11.6.8 | `isAssistant = true` | Les deux barres visibles | |
### 11.7 Dialog config plan (SuperAdmin)
| # | Action | Résultat attendu | |
|---|--------|-----------------|---|
| 11.7.1 | Cliquer sur le bouton swap | Dialog avec sur chaque instance | |
| 11.7.2 | Cliquer sur | Dialog "Plan NomInstance" avec radio buttons | |
| 11.7.3 | Sélectionner un plan + "Enregistrer" | Plan assigné via `PUT /api/Instance` | |
| 11.7.4 | Sélectionner "Aucun plan" + "Enregistrer" | Plan effacé | |
| 11.7.5 | Connexion en non-SuperAdmin | Bouton swap invisible | |
---
## 12. API Keys — date d'expiration
| # | Action | Résultat attendu | |
|---|--------|-----------------|---|
| 12.1 | Créer une API key dans manager-app | Date picker d'expiration disponible | |
| 12.2 | Créer une key avec date d'expiration | Date sauvegardée, colonne "Expiration" affichée dans le tableau | |
| 12.3 | Key expirée | Colonne "Expiration" rouge dans le tableau | |
| 12.4 | Appel API avec une key expirée | 401 / rejeté | |
| 12.5 | Révoquer une key (`IsActive = false`) | Appels suivants avec cette key refusés | |
---
## 13. Manager-app — GuidedStep champs avancés
| # | Action | Résultat attendu | |
|---|--------|-----------------|---|
| 13.1 | Ouvrir le formulaire d'un GuidedStep | Toggles `isHiddenInitially`, `isStepLocked`, `isStepTimer` visibles | |
| 13.2 | Activer `isStepTimer` | Champs `timerSeconds` et `timerExpiredMessage` (multilingue) apparaissent | |
| 13.3 | Sauvegarder avec timer | Valeurs persistées en base | |
| 13.4 | Géodéclenchement | Zone posée via le `GeometryInputContainer` (mini-carte) + toggle `isGeoTriggered` + `zoneRadiusMeters`. **révisé 2026-08-05** : il n'y a **pas** de champ `triggerGeoPointId`, l'ancienne ligne de test portait sur un champ inexistant | |
| 13.5 | `isHiddenInitially` activé puis sauvegardé | Valeur persistée mais **aucun effet visiteur** (écart M4, voir §20) | |
---
## 14. Manager-app — SectionEvent annotations par bloc
| # | Action | Résultat attendu | |
|---|--------|-----------------|---|
| 14.1 | Ouvrir le formulaire d'un `ProgrammeBlock` | Section "Annotations" présente | |
| 14.2 | Ajouter une annotation | Formulaire `showNewOrUpdateMapAnnotation` (label, géométrie, couleur, icône) | |
| 14.3 | Modifier une annotation existante | Mise à jour sauvegardée | |
| 14.4 | Supprimer une annotation | Supprimée de la liste | |
---
## 15. Backend — Bug GuidedPath SectionGameId
| # | Action | Résultat attendu | |
|---|--------|-----------------|---|
| 15.1 | Créer un `GuidedPath` avec un `SectionGameId` | `SectionGameId` sauvegardé | |
| 15.2 | `PUT` pour mettre à jour le `GuidedPath` | `SectionGameId` **conservé** après update (pas remis à null) | |
---
## 16. Non-régression
| # | Vérification | Résultat attendu | |
|---|-------------|-----------------|---|
| 16.1 | Navigation dans toutes les sections de manager-app | Aucune régression | |
| 16.2 | Upload de ressources | Fonctionne, `SizeBytes` renseigné | |
| 16.3 | `GET /api/Instance/{id}` existant | Retourne les anciens champs + les nouveaux | |
| 16.4 | Authentification JWT manager-app | Login/logout fonctionnels | |
| 16.5 | tablet-app sélection par pincode | Inchangé | |
| 16.6 | mymuseum-visitapp boot et chargement config | Inchangé | |
| 16.7 | Swagger / OpenAPI | Tous les nouveaux endpoints documentés | |
---
## 17. SectionVideo — multi-sources
### 17.1 Configuration (manager-app)
| # | Action | Résultat attendu | |
|---|--------|-----------------|---|
| 17.1.1 | Ouvrir la config d'une SectionVideo existante avec une URL YouTube | Sélecteur positionné sur "YouTube", URL pré-remplie | |
| 17.1.2 | Ouvrir une SectionVideo avec une URL Vimeo | Sélecteur positionné sur "Vimeo" | |
| 17.1.3 | Ouvrir une SectionVideo avec une URL Firebase Storage | Sélecteur positionné sur "Ressource" | |
| 17.1.4 | Changer de mode | `source_` remis à null, champ vidé | |
| 17.1.5 | Mode Ressource sélectionner une vidéo | Modal filtré sur `Video` et `VideoUrl` uniquement, label de la ressource affiché après sélection | |
| 17.1.6 | Sauvegarder dans chaque mode | `source_` contient l'URL correcte en base | |
### 17.2 Lecture tablet-app
| # | Action | Résultat attendu | |
|---|--------|-----------------|---|
| 17.2.1 | SectionVideo avec URL YouTube | Player YouTube affiché, lecture sans erreur 152 | |
| 17.2.2 | SectionVideo avec URL Vimeo | Player Vimeo affiché via WebView | |
| 17.2.3 | SectionVideo avec URL Firebase Storage (mp4) | `VideoViewer` affiché, lecture directe | |
| 17.2.4 | `source_` vide | Message "La vidéo ne peut pas être affichée, l'url est incorrecte" | |
| 17.2.5 | URL Vimeo malformée (sans ID numérique) | Message "Impossible d'extraire l'identifiant Vimeo depuis l'URL" | |
### 17.3 Lecture mymuseum-visitapp
| # | Action | Résultat attendu | |
|---|--------|-----------------|---|
| 17.3.1 | SectionVideo avec URL YouTube | Player YouTube affiché, lecture sans erreur 152 | |
| 17.3.2 | SectionVideo avec URL Vimeo | Player Vimeo affiché via WebView | |
| 17.3.3 | SectionVideo avec URL Firebase Storage (mp4) | `VideoViewer` affiché, lecture directe | |
| 17.3.4 | Bouton retour en mode normal | Retour fonctionnel | |
| 17.3.5 | Bouton retour en mode fullscreen YouTube | `exitFullScreen()` appelé puis retour | |
---
## 18. Onboarding self-service (plan Essentiel)
Implémenté le 2026-08-05, **jamais exécuté**. La checklist détaillée (prérequis Stripe CLI,
inscription, conversion payante, watermark, auth par token, job Hangfire, quota IA) vit dans
`todo-features.md` section **« 🧪 Checklist de test end-to-end »** du chapitre
*Onboarding & Acquisition*, pour rester à côté des specs correspondantes.
Points de vigilance à ne pas oublier au moment de tester :
- Le `whsec_` de `stripe listen` est **différent** de celui du dashboard à mettre dans
`appsettings.Development.json`, sinon la vérification de signature échoue en local.
- `IsAssistant` vaut `false` par défaut sur une instance neuve à passer à `true` en base pour
pouvoir tester quoi que ce soit sur le quota IA.
- Ne pas tester la désactivation d'essai sur une instance réelle : `IsActive = false` coupe
immédiatement l'accès visiteur via la `PublicApiKey`.
---
## 19. Parité manager-app → apps visiteur (par type de section)
> **But de ce chapitre** : répondre à « tout ce que je peux configurer est-il visible côté visiteur ? ».
> Un bloc par type de section, à jouer indépendamment des autres. Analyse de référence :
> [parity-manager-visitapp.md](parity-manager-visitapp.md).
>
> **Protocole commun à tous les blocs** — 1 configuration de test, 3 fenêtres ouvertes côte à côte :
> manager-app, `mymuseum-visitapp` (device ou émulateur), `visitapp-web` (`/{slug}/{configId}`).
> Remplir **toutes** les langues activées sur au moins un champ pour vérifier le multilingue au passage.
>
> Les lignes **⚠️ écart connu** sont des échecs **attendus** aujourd'hui : elles ne sont pas des bugs à
> découvrir mais des cas à confirmer (et à rejouer après correction).
### 19.1 SectionArticle
| # | À configurer | Attendu mobile | Attendu web | |
|---|---|---|---|---|
| 19.1.1 | `content` (HTML riche : gras, liste, lien) | rendu HTML fidèle | idem | |
| 19.1.2 | `contents` (2-3 images ordonnées) | carrousel dans l'ordre `order` | carrousel `ImageCarousel` dans l'ordre | |
| 19.1.3 | `isContentTop = true` | images **au-dessus** du texte | **écart connu W5** : le carrousel est toujours au-dessus, le flag est ignoré | |
| 19.1.4 | `audioIds` (1 audio par langue) | lecteur audio présent, bonne piste selon la langue | idem | |
| 19.1.5 | `isReadAudioAuto = true` | lecture démarre seule à l'ouverture | idem | |
### 19.2 SectionSlider
| # | À configurer | Attendu mobile | Attendu web | |
|---|---|---|---|---|
| 19.2.1 | `contents` : 3 items (image + titre + description) | slider, ordre respecté | idem | |
| 19.2.2 | Un item avec une ressource vidéo | lecteur vidéo dans le slide | idem | |
### 19.3 SectionVideo / SectionWeb
> Le détail multi-sources (YouTube / Vimeo / ressource) est déjà couvert au **§17**. Ne rejouer ici que
> la parité web.
| # | À configurer | Attendu mobile | Attendu web | |
|---|---|---|---|---|
| 19.3.1 | SectionVideo `source` = YouTube | player YouTube | player YouTube | |
| 19.3.2 | SectionVideo `source` = ressource mp4 | lecture directe | lecture directe | |
| 19.3.3 | SectionWeb `source` = URL externe | WebView | iframe | |
### 19.4 SectionMenu
| # | À configurer | Attendu mobile | Attendu web | |
|---|---|---|---|---|
| 19.4.1 | 4 sous-sections de types différents | grille de 4 entrées, tap bonne section | idem | |
| 19.4.2 | Sous-section avec `isActive = false` | absente | absente | |
| 19.4.3 | `isSectionImageBackground = true` (sur l'AppConfigurationLink) | image en fond des cards du menu | **écart connu W4** : flag ignoré en web | |
### 19.5 SectionPDF
| # | À configurer | Attendu mobile | Attendu web | |
|---|---|---|---|---|
| 19.5.1 | 2 PDF, un par langue | le PDF de la langue active s'ouvre | idem (`PdfViewer`) | |
| 19.5.2 | PDF volumineux (> 10 Mo) | pas de freeze, indicateur de chargement | idem | |
### 19.6 SectionQuiz
| # | À configurer | Attendu mobile | Attendu web | ✓ |
|---|---|---|---|---|
| 19.6.1 | 4 questions QCM avec bonne/mauvaise réponse | enchaînement, score correct | idem | |
| 19.6.2 | Question avec image sur la question et sur les réponses | images affichées | images affichées | |
| 19.6.3 | Messages `bad/medium/good/great_level` remplis | message de fin correspondant au score (< 25 % bad, 75 % great) | idem (`QuizSection.tsx:74-77`) | |
| 19.6.4 | Vérifier le mapping `isGood` `isCorrect` côté web | n/a | la bonne réponse est bien marquée correcte (piège connu du client web) | |
| 19.6.5 | | rappel : une SectionQuiz **autonome** ne supporte que le QCM. Texte libre / digicode / puzzle n'existent que dans un `GuidedStep` de Parcours (chantier « unifier SectionQuiz sur QuizQuestion », non fait) | idem | |
### 19.7 SectionGame
| # | À configurer | Attendu mobile | Attendu web | |
|---|---|---|---|---|
| 19.7.1 | `gameType = Puzzle`, image + `rows`/`cols` = 3×3 | puzzle jigsaw jouable, victoire détectée | `PuzzleGame` | |
| 19.7.2 | `gameType = SlidingPuzzle` | taquin, mélange solvable | `SlidingPuzzle` | |
| 19.7.3 | `messageDebut` / `messageFin` | affichés au début / à la victoire | idem (`MessageDialog`) | |
| 19.7.4 | Aucune image de puzzle | message d'erreur propre, pas d'écran vide | idem | |
| 19.7.5 | | `GameTypes.Escape` n'existe plus : un escape game se configure via **SectionParcours** + `IsGameMode`. Voir §19.13 | idem | |
### 19.8 SectionWeather
| # | À configurer | Attendu mobile | Attendu web | |
|---|---|---|---|---|
| 19.8.1 | `city` = "Namur" | météo du jour + prévisions | idem | |
| 19.8.2 | `city` vide | pas de crash, section vide ou masquée | idem | |
| 19.8.3 | Après passage du job `weather-sync-*` | données rafraîchies dans les deux apps | idem | |
### 19.9 SectionAgenda
| # | À configurer | Attendu mobile | Attendu web | |
|---|---|---|---|---|
| 19.9.1 | 3 events manuels (dont un aujourd'hui, un passé) | l'event du jour **apparaît**, le passé non | idem | |
| 19.9.2 | Event avec adresse + géométrie | mini-carte / lien vers la position | `EventMiniMap` | |
| 19.9.3 | Event avec `idVideoYoutube`, un autre avec `videoLink`, un autre avec ressource vidéo | les 3 lecteurs fonctionnent dans le popup | idem | |
| 19.9.4 | Event avec `website`, `phone`, `email` | affichés et cliquables | affichés | |
| 19.9.5 | `isOnlineAgenda = true` + ressource JSON | events synchronisés visibles (`isSynced`), coexistent avec les manuels | idem | |
| 19.9.6 | `agendaMapProvider` = Google | carte agenda selon le provider | **écart connu W9** : Leaflet en dur | |
### 19.10 SectionMap — la section la plus à risque
| # | À configurer | Attendu mobile | Attendu web | |
|---|---|---|---|---|
| 19.10.1 | `centerLatitude` / `centerLongitude` via le picker « Centrer sur » | carte centrée sur ce point | centré (`MapSection.tsx:62`) | |
| | | 2026-08-06 priorité au « Centrer sur », repli sur le point de section puis un défaut | | |
| 19.10.2 | `zoom` = 15 | niveau de zoom respecté | idem | |
| 19.10.3 | `mapProvider` = Google puis Mapbox | la vue change de provider | **écart connu W1** : toujours OSM/Leaflet | |
| 19.10.4 | 3 catégories avec couleur **et** icône | marqueurs avec l'icône de leur catégorie | 2026-08-06 icône dans la tête du pin | |
| 19.10.5 | Filtrer par catégorie | seuls les points de la catégorie restent | idem | |
| 19.10.6 | GeoPoint : `title`, `description`, `contents`, `imageUrl` | popup complet | idem | |
| 19.10.7 | GeoPoint : `prices`, `phone`, `email`, `site` | tous affichés dans le popup | idem | |
| 19.10.8 | GeoPoint : **`schedules`** (horaires) | 2026-08-06 | bloc « Horaires » | |
| 19.10.9 | GeoPoint polygone + `polyColor` | polygone à la bonne couleur | idem | |
| 19.10.10 | `isListViewEnabled = true` | bouton bascule Liste/Carte | idem | |
| 19.10.11 | Tap sur un POI | event stat `mapPoiTap` émis | idem | |
### 19.11 SectionEvent
| # | À configurer | Attendu mobile | Attendu web | |
|---|---|---|---|---|
| 19.11.1 | `startDate` / `endDate` + image | header avec dates et image | idem | |
| 19.11.2 | `programme` : 3 blocs horaires dont un « en cours » | timeline, bloc courant mis en évidence | idem | |
| 19.11.3 | Tap sur un bloc | détail + annotations du bloc | idem | |
| 19.11.4 | `baseSectionMapId` + `globalMapAnnotations` | onglet carte avec annotations globales | `EventMap` | |
| 19.11.5 | Annotations sur un bloc précis | affichées en couleur distincte par-dessus les globales | idem | |
| 19.11.6 | GuidedPaths liés à l'event | onglet Parcours, tap progression | idem | |
| 19.11.7 | `sectionEventId` sur la config | bloc « à la une » en haut du home | idem | |
### 19.12 Home / configuration / theming (hors section)
| # | À configurer | Attendu mobile | Attendu web | |
|---|---|---|---|---|
| 19.12.1 | `primaryColor` / `secondaryColor` | couleurs appliquées partout | idem (CSS vars) | |
| 19.12.2 | `languages` : 3 langues | sélecteur avec 3 langues, contenu traduit | idem | |
| 19.12.3 | `gridColSpan` / `gridRowSpan` (bento) | placement identique à l'aperçu manager-app | idem (CSS Grid) **comparer les deux visuellement** | |
| 19.12.4 | `roundedValue` | arrondis appliqués | **écart connu W3** : ignoré | |
| 19.12.5 | `loaderImageUrl` | splash statique, pas l'URL d'instance (connu) | champ présent, pas de splash | |
| 19.12.6 | `mainImageUrl` sur l'ApplicationInstance | **écart connu M5** : mobile affiche `config.imageSource` | utilise `mainImageUrl` | |
| 19.12.7 | Instance en essai (`isTrialActive = true`) | **écart connu M6** : aucun watermark | `TrialWatermark` | |
| 19.12.8 | `isAssistant = true` **sur l'instance et sur l'app Web** | bouton assistant + chat fonctionnel | 2026-08-06. Vérifier aussi : un seul des deux drapeaux à `false` aucune bulle ; réponse avec cards ; bouton de navigation qui route vers la bonne section ; quota 429 message neutre sans mention technique | |
| 19.12.9 | Section avec `isActive = false` | absente du home | absente | |
### 19.13 SectionParcours — les 5 cas d'usage
> C'est le chantier le plus récent et le moins vérifié. Les 5 configurations ci-dessous couvrent
> l'ensemble des combinaisons vendues.
**Cas 0 — la configuration elle-même dans manager-app** **à passer avant les cas A-E**, les corrections du 2026-08-09 portent toutes sur cet écran
| # | Manipulation | Attendu | |
|---|---|---|---|
| 0.1 | Créer un parcours, **ne pas toucher** à « Comment le visiteur progresse-t-il ? », sauvegarder, rouvrir | « Dans l'ordre » toujours sélectionné, et en base `isLinear = true` / `requireSuccessToAdvance = false`. Avant le 2026-08-09 la popup affichait « Dans l'ordre » et enregistrait « Libre » le parcours ne se comportait pas comme annoncé. `flutter test test/progression_mode_test.dart` (12/12) ne voyait pas le bug : il couvre la relecture des booléens, pas le DTO créé par la popup | |
| 0.2 | Bloc « Ambiance » | Deux options radio **Visite / Jeu** (plus une case à cocher), même forme que les deux autres questions. « Jeu » les champs message de début / de fin apparaissent | |
| 0.3 | Basculer Jeu Visite après avoir saisi des messages | Les messages sont conservés en base (juste masqués), rien n'est effacé à l'insu du client | |
| 0.4 | « Sur le terrain » dans une configuration **sans aucune section Carte** | Le menu « Carte de base » reste visible mais désactivé, avec « Aucune section Carte dans cette configuration ». Avant le 2026-08-09 il disparaissait sans un mot | |
| 0.5 | Étape bloc « Contenu riche » | Le champ s'appelle **« Médias : »** et le texte vide mentionne image / vidéo / audio le sélecteur accepte les trois depuis toujours, seul le libellé disait « Images » | |
| 0.6 | Ajouter une **vidéo** puis un **audio** en média d'étape, ouvrir l'étape côté visiteur | **Mobile** : lecteur vidéo / lecteur audio dans le carrousel. **Web** : idem (`ResourceViewer`). Avant le 2026-08-09 les deux apps forçaient le rendu en image vignette cassée | |
| 0.7 | Média image, toujours côté visiteur | Tap plein écran (lightbox) comme avant. Sur une vidéo ou un audio, **pas** de lightbox : les contrôles du lecteur restent cliquables | |
**Cas A — visite guidée simple** (`ShowMap = true`, `isLinear = true`, sans question)
| # | Attendu mobile | Attendu web | |
|---|---|---|---|
| A.1 | carte + pins des étapes + position live | carte Leaflet + pins | |
| A.2 | pins : complétée / courante / suivante / 🔒 verrouillée | équivalent visuel | |
| A.3 | « Dans l'ordre » : pas de saut possible vers une étape non atteinte (pins verrouillés) ; retour arrière autorisé | 2026-08-06, identique web | |
| A.4 | `estimatedDurationMinutes` affiché sur la card du parcours | idem | |
**Cas B — parcours libre** (`ShowMap = true`, `isLinear = false`)
| # | Attendu mobile | Attendu web | |
|---|---|---|---|
| B.1 | « Libre » : tap sur n'importe quel pin ou segment on y va directement | 2026-08-06, identique web | |
| B.2 | `hideNextStepsUntilComplete = true` étapes futures masquées + badge de progression | idem | |
**Cas C — parcours pédagogique avec questions** (`ShowMap` indifférent, quiz par étape)
| # | Attendu mobile | Attendu web | |
|---|---|---|---|
| C.1 | QCM sur une étape : validation, feedback | idem | |
| C.2 | Question **texte libre** (`QuestionType.Simple`) : réponse insensible à la casse **et aux accents** | idem | |
| C.3 | Réponse attendue **numérique** pavé digicode 0-9 auto-détecté | idem (`DigicodePad`) | |
| C.4 | Question **puzzle** | idem (`QuestionPuzzle`) | |
| C.5 | `requireSuccessToAdvance = true` « Suivant » bloqué tant que raté | idem | |
| C.6 | `isStepTimer` + `timerSeconds` compte à rebours, `timerExpiredMessage` à l'expiration | idem | |
**Cas D — escape game indoor** (`ShowMap = false`, `IsGameMode = true`) **le cas le plus fragile**
| # | Attendu | Mobile | Web | |
|---|---|---|---|---|
| D.0 | **Vérifier d'abord quel mode s'affiche réellement.** **écart X1** : la bascule diffère mobile exige `baseSectionMapId`, web exige des étapes géolocalisées. Tester les 3 combinaisons du tableau X1 et noter le mode obtenu de chaque côté **avant** de conclure quoi que ce soit sur D.2-D.5 | | | |
| D.1 | `gameMessageDebut` à l'ouverture, `gameMessageFin` à la victoire | | (`StartSheet` / `EndView`) | |
| D.2 | Aucun champ de position/zone proposé sur les étapes | le bloc « Emplacement » est masqué dans manager-app quand le parcours est « En salle » | idem | |
| D.3 | Progression « Étape par étape » étape suivante non atteignable tant que le défi n'est pas réussi | 2026-08-06 | 2026-08-06 | |
| D.4 | + case « masquer les étapes pas encore atteintes » segments futurs invisibles | 2026-08-06 | 2026-08-06 | |
| D.5 | Retour arrière vers une étape déjà vue | toujours possible | toujours possible | |
| D.6 | Progression « Libre » tous les segments de la barre sont cliquables | 2026-08-06 | 2026-08-06 | |
**Cas E — chasse au trésor / carnaval** (`ShowMap = true`, `IsGameMode = true`, `BaseSectionMapId` renseigné)
| # | Attendu mobile | Attendu web | |
|---|---|---|---|
| E.1 | carte de fond = les GeoPoints de la SectionMap liée | idem | |
| E.2 | 3 étapes géolocalisées : « Approchez-vous (X m) » « Vous êtes dans la zone » | idem | |
| E.3 | notification locale à l'entrée dans une zone | (web : pas de notif locale, indicateur visuel seulement) | |
| E.4 | question texte libre à chaque étape, puis message de victoire | idem | |
| E.5 | **C'est le « use case carnaval » de `todo-features.md`** le jouer de bout en bout depuis la création dans manager-app vaut validation de tout le chapitre | | |
---
## 20. Écarts de parité connus — à rejouer après correction
> Table de suivi des écarts listés dans [parity-manager-visitapp.md](parity-manager-visitapp.md).
> Cocher « corrigé » quand le code est changé, « retesté » quand le test §19 correspondant repasse au vert.
| Réf | Écart | Test §19 | Corrigé | Retesté |
|---|---|---|---|---|
| **X1** | Bascule carte/contenu différente entre les 2 apps | D.0 | 2026-08-06 unifiée sur `ShowMap` seul | |
| **M4** | `isHiddenInitially` sans consommateur | D.5 | 2026-08-06 champ supprimé du modèle | |
| **M7** | Mode contenu : verrouillage et masquage absents | D.2-D.4 | 2026-08-06 | |
| **M8** | `isLinear = false` (navigation libre) inexistant | A.3 | 2026-08-06 | |
| **W6** | `isLinear` / `isStepLocked` absents en web | A.3, D.3 | 2026-08-06 | |
| **W7** | Mode contenu web sans masquage | D.4 | 2026-08-06 | |
| M1 | Centrage carte mobile (`centerLatitude/Longitude`) | 19.10.1 | 2026-08-06 helper `Helpers/mapCenter.dart` utilisé par les 3 vues | |
| M2 | Horaires d'un GeoPoint absents sur mobile | 19.10.8 | 2026-08-06 bloc ajouté dans `marker_view.dart` | |
| M3 | `meterZoneGPS` ignoré (constante 100 m en dur) | §9 | | |
| M5 | `mainImageUrl` non utilisé sur mobile | 19.12.6 | | |
| M6 | Watermark d'essai absent sur mobile | 19.12.7 | | |
| W1 | Provider de carte ignoré en web | 19.10.3 | | |
| W2 | Icônes de catégorie ignorées en web | 19.10.4 | 2026-08-06 `resourceDTO.url` posé dans la tête du pin | |
| W3 | `roundedValue` ignoré en web | 19.12.4 | | |
| W4 | `isSectionImageBackground` ignoré en web | 19.4.3 | | |
| W5 | `isContentTop` ignoré en web | 19.1.3 | | |
| W8 | Assistant IA absent de visitapp-web | 19.12.8 | 2026-08-06 bulle flottante + suggestions dérivées | |
| W9 | `agendaMapProvider` ignoré en web | 19.9.6 | | |
| W10 | `iconResourceId` (Map / Event) ignoré en web | | | |
---
## 21. Visite hors ligne — diagnostic terrain
> Ajouté le 2026-08-07. **À jouer en premier** : conditionne l'urgence du chantier [v2/offline-visit-plan.md](v2/offline-visit-plan.md).
> Analyse du code : le `switch` de collecte des ressources est commenté dans `ConfigurationController.Export` **et** le bloc symétrique dans `downloadConfiguration.dart`. Attendu : une visite téléchargée ne contient ni images d'articles ni audios. À confirmer sur un vrai device. **Corrigé en D1 le 2026-08-11** — ce §21 vérifie désormais la correction, pas le diagnostic.
>
> ✅ **Périmètre arrêté le 2026-08-13 : installation neuve, et rien d'autre.** Aucun visiteur n'a l'app installée à ce jour, et la consigne aux 4 clients est « supprimer l'app et la réinstaller ». Ça retire **deux cas** que D2 avait ouverts, et il vaut mieux savoir pourquoi avant de les remettre :
>
> - ⛔ **La montée v3 → v4 ne se teste pas** : `_onCreate` appelle `createTable(resources)`, qui contient déjà `dateUpdate TEXT` (`DatabaseHelper.dart:239`). Une installation neuve naît en v4 et n'emprunte jamais `_onUpgrade`. Vérifié plutôt que supposé — c'est exactement l'endroit où une colonne ajoutée en migration et oubliée à la création aurait fait planter toutes les installations neuves.
> - ⛔ **Le cas « device portant des `<id>.unknown` » ne se teste pas non plus** : ces fichiers datent d'avant D3, donc d'installations qui n'existent plus. Le traitement de `.unknown` par `isResourceOutdated` **reste dans le code** — il ne coûte rien et redevient utile au premier device qui garde une vieille installation. On ne teste pas un chemin de réparation pour une population de zéro ; on ne le supprime pas pour autant.
>
> ⚠️ **Ce qui fait tenir ce périmètre, et qui peut sauter** : la table rase n'est gratuite que tant qu'il n'y a pas de visiteur réel. Dès qu'un téléphone garde une installation — après la bascule, typiquement — les deux cas ci-dessus redeviennent la seule preuve que le parc existant est réparé.
### 21.1 — État réel du téléchargement
| # | Action | Attendu (après correction) | OK |
|---|--------|---------------------------|-----|
| 21.1.1 | Télécharger une visite depuis visitapp, puis passer le device en mode avion | La visite s'ouvre sans erreur | |
| 21.1.2 | Ouvrir une section Article contenant des images de contenu | Les images s'affichent | |
| 21.1.3 | Lancer un audio dans cette section Article | L'audio se lit | |
| 21.1.4 | Inspecter le dossier local de la configuration sur le device | Un fichier par ressource, **avec une vraie extension** (pas `.unknown`) | |
| 21.1.5 | Ouvrir un Quiz avec images de questions/réponses | Les images s'affichent | |
| 21.1.6 | Ouvrir une SectionMap téléchargée | Fond de carte, icônes et points présents | |
### 21.2 — Fraîcheur du contenu
| # | Action | Attendu | OK |
|---|--------|---------|-----|
| 21.2.1 | ~~Télécharger une visite, puis **remplacer une image** dans manager-app (même ressource), puis relancer le téléchargement~~ **Cas impossible, réécrit le 2026-08-13** : `Upload` appelle `GenerateHexId()` à chaque téléversement (`ResourceController:276`), donc « remplacer une image » crée **un nouvel id et une nouvelle URL** aucun flux ne réécrit le blob d'un id existant. Le cas d'origine testait un scénario qui ne peut pas se produire.<br>**À jouer à la place** : téléverser une **nouvelle** image, la placer dans une section déjà téléchargée, re-télécharger | La nouvelle image arrive sur le device ; l'ancienne, si plus référencée, est purgée (cf. 21.2.3) | |
| 21.2.2 | Relancer un téléchargement sans aucune modification | Rien n'est re-téléchargé, message « à jour » | |
| 21.2.3 | Supprimer une ressource dans manager-app puis re-télécharger | Le fichier local correspondant est purgé | |
### 21.3 — Types non disponibles hors ligne
| # | Action | Attendu | OK |
|---|--------|---------|-----|
| 21.3.1 | En mode avion, ouvrir une section Weather | Message explicite « nécessite une connexion », pas d'écran blanc ni de spinner infini | |
| 21.3.2 | Idem sur une section Web (webview externe) | Message explicite | |
| 21.3.3 | Idem sur une section Agenda (événements distants) | Message explicite | |
| 21.3.4 | Ouvrir une SectionVideo pointant sur YouTube en mode avion | Message explicite, distinct d'une vidéo uploadée qui, elle, doit se lire | |
### 21.4 — Robustesse
| # | Action | Attendu | OK |
|---|--------|---------|-----|
| 21.4.1 | Couper le réseau pendant un téléchargement | Le nombre d'échecs est remonté à l'utilisateur, la visite n'est pas annoncée « téléchargée » | |
| 21.4.2 | Relancer après un téléchargement partiel | Seules les ressources manquantes sont récupérées | |
---
## 22. Recette de bascule — l'app d'aujourd'hui vs la base migrée
> Ajoutée le 2026-08-11, à la demande. **À jouer juste après le `POST /api/migration/run` du jour J**, avant la bascule DNS et pendant que Mongo est encore lisible. C'est la seule fenêtre où les deux bases coexistent : après, la comparaison n'est plus possible.
>
> Le dry run compare des **comptages**. Cette recette compare le **contenu** — un compte juste ne dit pas qu'une section a gardé ses questions, ses traductions et ses médias.
>
> Rappel : `MigrationController` lit un MongoDB **vivant**, pas les fichiers de `migration-data/`.
### 22.1 — Comptages globaux, les deux bases côte à côte
```bash
# Mongo (source) — adapter l'URI au Mongo de prod ou au dump restauré
docker run --rm --network container:myim_mongo_dryrun mongo:6 \
mongosh "mongodb://localhost:27017/TabletDb" --quiet --eval '
["Instances","Users","Configurations","Resources","Sections","Devices"]
.forEach(c => print(c.padEnd(16) + db[c].countDocuments()));'
```
```bash
# Postgres (cible)
docker exec -i myim_postgres psql -U mym -d my_info_mate <<'SQL'
SELECT 'Instances' AS entite, count(*) FROM "Instances"
UNION ALL SELECT 'Users', count(*) FROM "Users"
UNION ALL SELECT 'Configurations', count(*) FROM "Configurations"
UNION ALL SELECT 'Resources', count(*) FROM "Resources"
UNION ALL SELECT 'Sections', count(*) FROM "Sections"
UNION ALL SELECT 'Devices', count(*) FROM "Devices";
SQL
```
| # | Contrôle | Attendu | OK |
|---|---|---|---|
| 22.1.1 | Instances, Users, Configurations, Resources, Devices | **Identiques** des deux côtés | |
| 22.1.2 | Sections | Postgres = Mongo **moins les orphelines signalées**. Le rapport les liste dans `Skipped` : leur nombre doit expliquer *exactement* l'écart | |
| 22.1.3 | `FatalError` et code HTTP | Vide, et **200**. Un 500 veut dire que la transaction a été annulée : rien n'a été écrit, on corrige et on rejoue | |
### 22.2 — Sections par type : c'est là que les pertes se cachent
Un compte global juste peut masquer 19 articles perdus compensés ailleurs. À comparer **type par type**.
```bash
# Mongo — Type est un entier : 0=Map 1=Slider 2=Video 3=Web 4=Menu 5=Quiz
# 6=Article 7=PDF 8=Game 9=Agenda 10=Weather
docker run --rm --network container:myim_mongo_dryrun mongo:6 \
mongosh "mongodb://localhost:27017/TabletDb" --quiet --eval '
db.Sections.aggregate([{$group:{_id:"$Type",n:{$sum:1}}},{$sort:{_id:1}}])
.forEach(r => print(r._id + " : " + r.n));'
```
```bash
# Postgres — héritage TPH, le type vit dans Discriminator
docker exec -i myim_postgres psql -U mym -d my_info_mate <<'SQL'
SELECT "Discriminator", count(*) FROM "Sections" GROUP BY 1 ORDER BY 1;
SQL
```
| # | Contrôle | Attendu | OK |
|---|---|---|---|
| 22.2.1 | Chaque type de Mongo se retrouve en Postgres | Écart nul, **ou** entièrement expliqué par les orphelines de ce type | |
| 22.2.2 | `Event` et `Parcours` | **0 des deux côtés** — ces types n'existaient pas dans Mongo. En voir apparaître signalerait une erreur de mapping | |
| 22.2.3 | `Game` | Autant que de type 8 dans Mongo, avec `GameType = Puzzle` | |
### 22.3 — Les collections filles, invisibles dans les comptages de sections
C'est l'écart (b) : un quiz peut arriver sans une seule question sans que rien ne le signale.
```bash
docker exec -i myim_postgres psql -U mym -d my_info_mate <<'SQL'
SELECT count(*) AS questions,
count(DISTINCT "SectionQuizId") AS quiz_pourvus
FROM "QuizQuestions";
SQL
```
| # | Contrôle | Attendu | OK |
|---|---|---|---|
| 22.3.1 | Total `QuizQuestions` | **41** sur l'export d'avril — à recalculer sur le dump du jour J | |
| 22.3.2 | Ouvrir un quiz migré dans manager-app | Ses questions sont là, **avec leurs réponses et la bonne réponse cochée** | |
| 22.3.3 | Ordre des réponses | Identique à l'ancienne app | |
| 22.3.4 | `EventAgendas` et `GuidedPaths` | **Vides** — Mongo n'en contient pas. Non vides = un mapping a inventé des données | |
### 22.4 — Ce que la migration génère ou décide
Ces colonnes n'existent pas dans Mongo : rien à confronter, tout à vérifier. **C'est ici que se logent les pannes silencieuses du §1quinquies.**
```bash
docker exec -i myim_postgres psql -U mym -d my_info_mate <<'SQL'
SELECT "Name", "WebSlug", left("PublicApiKey", 6) AS cle, "SubscriptionPlanId",
"AiTokensPerMonth", "IsMobile", "IsTablet", "IsWeb", "IsAssistant"
FROM "Instances" ORDER BY "Name";
SQL
```
| # | Contrôle | Attendu | OK |
|---|---|---|---|
| 22.4.1 | `PublicApiKey` | **Non nulle pour les 4 instances**, préfixe `ap_`. Nulle = les apps visiteur ne s'authentifient plus | |
| 22.4.2 | `WebSlug` | Non nul et **unique**, dérivé du nom | |
| 22.4.3 | `SubscriptionPlanId` | `plan-premium` pour MyInfoMate et VisitNamur, `plan-pro` pour MDLF et le Fort | |
| 22.4.4 | `AiTokensPerMonth` | > 0 sur les deux Premium. **0 sur les deux Pro, c'est voulu** — Pro n'inclut pas l'IA | |
| 22.4.5 | `IsMobile` / `IsTablet` | Cohérents avec les apps réellement utilisées par chaque client | |
| 22.4.6 | `IsWeb` | **Faux partout** — le canal web n'existait pas avant, il s'active à la main | |
| 22.4.7 | Rôles des utilisateurs | Tous `InstanceAdmin`. **Aucun `SuperAdmin` : à poser à la main sur ton compte**, sinon les écrans SuperAdmin sont inatteignables | |
| 22.4.8 | `Resources.StoragePath` | Renseigné pour les types fichier, **nul pour les types URL** (`ImageUrl`, `VideoUrl`, `JSONUrl`) | |
| 22.4.9 | `Resources.SizeBytes` à 0 | Uniquement celles listées dans les `Errors` du rapport (HEAD en échec). Le quota les comptera pour rien tant qu'un backfill ne passe pas | |
### 22.5 — Comparaison à l'écran, ancienne app vs nouvelle
Les comptages ne disent rien des traductions, des médias liés ni de l'ordre. **Prendre une configuration par instance** et les ouvrir côte à côte.
| # | Contrôle | Attendu | OK |
|---|---|---|---|
| 22.5.1 | La liste des sections, dans l'ordre | Même ordre, mêmes libellés | |
| 22.5.2 | Une section Article | Contenu HTML identique, **dans chaque langue de la configuration** | |
| 22.5.3 | Une SectionMap | Mêmes points, mêmes catégories, même icône, même centrage et zoom | |
| 22.5.4 | Un Menu | Les sections liées sont les mêmes (`LinkMenuSectionsAsync` relie après coup) | |
| 22.5.5 | Un PDF | Le fichier s'ouvre, et c'est le bon | |
| 22.5.6 | Une sous-section | Toujours rattachée au bon parent | |
| 22.5.7 | Couleurs et loader de la configuration | Identiques | |
| 22.5.8 | **Une app visiteur avec sa vraie clé API** | Se connecte et affiche le contenu — le seul contrôle qui teste `PublicApiKey` de bout en bout | |
### 22.6 — Le contenu qu'on sait perdu
| # | Contrôle | Attendu | OK |
|---|---|---|---|
| 22.6.1 | Relire les `Skipped` du rapport | Chaque ligne est une section orpheline **connue et acceptée**. Une surprise ici = on arrête et on regarde | |
| 22.6.2 | Les 20 sections MDLF orphelines | Soit récupérées (les 2 configurations recréées dans Mongo **avant** la bascule), soit leur perte **actée avec le client** | |
---
## Hors scope (non implémenté)
| Feature | Statut |
|---------|--------|
| Ressource 360° / panorama | ❌ — 📦 **V2** (2026-08-07) |
| AR image tracking (Mind AR) | ❌ — 📦 **V2** (2026-08-07) |
| SectionForm (formulaires personnalisés) | ❌ — 📦 **V2** (2026-08-07) |
| Audit log API Keys | ❌ |
| Rate limiting API Keys | ❌ |