DOCS/test-plan.md
Thomas Fransolet 6221978526 Docs en attente : canal VR et contenu immersif livrés, SectionForm, roadmap
Cartes 600, 610 et 620 closes (back-office XR, ressource 360, appairage tablette),
250 et 320 retirées du planifié, 015 et 330 à jour ; STATUS, roadmap, test-plan,
todo-features et plans VR / frontière immersif alignés ; kanban.html regénéré.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 20:06:34 +02:00

1435 lines
105 KiB
Markdown
Raw Permalink 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**
23. [QR codes, nom d'application, page de téléchargement, proximité](#23-qr-codes-nom-dapplication-page-de-téléchargement-proximité)
24. [Onglet XR — flotte de casques (lot XR-2)](#24-onglet-xr--flotte-de-casques-lot-xr-2)
25. [App VR — appairage et lecture du contenu (XR-4, items E2-E3)](#25-app-vr--appairage-et-lecture-du-contenu-xr-4-items-e2-e3) ⚠️ **jamais exécuté**
26. [Médias immersifs dans le manager — ce que voit le gestionnaire (XR-4)](#26-médias-immersifs-dans-le-manager--ce-que-voit-le-gestionnaire-xr-4)
27. [Hors ligne complet et fond immersif (XR-4, E4 et E5)](#27-hors-ligne-complet-et-fond-immersif-xr-4-e4-et-e5) ⚠️ **jamais exécuté**
---
## 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 | App au premier plan : popup de suggestion, **sans** notification. App en arrière-plan / écran verrouillé : notification locale `beaconFound` (cf. §23) | |
| 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 | |
| 9.6 | **iOS** — balise émettant un `proximityUUID` **autre** que `FDA50693-A4E2-4FB1-AFCF-C6EB07647825` | Le cas doit **échouer** : la région iOS est filtrée sur cet UUID en dur (`geo_beacon_trigger_service.dart:148-155`). Si ça déclenche quand même, le code ne fait pas ce qu'il dit — Android, lui, scanne sans filtre | |
| 9.7 | Déclenchement **proactif de l'assistant** par balise (chemin distinct de 9.1) | Le prompt part depuis `geo_beacon_trigger_service._onBeaconResult`, uniquement si le mode proactif est actif | |
| 9.8 | Distance réelle de déclenchement, mètre en main | ⚠️ `proximity_suggestion_service.dart` = `beaconMaxDistanceMeters = 100` (accuracy du ranging). `Section.meterZoneGPS` sert désormais aux zones **GPS**, pas aux beacons. Noter la distance **mesurée**, pas celle attendue | |
| 9.9 | Stationner 2 min devant un POI | Une seule notification (cooldown), pas de répétition | |
| 9.10 | Balise avec `major` porteur d'un niveau de batterie | Aucun effet sur l'identification du POI — l'app ne lit que le `minorId`. C'est ce qui rend le `major` utilisable pour la télémétrie | |
---
## 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.1 bis — Entrée dans le téléchargement depuis la home v3
> Ajouté le 2026-09-08 avec le portage du téléchargement dans le bento (`home_3.0.dart`). Avant ce portage, une visite hors ligne non téléchargée ouvrait un détail **vide**, sans message.
| # | Action | Attendu | OK |
|---|--------|---------|-----|
| 21.1.7 | Ouvrir la home v3 avec une configuration `isOffline` jamais téléchargée | Tuile ternie, pastille `↓` en haut à droite | |
| 21.1.8 | Taper la tuile (pas la pastille) | Le dialogue de téléchargement s'ouvre **pas** un détail vide | |
| 21.1.9 | Télécharger, fermer, revenir à la home | La pastille passe au `✓` vert, la tuile n'est plus ternie | |
| 21.1.10 | Taper la tuile téléchargée | Le détail s'ouvre **avec ses sections** | |
| 21.1.11 | **Relancer l'app** puis regarder les tuiles | Les visites non téléchargées restent en `↓`. Le piège : le fetch écrit une ligne par visite dans la table `configurations` pour cacher `order`/`gridSpan` si l'état se remettait à se déduire de ces lignes, tout passerait en `✓` au 2ᵉ lancement | |
| 21.1.12 | Choisir une langue dans le dialogue, puis télécharger | Seules les ressources de cette langue sont récupérées (sauf mode admin toutes langues) | |
| 21.1.13 | Taper la pastille `✓` d'une visite déjà téléchargée | Dialogue de **mise à jour** (`downloadPromptUpdate`), pas de premier téléchargement | |
| 21.1.14 | Ouvrir une visite dont les langues ne contiennent pas la langue courante | Snackbar « langue non supportée », pas de navigation | |
| 21.1.15 | Désactiver une configuration dans manager-app (interrupteur du lien de canal), relancer la home | La tuile disparaît | |
### 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** | |
---
## 23. QR codes, nom d'application, page de téléchargement, proximité
Prérequis : migration `AddAppNameQrAndStoresToApplicationInstance` appliquée ; visitapp buildée après `flutter clean` (empreinte de `libapp.so` vérifiée).
### 23.1 — Réglages dans manager-app (écrans Mobile et Web)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 23.1.1 | Saisir le **nom de l'application** en FR/EN/NL, recharger | Valeurs conservées, par app (Mobile ≠ Web) | |
| 23.1.2 | Décocher **Afficher le scan de QR code**, recharger | Case restée décochée | |
| 23.1.3 | En **SuperAdmin**, saisir les liens App Store / Play Store (écran Mobile) | Enregistrés | |
| 23.1.4 | En **InstanceAdmin** | Les champs de liens stores n'apparaissent pas ; un `PUT` forgé avec d'autres liens ne les modifie pas | |
| 23.1.5 | QR d'une section (instance avec app mobile) | Encode `https://app.myinfomate.be/download/{instanceId}/{configId}/{sectionId}` | |
| 23.1.6 | QR d'une section (instance **web seule**) | Encode `https://app.myinfomate.be/{slug}/{configId}/sections/{sectionId}` | |
### 23.2 — Scan dans l'app mobile
| # | QR scanné | Depuis | Résultat attendu | ✓ |
|---|-----------|--------|-----------------|---|
| 23.2.1 | Id brut d'une section (QR du **Fort**) | Accueil | La visite s'ouvre directement sur la section | |
| 23.2.2 | Id brut d'une section de la visite ouverte | Visite | La section s'ouvre | |
| 23.2.3 | Id brut d'une section d'une autre visite | Visite | Popup « autre visite » puis ouverture sur la section | |
| 23.2.4 | URL `web.mymuseum.be/…` d'un vrai QR **MDLF** imprimé | Accueil et visite | Même comportement qu'avant | |
| 23.2.5 | Nouvelle URL `app.myinfomate.be/download/…` | Accueil et visite | Ouvre la section | |
| 23.2.6 | QR d'une **autre instance** | Accueil | « QR code invalide » | |
| 23.2.7 | Visite hors ligne, mode avion, id brut | Accueil | Résolu depuis la base locale | |
| 23.2.8 | Scan QR désactivé dans le manager | — | Bouton absent sur l'accueil, dans la visite et dans un menu | |
| 23.2.9 | Nom de l'app renseigné | — | En-tête de l'accueil = nom de l'app (langue active) ; sans nom : titre de la 1re visite comme avant | |
### 23.3 — Web (visitapp-web)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 23.3.1 | Ouvrir `/download/{instanceId}/{c}/{s}` (téléphone, appareil photo) | Image principale, nom de l'app, boutons stores renseignés, textes dans la langue du navigateur | |
| 23.3.2 | Même URL sans liens stores | « Bientôt disponible sur les stores » | |
| 23.3.3 | Instance sans app mobile | 404 | |
| 23.3.4 | Scanner un QR `download` depuis l'app web | Ouvre la section web | |
| 23.3.5 | Scan QR désactivé sur l'app **Web** | Bouton absent (accueil et visite) | |
| 23.3.6 | Nom de l'app renseigné | Titre sur l'accueil et dans l'onglet | |
| 23.3.7 | Visite avec sections géolocalisées, activer le bouton de proximité, forcer la position dans les DevTools | Carte « À proximité » une fois par section et par session, 20 s minimum entre deux | |
| 23.3.8 | ⚠️ **Prendre l'`instanceId` d'un vrai QR MDLF imprimé** et ouvrir `/download/{cet id}/…` | La page **de MDLF** s'affiche (son nom, son image, ses liens). Si elle ne trouve rien, l'id a changé à la migration Mongo → Postgres : la redirection ne servirait à rien, à traiter avant de la poser | |
| 23.3.9 | Instance sans **nom d'application** renseigné | Le titre retombe sur le nom de l'instance, jamais sur « Application mobile » | |
| 23.3.10 | Ancien QR d'une instance **web seule** redirigé vers `/download/…` | Redirection vers la visite web à la bonne section | |
### 23.4 — Proximité et notifications (mobile, device réel)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 23.4.1 | Visite avec zones GPS (sans beacon) | Le bouton de proximité apparaît une fois les sections chargées | |
| 23.4.2 | Activer, entrer dans une zone (mock location Android), app ouverte | Popup de suggestion, **pas** de notification | |
| 23.4.3 | Même chose écran verrouillé | Notification « Contenu à proximité » + titre de la section | |
| 23.4.4 | Toucher la notification | L'app s'ouvre sur la section | |
| 23.4.5 | Rester dans la zone écran verrouillé | Une seule notification par section et par visite | |
| 23.4.6 | Android : pendant le scan | Notification persistante « Suggestions de contenu à proximité activées » ; disparaît en coupant le bouton ou en quittant la visite | |
| 23.4.7 | iOS : écran verrouillé | Indicateur de localisation en arrière-plan ; notification reçue (beacon **et** GPS) | |
| 23.4.8 | App tuée | Aucune notification (hors périmètre, par choix) | |
| 23.4.9 | Mode vocal lunettes sans permission de localisation (Android 14+) | Pas de crash ; le service de premier plan ne démarre pas | |
---
## 24. Onglet XR — flotte de casques (lot XR-2)
Livré le 2026-09-12. **Aucun casque ne sait encore s'appairer**`E2` de `XR-4` n'est pas fait —
donc l'appareil de test se crée à la main par l'API. C'est la seule façon de voir la carte d'un
casque appairé aujourd'hui, et ça teste exactement le chemin que prendra Unity.
### 24.0 — Prérequis, à faire une fois
Il n'y a **pas encore d'écran** pour activer le canal VR sur une instance (carte kanban « Écran
SuperAdmin — add-on IA & canaux », planifiée). Tout se fait par l'API, en SuperAdmin.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 24.0.1 | `PUT /api/instance` avec `isVR: true` sur l'instance de test | 200, et `isVR` toujours à `true` après un `GET` | |
| 24.0.2 | `POST /api/applicationinstance` avec `appType: 3` et l'`instanceId` | 200 — sans elle, l'onglet affiche « Application VR non configurée » | |
| 24.0.3 | Créer un **lien de configuration** sur cette `ApplicationInstance` VR (depuis l'onglet Configuration, une fois 24.0.2 fait) | Le lien existe — il servira de carte « aucun casque appairé » | |
| 24.0.4 | `POST /api/device` avec `appType: 3`, un `identifier` bidon, l'`instanceId` et un `configurationId` valide | 200. ⚠️ Deux pièges : **sans `appType`, le serveur crée une tablette** (elle irait dans l'onglet Kiosk), et l'appel **crée son propre `AppConfigurationLink`** — il ne se rattache pas au lien de 24.0.3, on se retrouve donc avec deux cartes | |
### 24.1 — Le menu
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 24.1.1 | Ouvrir le manager sur l'instance de test | Entrée « VR » sous Applications, à côté de Mobile / Kiosk / Web | |
| 24.1.2 | Basculer sur une instance **sans** canal VR | L'entrée « VR » disparaît, et l'écran affiché n'est pas resté celui de l'instance précédente | |
| 24.1.3 | Instance avec `isVR: true` mais **sans** `ApplicationInstance` VR (sauter 24.0.2) | « Application VR non configurée », pas d'écran vide ni de crash | |
### 24.2 — Sous-onglet « Configuration »
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 24.2.1 | Ouvrir l'onglet VR | Deux sous-onglets, « Configuration » actif, souligné | |
| 24.2.2 | Modifier les **langues** de l'application VR, recharger | Conservées, et **sans effet** sur les langues de Mobile / Web / Kiosk | |
| 24.2.3 | Ajouter un lien de configuration depuis cet onglet | Apparaît dans la liste, et dans le sous-onglet « Casques » | |
| 24.2.4 | Comparer à l'onglet Mobile | Mêmes champs, même comportement — c'est le même écran réutilisé | |
### 24.3 — Sous-onglet « Casques »
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 24.3.1 | Ouvrir « Casques » | Le **code PIN** de l'instance en tête, puis la grille — **deux cartes**, celle du lien de 24.0.3 et celle créée par le `POST` de 24.0.4 | |
| 24.3.2 | La carte du lien de 24.0.3 | « Aucun casque appairé » + le nom de la configuration | |
| 24.3.3 | La carte du casque créé en 24.0.4 | Son nom (ou son identifiant si sans nom), le nom de la configuration, une pastille **rouge** (jamais connecté) | |
| 24.3.4 | Mettre `connected: true` sur ce device par l'API, revenir sur l'onglet | Pastille **verte** | |
| 24.3.5 | Renseigner `batteryLevel`, `appVersion`, `lastSeen` par l'API, recharger | Les trois lignes s'affichent sous la configuration ; la date est au format `jj/mm/aaaa hh:mm` | |
| 24.3.6 | Device **sans** `lastSeen` | « Jamais vu », pas de ligne vide ni de date à 1970 | |
| 24.3.7 | Bouton crayon → changer le nom et la configuration → valider | Notification de succès, la carte se met à jour **sans** recharger la page | |
| 24.3.8 | Recharger après 24.3.7 | Le nom **et** la configuration ont tenu — les deux passent par deux appels distincts (`device/mainInfos` puis le lien de configuration) | |
| 24.3.9 | Revenir sur « Configuration » puis sur « Casques » | La configuration réassignée en 24.3.7 est bien celle affichée des deux côtés | |
| 24.3.10 | Ouvrir l'onglet **Kiosk** de la même instance | Le casque de test **n'y apparaît pas**, et les tablettes existantes y sont toujours toutes | |
### 24.4 — Langues et affichage
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 24.4.1 | Basculer le manager en EN puis en NL | Titres d'onglets, « Casques », « Aucun casque appairé », « Batterie / Version / Vu le » traduits — aucun texte français résiduel | |
| 24.4.2 | Réduire la fenêtre à une largeur de tablette | La grille repasse en moins de colonnes, rien n'est tronqué en hauteur dans les cartes | |
| 24.4.3 | Un casque au nom très long | Ellipse, pas de débordement | |
### 24.5 — Ce que ce plan ne teste pas
Appairage réel par pincode depuis le casque, remontée automatique de batterie et de version,
rafraîchissement MQTT : tout cela est dans `XR-4` (`E2`) et `XR-5`, pas encore écrit. Les valeurs
de 24.3.5 sont posées à la main précisément parce que rien ne les alimente encore.
---
## 25. App VR — appairage et lecture du contenu (XR-4, items E2-E3)
Écrit le 2026-09-12, **jamais exécuté** : ni contre un vrai serveur, ni sur le casque. Le code
compile contre les DLL Unity, c'est tout ce qu'on sait. Ce chapitre est donc une **première
exécution**, pas une non-régression — traiter chaque échec comme une information, pas comme un bug.
Prérequis : le §24.0 joué (canal VR activé sur l'instance de test), un Quest en mode développeur,
et la scène construite par **MyInfoMate → Construire la scène Boot (E2-E3 — appairage et export)**.
Le code PIN, l'URL du serveur et la langue se posent **dans l'inspecteur du `Bootstrap`** avant le
build : sans l'Interaction SDK (E1) il n'y a ni pointeur ni clavier dans le casque.
### 25.1 — Appairage (E2)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.1.1 | PIN correct, canal VR activé, Build And Run | Le casque affiche le **nom de la configuration** et ses sections. Console : `[Pairing] Casque appairé — device …` | |
| 25.1.2 | Ouvrir l'onglet XR du manager après 25.1.1 | Le casque **apparaît dans la grille**, nom = celui du champ `headsetName`, pastille verte | |
| 25.1.3 | Ouvrir l'onglet **Kiosk** de la même instance | Le casque **n'y est pas** — c'est tout l'objet de `appType = 3` | |
| 25.1.4 | Relancer l'app sans rien changer | **Aucun nouvel appareil** dans le manager : l'identifiant matériel est stable et le serveur reconnaît l'appareil | |
| 25.1.5 | Relancer avec un PIN vidé | Toujours appairé (persistance `PlayerPrefs`), le PIN n'est plus lu | |
| 25.1.6 | Cocher `forgetPairing`, rebuild | Réappairage complet ; vérifier qu'il n'y a **pas** de second appareil dans le manager | |
| 25.1.7 | PIN inexistant | « Ce code PIN ne correspond à aucun lieu. » — pas d'écran noir | |
| 25.1.8 | PIN d'une instance **sans** canal VR (sauter 24.0.2) | « Le canal VR n'est pas activé pour ce lieu. » ⚠️ C'est un 404 traduit : si le message générique « Ce contenu n'existe plus » apparaît, la traduction du 404 est à revoir | |
| 25.1.9 | URL de serveur injoignable (couper le wifi avant le lancement) | « Le serveur est injoignable. Vérifiez la connexion du casque. » au bout de ~20 s, pas un gel | |
### 25.2 — Lecture du contenu (E3)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.2.1 | Instance avec plusieurs sections actives | Le message liste **jusqu'à 5 sections** avec leur type ; la console (`adb logcat`) les liste **toutes** | |
| 25.2.2 | Comparer à l'écran Configurations du manager | Même nombre de sections de premier niveau, même ordre. Les sous-sections d'un Menu **ne comptent pas** | |
| 25.2.3 | Une section désactivée dans le manager | Elle n'apparaît pas dans le casque | |
| 25.2.4 | Passer `language` de FR à EN, rebuild | Les titres changent de langue. ⚠️ Si une section n'a pas de traduction EN, elle retombe sur la première disponible — c'est voulu, pas un bug | |
| 25.2.5 | Une configuration contenant **tous** les types de section (Game, PDF, Weather…) | **Rien ne plante** : les 13 types se lisent, même ceux que la VR ne rendra jamais | |
| 25.2.6 | Configuration vide (aucune section) | « 0 sections », pas d'erreur | |
| 25.2.7 | ⚠️ **Ouvrir le JSON d'export d'une vraie configuration** (navigateur, avec la clé) | Il porte `exportVersion: 1`, `generatedAt`, **et les champs spécifiques de chaque section** — les `contents` d'un Slider, les `points` d'une Map, le `model3DResourceId` d'une maquette. Jusqu'au 12/09 il ne portait que les champs communs, et **l'export est aussi ce qui part en visite hors ligne** | |
| 25.2.8 | Sur ce même JSON, chercher une section multilingue | Les titres portent **toutes** les langues, pas seulement une : le casque change de langue sans rappeler le serveur | |
| 25.2.9 | ⚠️ **mymuseum-visitapp** : télécharger une visite hors ligne, couper le réseau, l'ouvrir | Les galeries, cartes et quiz ont leur contenu. C'est le même correctif d'export : à vérifier **avant** de croire que seul le casque était concerné | |
### 25.3 — Cache et mode hors ligne (E4)
Le cache s'écrit dès le premier chargement réussi. C'est l'argument n°1 d'Unity contre le web :
si ces cas-là ne passent pas, le choix de stack perd sa justification.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.3.1 | Lancer une première fois avec le wifi | Contenu affiché, et un fichier apparaît dans `/sdcard/Android/data/<package>/files/content/` (`adb shell ls`) | |
| 25.3.2 | **Couper le wifi du casque**, relancer l'app | Le contenu s'affiche **quand même**, avec la ligne « hors ligne — contenu du jj/mm hh:mm » | |
| 25.3.3 | Comparer le délai d'affichage avec et sans wifi | Le départ sur cache doit être **plus rapide** que le premier lancement : c'est tout l'objet du « cache d'abord » | |
| 25.3.4 | Modifier un titre de section dans le manager, relancer l'app (wifi actif) | L'ancien contenu s'affiche d'abord, puis **se remplace** par le nouveau sans relancer — console : `[Content] Contenu mis à jour depuis le serveur` | |
| 25.3.5 | Même chose sans modification | **Aucun clignotement** : le contenu identique ne doit pas provoquer de réaffichage | |
| 25.3.6 | Éteindre brutalement le casque pendant un rafraîchissement, relancer | Le contenu précédent est intact — l'écriture est atomique, jamais un JSON tronqué | |
| 25.3.7 | Wifi coupé **et** cache vidé (désinstaller/réinstaller) | « Le serveur est injoignable… », pas un écran noir | |
### 25.4 — Télémétrie (E9)
Rien n'émet encore d'événement automatiquement : `Telemetry` existe mais n'est appelée que
depuis le menu (E5), pas encore écrit. À jouer **après E5**, ou en déclenchant un appel à la main.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.4.1 | Émettre un `SectionView` depuis le casque | Écran Statistiques du manager : le canal **VR** apparaît dans le filtre, sans aucune configuration | |
| 25.4.2 | Vérifier le canal de l'événement en base | `AppType = 3`. ⚠️ S'il vaut `0` (Mobile), c'est le parsing par nom qui a échoué **en silence** — le piège documenté du `StatsController` | |
| 25.4.3 | Émettre pendant que le wifi est coupé | L'app continue normalement, la console note l'échec, **rien ne remonte au visiteur** | |
### 25.5 — Menu flottant : manette, main, tête (E5)
Scène **MyInfoMate → Construire la scène Boot (E5 — menu flottant)**. C'est l'app : elle appaire,
sert le cache, affiche le menu et émet la télémétrie. Les cas 25.1 à 25.4 valent aussi pour elle.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.5.1 | Lancer avec une configuration de 3-4 sections | Un **arc de panneaux** à ~2,2 m, à hauteur d'yeux, chacun de face | |
| 25.5.2 | Tourner la tête | Le menu **reste où il est** — un menu qui suit la tête est invisable et donne la nausée | |
| 25.5.3 | **Manettes posées, mains baissées.** Viser un panneau avec la tête | Il s'éclaircit, et un **anneau se remplit** en ~1,2 s. Pas de rayon visible — c'est voulu, un trait au centre du champ de vision gêne en permanence | |
| 25.5.4 | Détourner la tête avant la fin | L'anneau se vide, **rien ne se déclenche** | |
| 25.5.5 | Maintenir jusqu'au bout | Le titre de la section s'affiche. ⚠️ Vérifier que c'est **la bonne** — c'est tout l'objet du test | |
| 25.5.6 | Balayer le menu de la tête, sans s'arrêter | Aucun déclenchement accidentel. Si ça part trop vite, allonger `dwellSeconds` | |
| 25.5.7 | **Prendre une manette.** Viser | Un **rayon apparaît**, le panneau visé s'éclaircit, et l'anneau **ne se remplit pas** | |
| 25.5.8 | Appuyer sur la gâchette d'index | Déclenchement **immédiat**, sans attente | |
| 25.5.9 | Reposer la manette, reprendre la tête | Le rayon disparaît et la temporisation revient — la bascule est automatique, sans réglage | |
| 25.5.10 | **Mains nues** (building block *Hand Tracking* dans la scène) | Rayon depuis la main, déclenchement au **pincement pouce-index** | |
| 25.5.11 | Maintenir le pincement | **Un seul** déclenchement, pas un par image | |
| 25.5.12 | Sans le building block *Hand Tracking* | Manette et tête fonctionnent quand même, **aucune erreur** en console | |
| 25.5.13 | Configuration de **plus de 5 sections** | Une seconde rangée en dessous, pas un cercle autour du visiteur | |
| 25.5.14 | Sections avec image dans le manager | Les vignettes apparaissent **après** le menu, sans le bloquer | |
| 25.5.15 | Section sans image | Le panneau reste lisible, titre seul | |
| 25.5.16 | Relancer hors ligne après un premier lancement | Menu **et** vignettes viennent du cache | |
| 25.5.17 | Après 25.5.5, regarder les stats du manager | `MenuItemTap` et `SectionView` sur le canal VR (voir 25.4) | |
⚠️ **Deux réglages à rapporter**, ils ne se décident pas sur le papier :
| # | Réglage | Question | Valeur retenue |
|---|---------|----------|----------------|
| 25.5.18 | `dwellSeconds` (1,2 s) | Pénible, ou trop prompt à déclencher ? | |
| 25.5.19 | Distance et hauteur de l'arc (2,2 m / 1,45 m) | Texte lisible sans effort ? Panneaux hauts atteignables sans lever la tête ? | |
### 25.6 — Sections affichables : Slider, Map, Parcours, Event (E8)
Les quatre types qui s'ouvrent, tous dans la **même vue paginée** — une chose à la fois, devant
soi, avec de quoi passer à la suivante. Prérequis : une configuration de test qui contient un
Slider (3+ images), une Map (3+ points), un Event avec du programme **aujourd'hui**, et au moins
un type écarté (Article ou Quiz).
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.6.1 | Choisir un Slider dans le menu | Le menu **disparaît**, une grande image apparaît à 2,5 m avec « 1 / N » | |
| 25.6.2 | Galerie de 10+ images | La **première s'affiche sans attendre** les suivantes ; le compteur monte au fur et à mesure | |
| 25.6.3 | Viser ▶ | Image suivante, compteur à jour, légende à jour | |
| 25.6.4 | Arriver à la dernière image | La flèche ▶ **disparaît** — un bouton présent qui ne réagit pas se lit comme une panne | |
| 25.6.5 | Première image | Idem pour ◀ | |
| 25.6.6 | « Retour au menu » | La galerie disparaît, le menu **revient tel qu'il était** (même position, mêmes vignettes) | |
| 25.6.7 | Après 25.6.6, stats du manager | Un `SectionLeave` avec une **durée** cohérente avec le temps passé | |
| 25.6.8 | Slider dont les contenus ont un ordre défini dans le manager | Même ordre dans le casque | |
| 25.6.9 | Slider sans image, ou dont les images ne se téléchargent pas | « Cette galerie n'a aucune image affichable. », pas un cadre vide | |
| 25.6.10 | Rouvrir le même Slider, wifi coupé | Images servies depuis le cache | |
| 25.6.11 | Choisir une section **non gérée** (Article, Quiz…) | Le titre s'affiche avec « pas encore affichable », et on peut revenir | |
**Map et Parcours** — rendus en **liste de points d'intérêt**, un par page :
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.6.12 | Ouvrir une Map | Un POI par page : titre, description, horaires s'il y en a, photo s'il y en a | |
| 25.6.13 | Comparer à la liste de la Map dans le manager | Mêmes points, aucun oublié | |
| 25.6.14 | POI sans photo | Le cadre reste sombre et le **texte tient tout seul**, pas de trou | |
| 25.6.15 | Ouvrir un Parcours (Map avec `isParcours`) | Même rendu qu'une Map — c'est assumé, voir ci-dessous | |
**Event** — le programme, une page par bloc :
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.6.16 | Event avec du programme **aujourd'hui** | Seuls les blocs du jour, triés par heure, avec « 14:00 15:30 » | |
| 25.6.17 | Event dont le programme du jour est **vide** | Les **dates suivantes** s'affichent, au format « 14/09 · 14:00 » | |
| 25.6.18 | Event entièrement passé | « Rien au programme aujourd'hui. », pas un cadre vide | |
⚠️ **Trois choses à juger sur place, pas sur le papier :**
| # | Question | Impression |
|---|----------|------------|
| 25.6.19 | L'image fait 1,6 × 0,9 m à 2,5 m. Trop grande, elle oblige à balayer la tête ; trop petite, elle ne vaut pas mieux qu'une tablette | |
| 25.6.20 | Une **Map en liste de POI** est-elle acceptable en démo, ou est-ce que ça se voit trop que la maquette 3D manque ? | |
| 25.6.21 | Un **Parcours rendu comme une Map** passe-t-il, ou faut-il le masquer du menu tant qu'il n'a pas son vrai rendu ? | |
### 25.7 — Ressource 360° et lecture immersive (E6)
Deux moitiés : déclarer une 360 dans le manager, puis la voir dans le casque. La première se
teste sans casque — à faire dès maintenant si l'occasion se présente.
**Dans manager-app :**
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.7.1 | Médiathèque → ajouter un `.jpg` | Pastille « Image », comme avant | |
| 25.7.2 | **Toucher la pastille** | Elle bascule en « Image 360° » et se colore | |
| 25.7.3 | Envoyer, puis rouvrir la fiche du média | Type conservé après rechargement | |
| 25.7.4 | ⚠️ **Vérifier les dimensions du fichier stocké** (fiche du média, ou Firebase) | **La 360 n'est PAS redimensionnée** : une 8192×4096 reste 8192×4096. Si elle est ressortie en 2560 de large, l'exclusion de compression n'a pas pris — et dans le casque ce sera une bouillie | |
| 25.7.5 | Même chose avec un `.jpg` laissé en « Image » | Celui-là **est** compressé à 2560 : la compression ordinaire n'a pas été cassée au passage | |
| 25.7.6 | Un `.mp4` basculé en « Vidéo 360° » | Type conservé, fichier intact | |
| 25.7.7 | Déposer un `.glb` | Accepté, type « Modèle 3D » **déduit tout seul** | |
| 25.7.8 | Remplacer le fichier d'une ressource 360 existante | Toujours pas de compression | |
| 25.7.9 | Passer le manager en EN puis NL | Libellés et infobulle traduits | |
**Dans le casque** (une `SectionVideo` dont le média est la ressource 360) :
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.7.10 | Choisir la section depuis le menu | Le décor **devient** la photo : on tourne la tête, on est dedans | |
| 25.7.11 | ⚠️ Regarder la couleur | Si le ciel est **magenta**, `Skybox/Panoramic` n'est pas dans *Always Included Shaders* — ça ne se voit **que** sur le casque, jamais dans l'éditeur | |
| 25.7.12 | Chercher la couture verticale du panorama | Pas de trait net à la jonction | |
| 25.7.13 | Juger la définition | C'est ici que se voit une 360 qui aurait été compressée | |
| 25.7.14 | **Enchaîner cinq ou six 360 d'affilée**, en revenant au menu entre chaque | L'app ne ralentit pas et **ne se fait pas tuer** : chaque image est libérée en sortant. Une fuite ne se voit qu'au bout de plusieurs, jamais sur un essai unique | |
| 25.7.15 | Section en **vidéo** 360 | Elle démarre, tourne en boucle, le son sort | |
| 25.7.16 | **Attendre 4 s sans rien faire** | Le panneau de retour **s'efface** : il ne reste rien dans le décor | |
| 25.7.17 | **Baisser les yeux** | Il réapparaît, posé sous le regard courant | |
| 25.7.18 | Se retourner complètement, puis baisser les yeux | Il est **devant soi**, pas resté à sa position de départ | |
| 25.7.19 | Prendre une manette | Il réapparaît sans avoir à baisser la tête | |
| 25.7.20 | ⚠️ **Sans rien savoir, sauriez-vous ressortir ?** Faire essayer quelqu'un qui n'a pas lu ce plan | S'il reste coincé dans la photo, 4 s d'affichage initial ne suffisent pas — c'est le réglage à revoir | |
| 25.7.21 | Le retirer | Le menu revient **sur le décor d'origine**, pas sur la 360 restée affichée | |
| 25.7.22 | Rouvrir une autre 360 puis revenir | Idem, aucun décor résiduel | |
| 25.7.23 | Relancer hors ligne | La 360 vient du cache disque | |
| 25.7.24 | Section Video pointant une **URL YouTube** | Pas de skybox — elle n'est pas téléchargeable, c'est traité comme un type non géré | |
### 25.8 — Maquette 3D et points d'intérêt (E7)
Trois endroits à vérifier : la section dans le manager, le viewer qui sert d'éditeur, et le rendu
dans le casque. ⚠️ **Le viewer n'a encore jamais tourné** — le lancer seul (`npm run dev`, glisser un
GLB) avant de juger l'intégration évitera de confondre deux problèmes.
**Dans manager-app :**
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.8.1 | Créer une section, groupe « lieux » | Le type **Scène 3D** est proposé, avec son icône | |
| 25.8.1b | Choisir le mode **Objet** puis **Décor** | L'explication sous le sélecteur change ; le choix tient après enregistrement | |
| 25.8.2 | Ouvrir la section | Le sélecteur de modèle ne propose **que** des ressources `Model3D` | |
| 25.8.3 | Sans modèle choisi | « Choisissez d'abord un modèle 3D » — pas d'iframe vide | |
| 25.8.4 | Choisir un `.glb` | Le viewer apparaît et **charge le modèle** | |
| 25.8.5 | Le viewer ne se charge pas | Vérifier l'URL : il doit être servi sous le même domaine que le manager (`/viewer/index.html`), ou lancé avec `--dart-define=VIEWER_URL=…` | |
| 25.8.6 | Ajouter des points d'intérêt à la section | Ils apparaissent dans le viewer, à l'origine du modèle | |
| 25.8.7 | Glisser un point, **Alt+glisser** pour la hauteur | Sa position suit | |
| 25.8.8 | Enregistrer, recharger l'écran | Les points sont **là où on les a mis** | |
| 25.8.9 | Vérifier en base | `Model3DPosition` renseigné en jsonb, `Geometry` **inchangé** — les deux positions coexistent | |
**Dans le casque :**
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.8.10 | Ouvrir une scène en mode **Objet** | Le modèle est **posé devant** le visiteur, à hauteur d'yeux ; on tourne autour sans se déplacer | |
| 25.8.10b | Ouvrir une scène en mode **Décor** | Le visiteur est **dedans** et regarde autour. ⚠️ Si en mode Objet on se retrouve *dans* le modèle, le mode n'est pas lu — c'est lui qui décide si le rig est passé au moteur | |
| 25.8.11 | ⚠️ **Le test qui compte : placer trois points à des endroits franchement asymétriques** | Ils sont **exactement là** dans le casque. Une maquette symétrique ne prouve rien — c'est le bug que `GltfSpace` et `calibration.glb` existent pour attraper | |
| 25.8.12 | Viser un point 1,2 s | Il s'éclaircit et **son audio part de lui**, pas du centre de la tête | |
| 25.8.13 | Maquette dont un point n'a pas de position 3D | Il est simplement absent, rien ne plante | |
| 25.8.14 | Modèle absent ou illisible | « Cette maquette n'a pas pu être chargée », retour au menu possible | |
| 25.8.15 | Mesurer le temps de chargement d'un GLB lourd | À rapporter : c'est ce qui dira s'il faut un écran de progression | |
### 25.9 — Mode borne (E10)
Ce qui sépare une démo d'une borne qu'on laisse tourner 8 h. **À jouer à deux**, en se passant le
casque : c'est exactement le scénario visé.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.9.1 | Retirer le casque, le reposer sur la table | Retour au menu **immédiat**, sans attendre le délai | |
| 25.9.2 | Le remettre, tourné dans une **autre direction** | Le menu est **devant soi**, pas dans le dos — c'est le cas qui casse une borne en salle | |
| 25.9.3 | Retirer le casque alors qu'une galerie était ouverte | Le visiteur suivant trouve le **menu**, pas la galerie du précédent | |
| 25.9.4 | Idem, puis regarder les stats | Un `SectionLeave` a bien été émis pour la galerie abandonnée | |
| 25.9.5 | Garder le casque sur la tête, immobile, **90 s** | Retour au menu | |
| 25.9.6 | Lire une longue légende sans bouger, moins de 90 s | **Pas** de retour au menu intempestif — si ça coupe, allonger `idleSeconds` | |
| 25.9.7 | Feuilleter une galerie pendant plus de 90 s, sans bouger la tête | **Pas** de retour au menu : choisir est une activité, même sans mouvement | |
| 25.9.8 | Après un retour au menu, stats du manager | Les événements suivants portent un **`sessionId` différent** — sinon une journée de borne compte pour un seul visiteur | |
| 25.9.9 | Laisser le casque porté, sans rien faire, 10 min | L'écran **ne s'éteint pas** | |
| 25.9.10 | Lancer dans le simulateur, ou dans l'éditeur | La session **ne se réinitialise pas** toutes les 90 s : sans capteur de présence, on considère le casque porté | |
⚠️ **Ce que E10 ne fait pas** : verrouiller le casque sur l'app. Ce n'est pas du code, c'est le mode
appareil de Meta — à régler sur le casque, et à revérifier avant de l'écrire dans une offre.
### 25.10 — Supervision de flotte (XR-5)
Ce qui remplit enfin les colonnes batterie / version / dernier vu de l'onglet XR.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.10.1 | Lancer l'app, puis ouvrir l'onglet XR | Le casque passe **en ligne tout de suite**, pas au bout de 3 min | |
| 25.10.2 | Recharger l'onglet après quelques minutes | **Batterie** cohérente avec celle du casque, **version** = celle du build, **dernier vu** qui avance | |
| 25.10.3 | Quitter l'app, attendre, recharger | Le « dernier vu » **se fige** — c'est ce qui permet de voir qu'un casque est tombé | |
| 25.10.4 | Couper le wifi, laisser tourner | L'app continue normalement, rien ne remonte au visiteur | |
| 25.10.5 | Vérifier en base après un battement | `LastSeen` et `AppVersion` renseignés — **ils n'avaient aucun chemin d'écriture avant** | |
| 25.10.6 | ⚠️ Forger un battement vers un casque d'**une autre instance** | **403** — une clé ne parle que de ses appareils | |
| 25.10.7 | ⚠️ **Appairer une vraie nouvelle tablette** (pas un casque) | Elle s'appaire. **Ça répondait 403 depuis mars** ; les tests ne le prouvent pas, car ils appellent le contrôleur sans passer par les filtres | |
| 25.10.8 | ⚠️ **Redémarrer une tablette déjà appairée** | Elle retrouve sa configuration. Deux causes corrigées ensemble le 12/09 : la route `Device/{id}/detail` refusait les clés, **et** l'app reconstruisait son client sans clé au démarrage | |
| 25.10.9 | Sur cette tablette, ouvrir une visite complète | Rien d'autre n'est retombé : c'est le chemin qui traverse le plus de routes fermées le 12/09 | |
| 25.10.10 | **Assigner une autre configuration au casque** depuis l'onglet XR, sans y toucher | Au bout de **3 min au plus**, le casque revient au menu **avec le nouveau contenu** — sans redémarrage. C'est le « pousser vers le casque » de XR-5, obtenu par le battement plutôt que par MQTT | |
| 25.10.11 | Faire ça pendant qu'une galerie est ouverte | Elle se ferme : le visiteur ne reste pas dans un contenu qui n'est plus le sien | |
| 25.10.12 | **Éteindre un casque**, revenir sur l'onglet XR une heure après | Bandeau rouge **« Muet depuis 1 h »** sur sa carte. ⚠️ La pastille reste verte — c'est le dernier battement qui l'a écrite : c'est précisément ce mensonge que le bandeau corrige | |
| 25.10.13 | Un casque vu il y a 10 minutes | **Pas** de bandeau : le seuil est à 1 h, soit vingt battements manqués | |
| 25.10.14 | Un casque muet depuis plus d'un jour | « Muet depuis N j », pas « depuis 34 h » | |
| 25.10.15 | ⚠️ **mymuseum-visitapp** : ouvrir un contenu dont la ressource **n'a pas d'URL** | L'image se charge quand même. Repéré dans le code sans pouvoir le déclencher : `apiService.dart:111` retombe sur `GET /api/Resource/{id}` en HTTP brut, **sans clé** — donc 401 depuis le 12/09. Le domaine y est en dur (`api.mymuseum.be`), donc ce repli ne marchait déjà plus ailleurs ; à supprimer si le cas n'existe pas | |
### 25.11 — Ce qu'on mesure au passage
Trois chiffres à rapporter, ils n'existent nulle part aujourd'hui :
| # | Mesure | Pourquoi | Valeur |
|---|--------|----------|--------|
| 25.11.1 | Temps entre le lancement et l'affichage des sections, avec et sans cache | Décide si un écran de progression est un détail ou un sujet | |
| 25.11.2 | Taille du JSON d'export d'une vraie configuration (`adb shell ls -l` sur le cache) | Dimensionne le cache disque, et dit si le stockage de l'add-on immersif tient | |
| 25.11.3 | Le casque garde-t-il le wifi en veille ? | Conditionne le rafraîchissement en tâche de fond et la supervision d'XR-5 | |
---
## 26. Médias immersifs dans le manager — ce que voit le gestionnaire (XR-4)
Ce chapitre ne teste ni le casque ni l'API : seulement qu'un conservateur qui n'a jamais entendu
parler de glTF **comprend ce qu'il manipule**. Les trois types immersifs existaient en base
depuis le 12/09 mais étaient invisibles côté Médiathèque — pas de facette, pas d'icône, pas
d'aperçu. Corrigé le même jour, jamais rejoué à l'écran.
Prérequis : une instance avec l'add-on immersif, et dans sa médiathèque au moins une image 360,
une vidéo 360 et un `.glb`.
### 26.1 — Médiathèque
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 26.1.1 | Ouvrir la Médiathèque | Le rail de facettes propose **Image 360°**, **Vidéo 360°** et **Modèle 3D** en fin de liste, avec leur compteur | |
| 26.1.2 | Cliquer la facette « Image 360° » | Seules les 360 restent ; le chip de filtre actif affiche le libellé traduit, pas un code | |
| 26.1.3 | Regarder les vignettes des trois types | Icônes **photosphère / 360 / cube**. Aucun point d'exclamation — c'était le cas par défaut, il se lisait comme une erreur de fichier | |
| 26.1.4 | Ouvrir une **image 360** | Aperçu visible (image équirectangulaire, donc déformée aux pôles — c'est normal) et non un cadre gris vide | |
| 26.1.5 | Ouvrir une **vidéo 360** | Le lecteur vidéo s'ouvre et la vidéo démarre | |
| 26.1.6 | Ouvrir un **modèle 3D** | Phrase explicite : pas d'aperçu ici, le modèle se visualise dans une section Scène 3D et dans le casque. **Pas** de cadre vide sans explication | |
| 26.1.7 | Sur ces trois-là, lire le bloc « Informations » | Type traduit, poids réel, date. Le poids d'une vidéo 360 est en Go — c'est l'information qui justifie le quota | |
| 26.1.8 | Téléverser un `.glb` | Il arrive en **Modèle 3D**, et n'est **pas** compressé (une compression le détruirait) | |
### 26.2 — Déclarer qu'une image est une 360
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 26.2.1 | Dans un sélecteur de médias, regarder la pastille de type d'une image | Un petit **chevron ↔** accolé au libellé : c'est ce qui dit que la pastille est cliquable. Sans lui, le basculement n'existait qu'au survol | |
| 26.2.2 | Survoler la pastille | Infobulle : bascule plat / 360°, l'image n'est pas compressée et part en immersif dans le casque | |
| 26.2.3 | Cliquer | Le libellé passe à « Image 360° » et la pastille change de fond (teinte de marque) | |
| 26.2.4 | Enregistrer, recharger | Le type a tenu, et la ressource n'a **pas** été recompressée | |
| 26.2.5 | Même geste sur une vidéo | Bascule Vidéo ↔ Vidéo 360° | |
| 26.2.6 | Sur un PDF, un JSON, un audio | **Aucun** chevron, pastille non cliquable — rien à basculer | |
### 26.3 — Nommage de la section
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 26.3.1 | Créer une section, lire la liste des types | **« Scène 3D »** — plus « Maquette 3D », qui excluait l'épée ou la pièce de collection | |
| 26.3.2 | Lire sa description | Elle annonce **objet ou décor**, les points d'intérêt et le casque VR | |
| 26.3.3 | Passer le manager en **EN** | « 3D scene » pour la section, « 3D model » pour la ressource — deux libellés **distincts**. Ils étaient identiques, on ne savait pas lequel on choisissait | |
| 26.3.4 | Passer en **NL** | « 3D-scène » / « 3D-model », même distinction | |
| 26.3.5 | Ouvrir la section, lire le sélecteur de mode | Titre « Ce que le visiteur fait de la scène », puis **Objet** / **Décor**, chacun avec sa phrase d'exemple (une épée / une salle reconstituée) | |
| 26.3.6 | Basculer d'un mode à l'autre, enregistrer, recharger | Le mode a tenu. Il ne change rien à l'écran du manager : il ne se voit que dans le casque | |
| 26.3.7 | Section sans modèle choisi | « Choisissez d'abord un modèle 3D dans la médiathèque », et pas une zone morte | |
---
## 27. Hors ligne complet et fond immersif (XR-4, E4 et E5)
Écrit le 2026-09-12, **jamais exécuté**. Deux chantiers indépendants, regroupés parce qu'ils se
testent au même moment — le second n'a de sens que si le premier a rempli le cache.
### 27.1 — Préchargement des médias
Le cache existait mais ne se remplissait **qu'à la demande** : une borne branchée sans réseau
avait ses titres et pas une image. C'est ce que ce bloc vérifie, et c'est l'argument qui a fait
choisir Unity contre WebXR.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 27.1.1 | Casque neuf (`forgetPairing`), appairer, laisser l'app **cinq minutes** sur le menu sans rien toucher | Dans `adb logcat` : `[Preload] N médias à mettre en cache`, puis `[Preload] Terminé — N/N disponibles hors ligne`, avec le poids du cache en Mo | |
| 27.1.2 | Relancer l'app **sans rien effacer** | `N déjà en cache`, **0 téléchargement** : le préchargement ne retélécharge jamais ce qu'il a | |
| 27.1.3 | **Couper le wifi du casque**, relancer | Le menu s'affiche, et **chaque section s'ouvre avec ses images** — c'est le seul test qui compte | |
| 27.1.4 | Toujours hors ligne : ouvrir une 360, une vidéo 360, une scène 3D | Les trois se chargent depuis le disque | |
| 27.1.5 | Toujours hors ligne : viser un point d'intérêt qui porte un audio | **Le son sort.** Il était streamé depuis l'URL : le hotspot était muet dès que le réseau tombait, alors que le fichier pouvait être sur le casque | |
| 27.1.6 | Une configuration contenant une section Video **YouTube** | Elle n'est **pas** téléchargée (lien externe), et le journal ne compte pas d'échec pour elle | |
| 27.1.7 | Une configuration avec un PDF et un JSON | Pas téléchargés non plus : le casque ne les affiche pas, les prendre ne ferait que remplir le disque | |
| 27.1.8 | Couper le wifi **pendant** le préchargement, le remettre, relancer l'app | Les médias déjà pris sont gardés, les autres repartent. Aucun message au visiteur | |
| 27.1.9 | ⚠️ **Mesurer** : durée du préchargement complet et poids final du cache, sur une vraie configuration | C'est le chiffre qui dira si une borne peut être mise en service le matin même ou la veille | |
| 27.1.10 | Vérifier l'espace disque du casque après plusieurs configurations | Rien ne purge aujourd'hui. Si ça monte, c'est un sujet — pas un bug | |
### 27.2 — Fond immersif, côté manager
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 27.2.1 | Instance **sans** l'add-on immersif → ouvrir une configuration | **Aucune** carte « Fond immersif » : pas de réglage qu'on ne peut ni remplir ni voir | |
| 27.2.2 | Activer l'add-on (écran SuperAdmin), rouvrir | La carte apparaît | |
| 27.2.3 | Choisir une **image 360** comme fond | Sous le champ : « Panorama 360° ». Le type n'est **jamais demandé** — il est déduit du média, donc impossible à renseigner faux | |
| 27.2.4 | Choisir une **vidéo 360** à la place | Le libellé devient « Vidéo 360° », avec l'avertissement sur le décodeur d'une borne | |
| 27.2.5 | Choisir un **modèle 3D** | « Décor 3D : prévu par le contrat, pas encore affiché en fond » — annoncé avant, pas découvert dans le casque | |
| 27.2.6 | Ne **pas** renseigner l'image de repli, enregistrer | Autorisé, mais la phrase d'aide dit ce que ça coûte : web, mobile et tablettes n'afficheront aucun fond | |
| 27.2.7 | Renseigner le repli, enregistrer, recharger | Les deux ont tenu | |
| 27.2.8 | « Retirer le fond », enregistrer, recharger | Le fond est parti — et **pas** un fond vide avec un type resté collé | |
| 27.2.9 | Onglet XR → sous-onglet Configuration, en bas | La même carte, pour le **fond du menu d'accueil** cette fois, enregistré immédiatement | |
| 27.2.10 | Le même écran pour le mobile, le web, le kiosk | **Aucune** carte de fond : ces canaux n'ont pas de menu à décorer | |
| 27.2.11 | Passer en EN et NL | Les dix libellés traduits | |
### 27.3 — Fond immersif, côté casque
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 27.3.1 | Configuration **sans** fond, lancer l'app | Menu sur fond noir, comme avant. Aucun message, aucune erreur | |
| 27.3.2 | Configuration avec un **panorama**, relancer | Le menu flotte **dans le lieu**, pas dans le noir. C'est tout l'objet du chantier | |
| 27.3.3 | Ouvrir une section 360 depuis ce menu | La 360 remplace le fond | |
| 27.3.4 | La fermer | **Le fond revient.** `SkyboxView` restaure le ciel précédent, et le ciel précédent, c'est le fond | |
| 27.3.5 | Fond en **vidéo 360** | Elle tourne en boucle et **sans son** — un fond qui parle par-dessus un commentaire serait intenable | |
| 27.3.6 | ⚠️ Laisser une borne à fond vidéo tourner **une heure**, relever la batterie | Le décodeur tourne en permanence. À mesurer avant de vendre ce cas comme normal | |
| 27.3.7 | Assigner au casque une configuration **avec un autre fond**, attendre le battement | Le nouveau fond remplace l'ancien, sans redémarrage et sans que les deux vidéos tournent | |
| 27.3.8 | Fond de type **Scène 3D** | Ciel par défaut + une ligne dans le journal. Pas d'écran noir muet | |
| 27.3.9 | ⚠️ Fond dont la ressource a été **supprimée** de la médiathèque | Le menu reste utilisable sur fond noir. Jamais un plantage au démarrage | |
| 27.3.10 | Reposer le casque, le reprendre (nouvelle visite) | Le fond est toujours là : il appartient à la visite, pas à la session | |
---
## Hors scope (non implémenté)
| Feature | Statut |
|---------|--------|
| 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 | ❌ |