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

105 KiB
Raw Blame History

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

  1. Porte d'entrée — « est-ce que ça build ? » ⚠️ à passer en premier
  2. Migration DB & prérequis
  3. SectionAgenda
  4. SectionWeather
  5. SectionEvent
  6. Game — Escape & Puzzle
  7. SectionMap / Parcours guidés
  8. Notifications push
  9. Statistiques
  10. Beacons / géofencing
  11. AI Assistant
  12. Quotas & Plans d'abonnement
  13. API Keys — date d'expiration
  14. Manager-app — GuidedStep champs avancés
  15. Manager-app — SectionEvent annotations par bloc
  16. Backend — Bug GuidedPath SectionGameId
  17. Non-régression
  18. SectionVideo — multi-sources
  19. Onboarding self-service (plan Essentiel)
  20. Parité manager-app → apps visiteur (par type de section)
  21. Écarts de parité connus — à rejouer après correction
  22. Visite hors ligne — diagnostic terrain ⚠️ bugs suspectés, à jouer tôt
  23. QR codes, nom d'application, page de téléchargement, proximité
  24. Onglet XR — flotte de casques (lot XR-2)
  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)
  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 § É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.

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 isGoodisCorrect 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. 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. 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.
À 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

# 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()));'
# 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.

# 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));'
# 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.

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.

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'appairerE2 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