From 74e31c143813dddfc727fd832da72626c2374219 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:10:12 +0200 Subject: [PATCH 1/9] =?UTF-8?q?fix(postures):=20allumer=20les=20postures?= =?UTF-8?q?=20impos=C3=A9es=20et=20pauses=20sc=C3=A9naris=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les notes de version 0.6.0 annoncent les postures imposées et les pauses scénarisées, mais leur tirage était conditionné à un toggle de la section debug de l'écran SONS, off par défaut : la fonctionnalité était invisible pour tout le monde depuis deux versions. Le réglage devient une préférence utilisateur assumée (`pref.scripted_breaks`, on par défaut, section « Déroulé de séance » du Profil) au lieu d'un flag debug. L'ancienne clé `debug.scripted_breaks` est abandonnée plutôt que migrée : la reprendre rendrait le off historique collant. Les presets de debug `reset` et `mi_carriere_profondeur`, qui recopiaient les défauts d'usine, sont réalignés sur le nouveau défaut. --- .../profiles/mi_carriere_profondeur.json | 2 +- rhythm_coach/assets/debug/profiles/reset.json | 2 +- .../lib/career/screens/career_screen.dart | 9 +- .../services/debug_settings_service.dart | 19 ++-- .../generation/career_session_generator.dart | 15 ++- .../services/generation/session_config.dart | 10 +- rhythm_coach/lib/l10n/app_de.arb | 5 +- rhythm_coach/lib/l10n/app_en.arb | 5 +- rhythm_coach/lib/l10n/app_es.arb | 5 +- rhythm_coach/lib/l10n/app_fr.arb | 5 +- rhythm_coach/lib/l10n/app_localizations.dart | 30 +++--- .../lib/l10n/app_localizations_de.dart | 17 +-- .../lib/l10n/app_localizations_en.dart | 17 +-- .../lib/l10n/app_localizations_es.dart | 17 +-- .../lib/l10n/app_localizations_fr.dart | 17 +-- rhythm_coach/lib/screens/profile_screen.dart | 53 ++++++++++ .../lib/screens/sound_demo_screen.dart | 26 ----- .../services/diagnostic_import_service.dart | 8 +- .../test/diagnostic_import_service_test.dart | 2 +- .../test/scripted_breaks_enabled_test.dart | 100 ++++++++++++++++++ 20 files changed, 261 insertions(+), 103 deletions(-) create mode 100644 rhythm_coach/test/scripted_breaks_enabled_test.dart diff --git a/rhythm_coach/assets/debug/profiles/mi_carriere_profondeur.json b/rhythm_coach/assets/debug/profiles/mi_carriere_profondeur.json index 62e53524..c4ab3c38 100644 --- a/rhythm_coach/assets/debug/profiles/mi_carriere_profondeur.json +++ b/rhythm_coach/assets/debug/profiles/mi_carriere_profondeur.json @@ -131,7 +131,7 @@ "skipSessionButton": false, "showBackgroundMedia": true, "showSessionRemainingTime": false, - "scriptedBreaks": false + "scriptedBreaks": true }, "consent": { "adultConsentAccepted": true, diff --git a/rhythm_coach/assets/debug/profiles/reset.json b/rhythm_coach/assets/debug/profiles/reset.json index 35b300c3..dc4d2efa 100644 --- a/rhythm_coach/assets/debug/profiles/reset.json +++ b/rhythm_coach/assets/debug/profiles/reset.json @@ -81,7 +81,7 @@ "skipSessionButton": false, "showBackgroundMedia": true, "showSessionRemainingTime": false, - "scriptedBreaks": false + "scriptedBreaks": true }, "consent": { "adultConsentAccepted": true, diff --git a/rhythm_coach/lib/career/screens/career_screen.dart b/rhythm_coach/lib/career/screens/career_screen.dart index 69ca7991..6bc399ee 100644 --- a/rhythm_coach/lib/career/screens/career_screen.dart +++ b/rhythm_coach/lib/career/screens/career_screen.dart @@ -2084,9 +2084,12 @@ class _CareerBundle { /// Sert au déblocage des coachs par investissement (Phase 19.10). final int totalSeconds; - /// Flag debug `debug.scripted_breaks` (issue #77). Quand `true`, les - /// postures imposées + breaks scénarisés sont activés : passé à - /// `generate(scriptedBreaks:)` à tous les call sites. + /// Préférence utilisateur `pref.scripted_breaks` (issue #77), on par défaut. + /// Quand `true`, les postures imposées + breaks scénarisés sont activés. + /// Passé à `generate(scriptedBreaks:)` sur les deux entrées qui construisent + /// un contrôleur frais (`_start`, `_handleEncore`) — pas aux régens + /// mi-séance, qui swappent `_session` en vol et repartent sans break (cf. + /// reset d'état dans `requestUpgrade`). final bool scriptedBreaks; const _CareerBundle({ diff --git a/rhythm_coach/lib/career/services/debug_settings_service.dart b/rhythm_coach/lib/career/services/debug_settings_service.dart index f8417575..4446f8c9 100644 --- a/rhythm_coach/lib/career/services/debug_settings_service.dart +++ b/rhythm_coach/lib/career/services/debug_settings_service.dart @@ -14,7 +14,7 @@ class DebugSettingsService { static const String _kShowBackgroundMedia = 'pref.show_background_media'; static const String _kShowSessionRemainingTime = 'pref.show_session_remaining_time'; - static const String _kScriptedBreaks = 'debug.scripted_breaks'; + static const String _kScriptedBreaks = 'pref.scripted_breaks'; Future getShowStaminaBar() async { final prefs = await SharedPreferences.getInstance(); @@ -159,14 +159,19 @@ class DebugSettingsService { await prefs.setBool(_kShowSessionRemainingTime, value); } - /// Quand true, active les postures imposées + breaks scénarisés en carrière - /// (issue #77) : posture tirée à l'intro et pauses actives de récup sur les - /// sessions longues. Off par défaut le temps de calibrer (cf. spec - /// `specs/scripted_breaks.md`). Lu par `career_screen` et passé au - /// générateur via `generate(scriptedBreaks:)`. + /// Quand true (défaut), active les postures imposées + breaks scénarisés en + /// carrière (issue #77) : posture tirée à l'intro et pauses actives de récup + /// sur les sessions longues. Lu par `career_screen` et passé au générateur + /// via `generate(scriptedBreaks:)`. + /// + /// Préférence utilisateur assumée (clé `pref.`, réglage dans le Profil) et + /// non plus flag debug : le gate métier de la fonctionnalité, ce sont les + /// milestones `intro_posture_*` qui débloquent chaque posture. L'ancienne + /// clé `debug.scripted_breaks` est délibérément abandonnée — la reprendre + /// rendrait le off historique (= le défaut d'avant) collant. Future getScriptedBreaks() async { final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(_kScriptedBreaks) ?? false; + return prefs.getBool(_kScriptedBreaks) ?? true; } Future setScriptedBreaks(bool value) async { diff --git a/rhythm_coach/lib/career/services/generation/career_session_generator.dart b/rhythm_coach/lib/career/services/generation/career_session_generator.dart index bf0d6ce3..5201b5b7 100644 --- a/rhythm_coach/lib/career/services/generation/career_session_generator.dart +++ b/rhythm_coach/lib/career/services/generation/career_session_generator.dart @@ -562,11 +562,9 @@ class CareerSessionGenerator { /// sont insérés vers 60 % du temps planifié (cf. spec § 4.3). ChallengeInputs challenge = ChallengeInputs.none, - /// Active les postures imposées + breaks scénarisés (issue #77, flag - /// debug `debug.scripted_breaks`). `false` = posture `free`, aucun - /// break (comportement historique). En PR3 seule la posture initiale - /// est tirée ; l'insertion des breaks suit. Cf. spec - /// `specs/scripted_breaks.md`. + /// Active les postures imposées + breaks scénarisés (issue #77, préférence + /// utilisateur `pref.scripted_breaks`). `false` = posture `free`, aucun + /// break. Cf. spec `specs/scripted_breaks.md`. bool scriptedBreaks = false, }) { // Invariants `milestones` : on ne peut pas les déplacer dans le @@ -808,7 +806,7 @@ class CareerSessionGenerator { // longues. Comme les défis : trigger times planifiés, insertion aux // frontières de blocs dans la boucle main, réservation de l'enveloppe // (trou d'effort = `durationSeconds`, le runtime gèle le moteur — cf. - // PR4). Gaté par le flag debug `scriptedBreaks` ; 0 break si off. + // PR4). Gaté par `_config.scriptedBreaks` ; 0 break si off. // `currentPose` suit la posture en cours (départ = posture initiale) pour // que le 1ᵉʳ break impose une pose *différente* ; le 2ᵉ tend vers récup // pure (`newPose == null`, continuité de pose). @@ -1604,12 +1602,11 @@ class CareerSessionGenerator { /// Posture imposée au démarrage de la séance courante. Tirée en début de /// `generate()` (où `unlockedKeys` est typé `Set`) et lue par - /// `_assembleResult`. `free` tant que le flag debug `scriptedBreaks` est - /// off. + /// `_assembleResult`. `free` tant que `_config.scriptedBreaks` est off. Posture _initialPose = Posture.free; /// Tire la posture imposée au démarrage (issue #77). [Posture.free] tant - /// que le flag debug `scriptedBreaks` est off ; sinon tirage uniforme + /// que `_config.scriptedBreaks` est off ; sinon tirage uniforme /// parmi les postures débloquées (`free` incluse dans le pool). Bas niveau /// / rien de débloqué ⇒ `free` de toute façon. Déterministe sous seed via /// `_rng`. diff --git a/rhythm_coach/lib/career/services/generation/session_config.dart b/rhythm_coach/lib/career/services/generation/session_config.dart index 20cdef28..785e21df 100644 --- a/rhythm_coach/lib/career/services/generation/session_config.dart +++ b/rhythm_coach/lib/career/services/generation/session_config.dart @@ -102,9 +102,13 @@ class SessionConfig { /// `intense` est actif sur l'encore (cf. `generate()`). final int encoreChainIndex; - /// Active les postures imposées + breaks scénarisés (issue #77, flag debug - /// `debug.scripted_breaks`). `false` = comportement historique (posture - /// `free`, aucun break). Cf. spec `specs/scripted_breaks.md`. + /// Active les postures imposées + breaks scénarisés (issue #77, préférence + /// utilisateur `pref.scripted_breaks`). `false` = posture `free` et aucun + /// break. Cf. spec `specs/scripted_breaks.md`. + /// + /// Défaut `false` ici (et non `true` comme la préférence) : les appelants + /// hors carrière — Custom, Music, tests de génération — n'ont pas de + /// postures à imposer et ne passent pas l'argument. final bool scriptedBreaks; /// Mode « Utilise-moi » (remplace Supplier en carrière) : escalade diff --git a/rhythm_coach/lib/l10n/app_de.arb b/rhythm_coach/lib/l10n/app_de.arb index c8412310..0a3e4e03 100644 --- a/rhythm_coach/lib/l10n/app_de.arb +++ b/rhythm_coach/lib/l10n/app_de.arb @@ -648,8 +648,6 @@ "soundsDebugCameraHoldCheckSubtitle": "Während der Holds prüft die Frontkamera, ob die Position gehalten wird. Der Coach gibt einen kurzen Hinweis, wenn du abdriftest. Erfordert eine kalibrierte Kamera (Kamera-Symbol im SZENARIO-Bildschirm).", "soundsDebugSkipSession": "Knopf „als Erfolg beenden“", "soundsDebugSkipSessionSubtitle": "Zeigt einen Knopf in der Session, der sie sofort als vollen Erfolg beendet (Abzeichen, Meilensteine, Stufe). Praktisch, um an Inhalten zu feilen, ohne alles durchzuspielen.", - "soundsDebugScriptedBreaks": "Vorgegebene Posen + Breaks", - "soundsDebugScriptedBreaksSubtitle": "Aktiviert vorgegebene Posen (kniend, auf allen vieren…) und aktive Erholungspausen in langen Sessions. Experimentell, in Kalibrierung.", "soundsShowBackgroundMedia": "Hintergrundmedien in der Session", "soundsShowBackgroundMediaSubtitle": "Zeigt die Bilder/GIFs aus assets/backgrounds/ im Hintergrund, mit Wechsel bei jedem Step. Deaktivieren, um nur den Ambiente-Verlauf zu sehen.", "sessionDebugFinishButton": "DEBUG: als Erfolg beenden", @@ -1102,6 +1100,9 @@ "profileSessionDisplaySection": "Sitzungsanzeige", "profileShowRemainingTime": "Restzeit anzeigen", "profileShowRemainingTimeSubtitle": "Kleine mm:ss-Uhr oben auf dem Bildschirm während der Sitzung.", + "profileSessionFlowSection": "Sitzungsablauf", + "profileScriptedBreaks": "Vorgegebene Posen und Pausen", + "profileScriptedBreaksSubtitle": "Die Domina gibt eine Pose vor (kniend, auf allen vieren …) und baut in langen Sitzungen aktive Pausen ein. Die Posen werden nach und nach über die Karriere-Meilensteine freigeschaltet.", "sessionRemainingTimeLabel": "Rest: {time}", "@sessionRemainingTimeLabel": { "placeholders": { diff --git a/rhythm_coach/lib/l10n/app_en.arb b/rhythm_coach/lib/l10n/app_en.arb index 275697fc..426b9bc2 100644 --- a/rhythm_coach/lib/l10n/app_en.arb +++ b/rhythm_coach/lib/l10n/app_en.arb @@ -648,8 +648,6 @@ "soundsDebugCameraHoldCheckSubtitle": "During holds, the front camera checks that the position is being held. The coach gives a short cue if you drift. Requires the camera to be calibrated (camera icon in the SCENARIO screen).", "soundsDebugSkipSession": "“Finish as success” button", "soundsDebugSkipSessionSubtitle": "Shows a button in the session that ends it immediately as a full success (badges, milestones, level). Useful to iterate on content without playing through.", - "soundsDebugScriptedBreaks": "Scripted postures + breaks", - "soundsDebugScriptedBreaksSubtitle": "Enables imposed postures (kneeling, on all fours…) and active recovery breaks on long sessions. Experimental, being calibrated.", "soundsShowBackgroundMedia": "Background media in session", "soundsShowBackgroundMediaSubtitle": "Shows images/GIFs from assets/backgrounds/ in the background, rotating each step. Disable to see only the ambience gradient.", "sessionDebugFinishButton": "DEBUG: finish as success", @@ -1102,6 +1100,9 @@ "profileSessionDisplaySection": "Session display", "profileShowRemainingTime": "Show remaining time", "profileShowRemainingTimeSubtitle": "Small mm:ss clock at the top of the screen during the session.", + "profileSessionFlowSection": "Session flow", + "profileScriptedBreaks": "Enforced postures and breaks", + "profileScriptedBreaksSubtitle": "The coach enforces a posture (kneeling, on all fours…) and inserts active breaks on long sessions. Postures unlock as you progress through career milestones.", "sessionRemainingTimeLabel": "Left: {time}", "@sessionRemainingTimeLabel": { "placeholders": { diff --git a/rhythm_coach/lib/l10n/app_es.arb b/rhythm_coach/lib/l10n/app_es.arb index 82cb3cd0..96638f53 100644 --- a/rhythm_coach/lib/l10n/app_es.arb +++ b/rhythm_coach/lib/l10n/app_es.arb @@ -648,8 +648,6 @@ "soundsDebugCameraHoldCheckSubtitle": "Durante los holds, la cámara frontal comprueba que se mantiene la posición. El coach te avisa breve si te desvías. Requiere calibrar la cámara antes (icono cámara en la pantalla ESCENARIO).", "soundsDebugSkipSession": "Botón «Terminar como éxito»", "soundsDebugSkipSessionSubtitle": "Muestra un botón en la sesión que la termina inmediatamente como éxito total (insignias, hitos, nivel). Útil para iterar sobre el contenido sin jugar.", - "soundsDebugScriptedBreaks": "Posturas + pausas guionizadas", - "soundsDebugScriptedBreaksSubtitle": "Activa las posturas impuestas (de rodillas, a cuatro patas…) y las pausas activas de recuperación en sesiones largas. Experimental, en calibración.", "soundsShowBackgroundMedia": "Medios de fondo en sesión", "soundsShowBackgroundMediaSubtitle": "Muestra imágenes/GIFs de assets/backgrounds/ en el fondo, rotando con cada paso. Desactívalo para ver solo el degradado de ambiente.", "sessionDebugFinishButton": "DEBUG: terminar como éxito", @@ -1102,6 +1100,9 @@ "profileSessionDisplaySection": "Visualización de sesión", "profileShowRemainingTime": "Mostrar tiempo restante", "profileShowRemainingTimeSubtitle": "Reloj mm:ss pequeño en la parte alta de la pantalla durante la sesión.", + "profileSessionFlowSection": "Desarrollo de la sesión", + "profileScriptedBreaks": "Posturas impuestas y pausas", + "profileScriptedBreaksSubtitle": "La coach impone una postura (de rodillas, a cuatro patas…) e inserta pausas activas en las sesiones largas. Las posturas se desbloquean a medida que avanzas por los hitos de carrera.", "sessionRemainingTimeLabel": "Quedan: {time}", "@sessionRemainingTimeLabel": { "placeholders": { diff --git a/rhythm_coach/lib/l10n/app_fr.arb b/rhythm_coach/lib/l10n/app_fr.arb index f8740dd0..30c51d28 100644 --- a/rhythm_coach/lib/l10n/app_fr.arb +++ b/rhythm_coach/lib/l10n/app_fr.arb @@ -648,8 +648,6 @@ "soundsDebugCameraHoldCheckSubtitle": "Pendant les holds, la caméra avant vérifie que la position est tenue. Le coach lance un rappel court si tu dérives. Nécessite d'avoir calibré la caméra (icône caméra de l'écran SCÉNARIO).", "soundsDebugSkipSession": "Bouton « terminer en succès »", "soundsDebugSkipSessionSubtitle": "Affiche un bouton dans la séance qui termine immédiatement comme un succès complet (badges, milestones, niveau). Pratique pour itérer sur le contenu sans tout jouer.", - "soundsDebugScriptedBreaks": "Postures + breaks scénarisés", - "soundsDebugScriptedBreaksSubtitle": "Active les postures imposées (à genoux, à quatre pattes…) et les pauses actives de récup sur les sessions longues. Expérimental, en cours de calibration.", "soundsShowBackgroundMedia": "Fonds média en séance", "soundsShowBackgroundMediaSubtitle": "Affiche les images/GIF présents dans assets/backgrounds/ en arrière-plan, avec rotation à chaque step. Désactive pour ne voir que le dégradé d'ambiance.", "sessionDebugFinishButton": "DEBUG : terminer en succès", @@ -1102,6 +1100,9 @@ "profileSessionDisplaySection": "Affichage de session", "profileShowRemainingTime": "Afficher le temps restant", "profileShowRemainingTimeSubtitle": "Petite horloge mm:ss en haut de l'écran pendant la séance.", + "profileSessionFlowSection": "Déroulé de séance", + "profileScriptedBreaks": "Postures imposées et pauses", + "profileScriptedBreaksSubtitle": "La coach impose une posture (à genoux, à quatre pattes…) et insère des pauses actives sur les séances longues. Les postures se débloquent au fil des paliers de carrière.", "sessionRemainingTimeLabel": "Restant : {time}", "@sessionRemainingTimeLabel": { "placeholders": { diff --git a/rhythm_coach/lib/l10n/app_localizations.dart b/rhythm_coach/lib/l10n/app_localizations.dart index ebb03c1e..b0619948 100644 --- a/rhythm_coach/lib/l10n/app_localizations.dart +++ b/rhythm_coach/lib/l10n/app_localizations.dart @@ -2364,18 +2364,6 @@ abstract class AppLocalizations { /// **'Affiche un bouton dans la séance qui termine immédiatement comme un succès complet (badges, milestones, niveau). Pratique pour itérer sur le contenu sans tout jouer.'** String get soundsDebugSkipSessionSubtitle; - /// No description provided for @soundsDebugScriptedBreaks. - /// - /// In fr, this message translates to: - /// **'Postures + breaks scénarisés'** - String get soundsDebugScriptedBreaks; - - /// No description provided for @soundsDebugScriptedBreaksSubtitle. - /// - /// In fr, this message translates to: - /// **'Active les postures imposées (à genoux, à quatre pattes…) et les pauses actives de récup sur les sessions longues. Expérimental, en cours de calibration.'** - String get soundsDebugScriptedBreaksSubtitle; - /// No description provided for @soundsShowBackgroundMedia. /// /// In fr, this message translates to: @@ -3996,6 +3984,24 @@ abstract class AppLocalizations { /// **'Petite horloge mm:ss en haut de l\'écran pendant la séance.'** String get profileShowRemainingTimeSubtitle; + /// No description provided for @profileSessionFlowSection. + /// + /// In fr, this message translates to: + /// **'Déroulé de séance'** + String get profileSessionFlowSection; + + /// No description provided for @profileScriptedBreaks. + /// + /// In fr, this message translates to: + /// **'Postures imposées et pauses'** + String get profileScriptedBreaks; + + /// No description provided for @profileScriptedBreaksSubtitle. + /// + /// In fr, this message translates to: + /// **'La coach impose une posture (à genoux, à quatre pattes…) et insère des pauses actives sur les séances longues. Les postures se débloquent au fil des paliers de carrière.'** + String get profileScriptedBreaksSubtitle; + /// No description provided for @sessionRemainingTimeLabel. /// /// In fr, this message translates to: diff --git a/rhythm_coach/lib/l10n/app_localizations_de.dart b/rhythm_coach/lib/l10n/app_localizations_de.dart index a7b83010..8a9e033f 100644 --- a/rhythm_coach/lib/l10n/app_localizations_de.dart +++ b/rhythm_coach/lib/l10n/app_localizations_de.dart @@ -1337,13 +1337,6 @@ class AppLocalizationsDe extends AppLocalizations { String get soundsDebugSkipSessionSubtitle => 'Zeigt einen Knopf in der Session, der sie sofort als vollen Erfolg beendet (Abzeichen, Meilensteine, Stufe). Praktisch, um an Inhalten zu feilen, ohne alles durchzuspielen.'; - @override - String get soundsDebugScriptedBreaks => 'Vorgegebene Posen + Breaks'; - - @override - String get soundsDebugScriptedBreaksSubtitle => - 'Aktiviert vorgegebene Posen (kniend, auf allen vieren…) und aktive Erholungspausen in langen Sessions. Experimentell, in Kalibrierung.'; - @override String get soundsShowBackgroundMedia => 'Hintergrundmedien in der Session'; @@ -2266,6 +2259,16 @@ class AppLocalizationsDe extends AppLocalizations { String get profileShowRemainingTimeSubtitle => 'Kleine mm:ss-Uhr oben auf dem Bildschirm während der Sitzung.'; + @override + String get profileSessionFlowSection => 'Sitzungsablauf'; + + @override + String get profileScriptedBreaks => 'Vorgegebene Posen und Pausen'; + + @override + String get profileScriptedBreaksSubtitle => + 'Die Domina gibt eine Pose vor (kniend, auf allen vieren …) und baut in langen Sitzungen aktive Pausen ein. Die Posen werden nach und nach über die Karriere-Meilensteine freigeschaltet.'; + @override String sessionRemainingTimeLabel(String time) { return 'Rest: $time'; diff --git a/rhythm_coach/lib/l10n/app_localizations_en.dart b/rhythm_coach/lib/l10n/app_localizations_en.dart index 7802d2a7..448979f1 100644 --- a/rhythm_coach/lib/l10n/app_localizations_en.dart +++ b/rhythm_coach/lib/l10n/app_localizations_en.dart @@ -1331,13 +1331,6 @@ class AppLocalizationsEn extends AppLocalizations { String get soundsDebugSkipSessionSubtitle => 'Shows a button in the session that ends it immediately as a full success (badges, milestones, level). Useful to iterate on content without playing through.'; - @override - String get soundsDebugScriptedBreaks => 'Scripted postures + breaks'; - - @override - String get soundsDebugScriptedBreaksSubtitle => - 'Enables imposed postures (kneeling, on all fours…) and active recovery breaks on long sessions. Experimental, being calibrated.'; - @override String get soundsShowBackgroundMedia => 'Background media in session'; @@ -2255,6 +2248,16 @@ class AppLocalizationsEn extends AppLocalizations { String get profileShowRemainingTimeSubtitle => 'Small mm:ss clock at the top of the screen during the session.'; + @override + String get profileSessionFlowSection => 'Session flow'; + + @override + String get profileScriptedBreaks => 'Enforced postures and breaks'; + + @override + String get profileScriptedBreaksSubtitle => + 'The coach enforces a posture (kneeling, on all fours…) and inserts active breaks on long sessions. Postures unlock as you progress through career milestones.'; + @override String sessionRemainingTimeLabel(String time) { return 'Left: $time'; diff --git a/rhythm_coach/lib/l10n/app_localizations_es.dart b/rhythm_coach/lib/l10n/app_localizations_es.dart index 4a11bba5..7eb2a223 100644 --- a/rhythm_coach/lib/l10n/app_localizations_es.dart +++ b/rhythm_coach/lib/l10n/app_localizations_es.dart @@ -1337,13 +1337,6 @@ class AppLocalizationsEs extends AppLocalizations { String get soundsDebugSkipSessionSubtitle => 'Muestra un botón en la sesión que la termina inmediatamente como éxito total (insignias, hitos, nivel). Útil para iterar sobre el contenido sin jugar.'; - @override - String get soundsDebugScriptedBreaks => 'Posturas + pausas guionizadas'; - - @override - String get soundsDebugScriptedBreaksSubtitle => - 'Activa las posturas impuestas (de rodillas, a cuatro patas…) y las pausas activas de recuperación en sesiones largas. Experimental, en calibración.'; - @override String get soundsShowBackgroundMedia => 'Medios de fondo en sesión'; @@ -2264,6 +2257,16 @@ class AppLocalizationsEs extends AppLocalizations { String get profileShowRemainingTimeSubtitle => 'Reloj mm:ss pequeño en la parte alta de la pantalla durante la sesión.'; + @override + String get profileSessionFlowSection => 'Desarrollo de la sesión'; + + @override + String get profileScriptedBreaks => 'Posturas impuestas y pausas'; + + @override + String get profileScriptedBreaksSubtitle => + 'La coach impone una postura (de rodillas, a cuatro patas…) e inserta pausas activas en las sesiones largas. Las posturas se desbloquean a medida que avanzas por los hitos de carrera.'; + @override String sessionRemainingTimeLabel(String time) { return 'Quedan: $time'; diff --git a/rhythm_coach/lib/l10n/app_localizations_fr.dart b/rhythm_coach/lib/l10n/app_localizations_fr.dart index b2bc204e..b66cb43f 100644 --- a/rhythm_coach/lib/l10n/app_localizations_fr.dart +++ b/rhythm_coach/lib/l10n/app_localizations_fr.dart @@ -1340,13 +1340,6 @@ class AppLocalizationsFr extends AppLocalizations { String get soundsDebugSkipSessionSubtitle => 'Affiche un bouton dans la séance qui termine immédiatement comme un succès complet (badges, milestones, niveau). Pratique pour itérer sur le contenu sans tout jouer.'; - @override - String get soundsDebugScriptedBreaks => 'Postures + breaks scénarisés'; - - @override - String get soundsDebugScriptedBreaksSubtitle => - 'Active les postures imposées (à genoux, à quatre pattes…) et les pauses actives de récup sur les sessions longues. Expérimental, en cours de calibration.'; - @override String get soundsShowBackgroundMedia => 'Fonds média en séance'; @@ -2266,6 +2259,16 @@ class AppLocalizationsFr extends AppLocalizations { String get profileShowRemainingTimeSubtitle => 'Petite horloge mm:ss en haut de l\'écran pendant la séance.'; + @override + String get profileSessionFlowSection => 'Déroulé de séance'; + + @override + String get profileScriptedBreaks => 'Postures imposées et pauses'; + + @override + String get profileScriptedBreaksSubtitle => + 'La coach impose une posture (à genoux, à quatre pattes…) et insère des pauses actives sur les séances longues. Les postures se débloquent au fil des paliers de carrière.'; + @override String sessionRemainingTimeLabel(String time) { return 'Restant : $time'; diff --git a/rhythm_coach/lib/screens/profile_screen.dart b/rhythm_coach/lib/screens/profile_screen.dart index 48f31b7f..575a830c 100644 --- a/rhythm_coach/lib/screens/profile_screen.dart +++ b/rhythm_coach/lib/screens/profile_screen.dart @@ -222,6 +222,10 @@ class _ProfileScreenState extends State { const SizedBox(height: 8), const _SessionDisplaySection(), const SizedBox(height: 24), + _SectionLabel(t.profileSessionFlowSection), + const SizedBox(height: 8), + const _SessionFlowSection(), + const SizedBox(height: 24), _SectionLabel(t.profileDiagnosticSection), const SizedBox(height: 8), const DiagnosticExportSection(), @@ -426,6 +430,55 @@ class _SessionDisplaySectionState extends State<_SessionDisplaySection> { } } +class _SessionFlowSection extends StatefulWidget { + const _SessionFlowSection(); + + @override + State<_SessionFlowSection> createState() => _SessionFlowSectionState(); +} + +class _SessionFlowSectionState extends State<_SessionFlowSection> { + final DebugSettingsService _debug = DebugSettingsService(); + bool _scriptedBreaks = true; + + @override + void initState() { + super.initState(); + _debug.getScriptedBreaks().then((value) { + if (!mounted) return; + setState(() => _scriptedBreaks = value); + }); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text( + t.profileScriptedBreaks, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textPrimary, + ), + ), + subtitle: Text( + t.profileScriptedBreaksSubtitle, + style: const TextStyle( + fontSize: 11, + color: AppTheme.textMuted, + ), + ), + value: _scriptedBreaks, + onChanged: (v) async { + await _debug.setScriptedBreaks(v); + if (!mounted) return; + setState(() => _scriptedBreaks = v); + }, + ); + } +} + class _ResetSection extends StatelessWidget { final bool resetting; final VoidCallback onReset; diff --git a/rhythm_coach/lib/screens/sound_demo_screen.dart b/rhythm_coach/lib/screens/sound_demo_screen.dart index b2d9b978..5da1209b 100644 --- a/rhythm_coach/lib/screens/sound_demo_screen.dart +++ b/rhythm_coach/lib/screens/sound_demo_screen.dart @@ -58,7 +58,6 @@ class _SoundDemoScreenState extends State { bool _showBackgroundMedia = true; bool _cameraHoldCheck = false; bool _skipSessionButton = false; - bool _scriptedBreaks = false; @override void initState() { @@ -81,7 +80,6 @@ class _SoundDemoScreenState extends State { final showBgMedia = await _debug.getShowBackgroundMedia(); final camCheck = await _debug.getCameraHoldCheck(); final skipSession = await _debug.getSkipSessionButton(); - final scriptedBreaks = await _debug.getScriptedBreaks(); if (!mounted) return; setState(() { _ready = true; @@ -95,7 +93,6 @@ class _SoundDemoScreenState extends State { _showBackgroundMedia = showBgMedia; _cameraHoldCheck = camCheck; _skipSessionButton = skipSession; - _scriptedBreaks = scriptedBreaks; }); }); } @@ -562,29 +559,6 @@ class _SoundDemoScreenState extends State { setState(() => _skipSessionButton = v); }, ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text( - t.soundsDebugScriptedBreaks, - style: const TextStyle( - fontSize: 14, - color: AppTheme.textPrimary, - ), - ), - subtitle: Text( - t.soundsDebugScriptedBreaksSubtitle, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textMuted, - ), - ), - value: _scriptedBreaks, - onChanged: (v) async { - await _debug.setScriptedBreaks(v); - if (!mounted) return; - setState(() => _scriptedBreaks = v); - }, - ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon( diff --git a/rhythm_coach/lib/services/diagnostic_import_service.dart b/rhythm_coach/lib/services/diagnostic_import_service.dart index 1338bb1c..40c87d5a 100644 --- a/rhythm_coach/lib/services/diagnostic_import_service.dart +++ b/rhythm_coach/lib/services/diagnostic_import_service.dart @@ -198,9 +198,9 @@ class DiagnosticImportService { _bool('debug.skip_session_button', j['skipSessionButton']); _bool('pref.show_background_media', j['showBackgroundMedia']); _bool('pref.show_session_remaining_time', j['showSessionRemainingTime']); - // Champ hors-export : les presets debug peuvent piloter le toggle - // « Postures + breaks scénarisés » directement (`debug.scripted_breaks`). - _bool('debug.scripted_breaks', j['scriptedBreaks']); + // Champ hors-export : les presets debug peuvent piloter le réglage + // « Postures imposées et pauses » directement (`pref.scripted_breaks`). + _bool('pref.scripted_breaks', j['scriptedBreaks']); } void _consent(Map j) { @@ -272,7 +272,7 @@ class DiagnosticImportService { 'debug.show_mode_badge', 'debug.camera_hold_check', 'debug.skip_session_button', - 'debug.scripted_breaks', + 'pref.scripted_breaks', 'pref.show_background_media', 'pref.show_session_remaining_time', 'app.adult_consent_accepted', diff --git a/rhythm_coach/test/diagnostic_import_service_test.dart b/rhythm_coach/test/diagnostic_import_service_test.dart index 7531244e..b244c0c4 100644 --- a/rhythm_coach/test/diagnostic_import_service_test.dart +++ b/rhythm_coach/test/diagnostic_import_service_test.dart @@ -68,6 +68,6 @@ void main() { // BadgeTier = {none, bronze, silver, ...} → silver = idx 2. expect(prefs.getInt('badge.tier.throatQueen'), 2); - expect(prefs.getBool('debug.scripted_breaks'), true); + expect(prefs.getBool('pref.scripted_breaks'), true); }); } diff --git a/rhythm_coach/test/scripted_breaks_enabled_test.dart b/rhythm_coach/test/scripted_breaks_enabled_test.dart new file mode 100644 index 00000000..ae7d875b --- /dev/null +++ b/rhythm_coach/test/scripted_breaks_enabled_test.dart @@ -0,0 +1,100 @@ +import 'package:beat_bitch/career/models/phrase_bank.dart'; +import 'package:beat_bitch/career/services/debug_settings_service.dart'; +import 'package:beat_bitch/career/services/generation/career_session_generator.dart'; +import 'package:beat_bitch/models/posture.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Les postures imposées + pauses scénarisées (issue #77) sont annoncées dans +/// les notes de version 0.6.0 comme un comportement du jeu. Elles ont pourtant +/// vécu deux versions derrière un toggle de la section debug de l'écran SONS, +/// off par défaut — donc invisibles pour tout le monde. Ces tests verrouillent +/// l'état « allumé sans toucher à un réglage » : préférence `pref.`, on par +/// défaut, et une génération carrière qui produit réellement une posture et +/// des breaks quand on lui passe ce défaut. + +List _p(List t) => + t.map((s) => PhraseEntry(text: s)).toList(); + +PhraseBank _bank() => PhraseBank( + byMode: { + for (final m in SessionMode.values) + m: { + 'soft': _p(['s']), + 'medium': _p(['m']), + 'hard': _p(['h']), + 'finale': _p(['f']), + }, + }, + congrats: _p(['bravo']), + intros: _p(['intro']), + ); + +void main() { + group('Préférence « postures imposées et pauses »', () { + test('profil vierge → activée par défaut', () async { + SharedPreferences.setMockInitialValues({}); + expect(await DebugSettingsService().getScriptedBreaks(), isTrue); + }); + + test('portée par une clé `pref.`, pas par l\'ancienne clé debug', () async { + // Une valeur restée sous l'ancienne clé debug (off par défaut à + // l'époque) ne doit plus rien éteindre : la préférence a changé de + // famille, l'ancien off n'est pas collant. + SharedPreferences.setMockInitialValues({'debug.scripted_breaks': false}); + expect(await DebugSettingsService().getScriptedBreaks(), isTrue); + + SharedPreferences.setMockInitialValues({'pref.scripted_breaks': false}); + expect(await DebugSettingsService().getScriptedBreaks(), isFalse); + }); + + test('setter écrit bien sous la clé `pref.`', () async { + SharedPreferences.setMockInitialValues({}); + await DebugSettingsService().setScriptedBreaks(false); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('pref.scripted_breaks'), isFalse); + }); + }); + + group('Chaîne complète — le défaut de la préférence allume la génération', + () { + test('posture imposée à l\'intro + breaks sur une séance longue', () async { + SharedPreferences.setMockInitialValues({}); + final enabled = await DebugSettingsService().getScriptedBreaks(); + + // Le pool de tirage inclut toujours `free` : sur un seed donné le + // résultat peut être `free` sans que ce soit un défaut. On balaie donc + // plusieurs seeds et on exige qu'une posture sorte, et que les breaks + // (eux, déterministes sur une séance de 50 min) soient là à chaque fois. + final poses = {}; + for (var seed = 0; seed < 12; seed++) { + final result = CareerSessionGenerator(seed: seed).generate( + level: 14, + bank: _bank(), + durationSeconds: 50 * 60, + unlockedKeys: UnlockKey.values.toSet(), + scriptedBreaks: enabled, + ); + poses.add(result.session.initialPose); + expect(result.session.breaks, isNotEmpty, + reason: 'séance de 50 min, seed $seed : aucun break inséré'); + } + expect(poses.any((p) => p != Posture.free), isTrue, + reason: 'aucune posture imposée sur 12 seeds'); + }); + + test('la préférence coupée éteint tout', () async { + SharedPreferences.setMockInitialValues({'pref.scripted_breaks': false}); + final enabled = await DebugSettingsService().getScriptedBreaks(); + final result = CareerSessionGenerator(seed: 3).generate( + level: 14, + bank: _bank(), + durationSeconds: 50 * 60, + unlockedKeys: UnlockKey.values.toSet(), + scriptedBreaks: enabled, + ); + expect(result.session.initialPose, Posture.free); + expect(result.session.breaks, isEmpty); + }); + }); +} From 63927e91136d3a8ee1d6253a88d60a1721bbc4b6 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:10:12 +0200 Subject: [PATCH 2/9] =?UTF-8?q?test(postures):=20prouver=20l'atteignabilit?= =?UTF-8?q?=C3=A9=20des=20paliers=20pour=20un=20profil=20avanc=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le coach Marc a coûté un correctif en 0.6.1 : son palier de déblocage était franchissable une seule fois, et qui l'avait dépassé avant sa sortie restait verrouillé définitivement. Les 5 milestones `intro_posture_*` (levels 4 à 12) sont exposées au même risque pour un profil déjà au-delà. Ces tests tournent sur le vrai catalogue `assets/career/milestones.json` et établissent que `level` est un plancher et non une fenêtre, que les postures remontent en tête de file (règle overdue), et qu'il faut 3 séances moyenne/longue — 5 courtes — pour les débloquer toutes. Ils caractérisent aussi l'exigence de capacité de `intro_posture_on_back` (`hold.full.streak ≥ 5`), seul palier qui attend une preuve physique. --- ...ture_milestones_advanced_profile_test.dart | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 rhythm_coach/test/posture_milestones_advanced_profile_test.dart diff --git a/rhythm_coach/test/posture_milestones_advanced_profile_test.dart b/rhythm_coach/test/posture_milestones_advanced_profile_test.dart new file mode 100644 index 00000000..0ed1385f --- /dev/null +++ b/rhythm_coach/test/posture_milestones_advanced_profile_test.dart @@ -0,0 +1,180 @@ +import 'package:beat_bitch/career/models/level_milestone.dart'; +import 'package:beat_bitch/career/models/specialization.dart'; +import 'package:beat_bitch/career/services/milestone_loader.dart'; +import 'package:beat_bitch/career/services/milestone_service.dart'; +import 'package:beat_bitch/services/capability_axis.dart'; +import 'package:beat_bitch/services/capability_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Les 5 postures (issue #77) se débloquent chacune par une milestone dédiée +/// (`intro_posture_*`, `level` 4/5/6/9/12) livrée en 0.6.0. Une joueuse qui +/// avait déjà dépassé ces niveaux quand les milestones sont apparues doit +/// quand même les voir arriver — c'est exactement le piège payé en 0.6.1 avec +/// le coach Marc (palier franchi avant la sortie ⇒ verrouillé définitivement). +/// +/// Ces tests tournent sur le **vrai catalogue** `assets/career/milestones.json` +/// et non sur des milestones répliquées : le point à prouver est une propriété +/// des données autant que du tri. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List catalog; + late Set postureIds; + + /// Profil « déjà avancé » : tous les paliers antérieurs acquittés, seules + /// les milestones posture restent à faire. + Set allButPostures() => + catalog.map((m) => m.id).toSet()..removeAll(postureIds); + + MilestoneService serviceWith(Set completed) { + SharedPreferences.setMockInitialValues({}); + final svc = MilestoneService(); + svc.seedForTest(catalog: catalog, completed: completed); + return svc; + } + + List pendingIdsFor( + MilestoneService svc, { + required int playerLevel, + required CapabilityProfile capability, + }) => + svc + .allPendingFor( + humiliationScore: 100, + obedience: 100, + playerLevel: playerLevel, + allocation: SpecializationAllocation.empty(), + capabilityProfile: capability, + ) + .map((m) => m.id) + .toList(); + + /// Nombre de séances pour acquitter les 5 postures depuis un profil avancé, + /// à raison de [bodyCount] milestones body par séance (cf. + /// `SessionLengthChoice.maxBodyMilestones` : 1 en courte, 2 en + /// moyenne/longue). `-1` si le pool s'épuise avant. + Future sessionsToUnlockAll({ + required int bodyCount, + required CapabilityProfile capability, + int playerLevel = 30, + }) async { + final svc = serviceWith(allButPostures()); + var sessions = 0; + while (!postureIds.every(svc.isCompleted)) { + final picked = svc.pendingForList( + count: bodyCount, + humiliationScore: 100, + obedience: 100, + playerLevel: playerLevel, + allocation: SpecializationAllocation.empty(), + capabilityProfile: capability, + ); + if (picked.isEmpty) return -1; + sessions++; + for (final m in picked) { + await svc.markCompleted(m.id, hadFail: false); + } + } + return sessions; + } + + /// Profil de capacités d'une joueuse qui a déjà tenu un hold `full` — la + /// seule exigence de télémétrie parmi les 5 postures. + const holdFullProven = CapabilityProfile({ + CapabilityAxis.holdFullStreak: CapabilityAxisState(best: 6), + }); + + /// Joueuse avancée dont l'axe `hold.full.streak` n'a jamais rien produit. + const noTelemetry = CapabilityProfile({}); + + setUpAll(() async { + catalog = await MilestoneLoader().load(); + postureIds = catalog + .where((m) => m.id.startsWith('intro_posture_')) + .map((m) => m.id) + .toSet(); + expect(postureIds, hasLength(5), + reason: 'le catalogue doit porter les 5 milestones posture'); + }); + + group('Profil déjà avancé — les paliers de posture restent atteignables', () { + test('les 5 postures sont proposées à un profil niveau 30', () { + final svc = serviceWith(allButPostures()); + expect( + pendingIdsFor(svc, playerLevel: 30, capability: holdFullProven), + containsAll(postureIds), + ); + }); + + test('`level` est un plancher, pas une fenêtre', () { + final svc = serviceWith(allButPostures()); + // Si `level` était une fenêtre, un niveau très au-dessus du palier + // ferait disparaître les candidates. On vérifie sur toute la plage, + // du palier le plus haut (12) à un niveau absurdement élevé. + for (final level in [12, 20, 30, 99, 999]) { + expect( + pendingIdsFor(svc, playerLevel: level, capability: holdFullProven), + containsAll(postureIds), + reason: 'postures absentes du pool au niveau $level', + ); + } + }); + + test('les postures passent en tête de file (règle overdue)', () { + final svc = serviceWith(allButPostures()); + final pending = + pendingIdsFor(svc, playerLevel: 30, capability: holdFullProven); + // Le profil n'a plus que les postures à faire : le tri ne doit pas les + // reléguer derrière quoi que ce soit. + expect(pending.take(5).toSet(), postureIds); + expect(pending.first, 'intro_posture_sitting', + reason: 'la plus en retard (level 4) doit sortir en premier'); + }); + + test('3 séances moyenne/longue suffisent à débloquer les 5', () async { + expect( + await sessionsToUnlockAll(bodyCount: 2, capability: holdFullProven), + 3, + ); + }); + + test('5 séances courtes suffisent à débloquer les 5', () async { + expect( + await sessionsToUnlockAll(bodyCount: 1, capability: holdFullProven), + 5, + ); + }); + }); + + group('Exigence de capacité — `intro_posture_on_back`', () { + test('sans donnée sur hold.full.streak, seules 4 postures sont candidates', + () { + // Gating métier assumé (`requiresCapability: hold.full.streak ≥ 5`) et + // non un verrou définitif : l'axe se remplit dès qu'un hold `full` est + // tenu en séance (`CapabilityTracker`), y compris via un défi. Test de + // caractérisation — il documente que la 5ᵉ posture attend une preuve + // physique, pas un niveau. + final svc = serviceWith(allButPostures()); + final pending = + pendingIdsFor(svc, playerLevel: 30, capability: noTelemetry); + expect(pending, + containsAll(postureIds.difference({'intro_posture_on_back'}))); + expect(pending, isNot(contains('intro_posture_on_back'))); + }); + + test('elle devient candidate dès que l\'axe atteint le seuil', () { + final svc = serviceWith(allButPostures()); + const justUnder = CapabilityProfile({ + CapabilityAxis.holdFullStreak: CapabilityAxisState(best: 4), + }); + const atThreshold = CapabilityProfile({ + CapabilityAxis.holdFullStreak: CapabilityAxisState(best: 5), + }); + expect(pendingIdsFor(svc, playerLevel: 30, capability: justUnder), + isNot(contains('intro_posture_on_back'))); + expect(pendingIdsFor(svc, playerLevel: 30, capability: atThreshold), + contains('intro_posture_on_back')); + }); + }); +} From 15f60131a9e89f28380808d0ec56e6cc0f6e1dee Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:15:21 +0200 Subject: [PATCH 3/9] =?UTF-8?q?fix(postures):=20fermer=20le=20gel=20de=20s?= =?UTF-8?q?=C3=A9ance=20sur=20les=20chemins=20qui=20traversent=20une=20pau?= =?UTF-8?q?se?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La sortie d'une pause scénarisée qui impose une nouvelle posture arme un gate de mise en place : `_checkSteps` ne consomme plus de step et `_onTick` gèle l'horloge jusqu'à la validation. Trois chemins traversaient cette fenêtre sans jamais passer par `_exitBreak`, donc sans lever le gate — il survivait au saut de timeline et le gel délibéré devenait définitif (ticker actif, plus rien qui avance, jusqu'au timeout de 90 s) : - « je peux pas » pendant la pause ou pendant l'attente de mise en place — le bouton n'est masqué par aucun des deux états ; - « Utilise-moi » et le retry milestone, via `requestUpgrade` (qui remettait déjà l'état de pause à zéro, mais pas le gate) ; - la régénération post-défi, par symétrie. Le bouton reste actif dans les deux états : c'est la seule sortie visible en séance, et une pause de 60 à 120 s suivie d'une attente n'est pas un moment où la priver serait défendable. Le flow d'échec annule la pause au lieu de la subir — sans appliquer sa posture, dont l'annonce ne sera pas prononcée. Corrige aussi le retour d'arrière-plan pendant une pause : `resume()` relançait l'audio d'effort coupé par l'entrée dans la pause. Chemin atteignable par tout le monde, une notification suffit. Les 4 tests runtime ajoutés sont rouges sans ces correctifs. --- .../lib/controllers/session_controller.dart | 10 +- .../session_controller_career_hooks.dart | 8 +- .../session_controller_fail_flow.dart | 14 + .../test/session_break_fail_gate_test.dart | 379 ++++++++++++++++++ 4 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 rhythm_coach/test/session_break_fail_gate_test.dart diff --git a/rhythm_coach/lib/controllers/session_controller.dart b/rhythm_coach/lib/controllers/session_controller.dart index 023bcdbf..d5445829 100644 --- a/rhythm_coach/lib/controllers/session_controller.dart +++ b/rhythm_coach/lib/controllers/session_controller.dart @@ -1180,7 +1180,15 @@ class SessionController extends ChangeNotifier { _state = SessionState.running; _startTicker(); _startRandomComments(); - await _beep.resume(); + // Pendant une pause scénarisée ou l'attente de mise en place (issue #77), + // le loop d'effort a été coupé et c'est le step posé après le trou qui le + // reconfigure : le reprendre ici ferait repartir l'effort en pleine pause. + // Chemin atteignable par tout le monde — `didChangeAppLifecycleState` + // appelle `pause()`/`resume()` sur une notification ou un écran verrouillé, + // sans passer par les contrôles de séance de debug. Même paire de gardes + // que le gel d'accrual dans `_onTick`. L'ambiance, elle, n'est pas coupée + // par le break : elle reprend inconditionnellement. + if (!_breakActive && !_awaitingReady) await _beep.resume(); await _ambience.resume(); notifyListeners(); } diff --git a/rhythm_coach/lib/controllers/session_controller_career_hooks.dart b/rhythm_coach/lib/controllers/session_controller_career_hooks.dart index 63e9a18f..035dbd8d 100644 --- a/rhythm_coach/lib/controllers/session_controller_career_hooks.dart +++ b/rhythm_coach/lib/controllers/session_controller_career_hooks.dart @@ -151,6 +151,10 @@ extension CareerHooksOrchestrator on SessionController { _breakActive = false; _activeBreak = null; _nextBreakIndex = 0; + // Le gate de posture posé à la sortie de ce break tombe avec lui : sans + // ça il survit à la régénération et fige la nouvelle suite (`_checkSteps` + // ne consomme plus rien, `_onTick` gèle l'horloge). No-op hors gate. + confirmPostureReady(); // Reset du flag chime : la régen apporte son propre step final + // apothéose. Si l'ancienne session avait déjà tiré son chime (cas // rare où Supplier est cliqué pile entre final et fin), on doit @@ -205,10 +209,12 @@ extension CareerHooksOrchestrator on SessionController { // suite régénérée serait plus courte que prévu. _ttsDeferredTicks = 0; // Reset de l'état break (issue #77), même raison que `requestUpgrade` : - // la suite régénérée n'a pas de break, on évite un `_breakActive` coincé. + // la suite régénérée n'a pas de break, on évite un `_breakActive` coincé + // et un gate de posture qui figerait la nouvelle suite. _breakActive = false; _activeBreak = null; _nextBreakIndex = 0; + confirmPostureReady(); // Reset du flag chime : la régen apporte son propre step final + apothéose. _finalChimePlayed = false; _finaleChimeStarted = false; diff --git a/rhythm_coach/lib/controllers/session_controller_fail_flow.dart b/rhythm_coach/lib/controllers/session_controller_fail_flow.dart index 1c546f50..84b742c3 100644 --- a/rhythm_coach/lib/controllers/session_controller_fail_flow.dart +++ b/rhythm_coach/lib/controllers/session_controller_fail_flow.dart @@ -52,6 +52,20 @@ extension FailFlowOrchestrator on SessionController { return; } + // Break scénarisé / gate posture (issue #77) : tout ce qui suit rebat la + // timeline (saut de section, ou régénération de retry) sans jamais passer + // par `_exitBreak`. Un gate laissé armé y survit intact, et il fige la + // séance pour de bon : `_checkSteps()` retourne tôt, `_onTick` décrémente + // `_timelineOffset` — ticker actif, plus rien qui avance jusqu'au timeout + // de 90 s. En amont du retry milestone pour couvrir aussi ce chemin. + // + // La pose du break n'est pas appliquée : sa phrase de changement ne sera + // pas prononcée, imposer une position sans l'ordonner serait un ordre + // muet. Le break est annulé, sa mise en scène avec. + _breakActive = false; + _activeBreak = null; + confirmPostureReady(); + // Retry milestone : si on rate dans la fenêtre pédagogique, on tente // d'abord de proposer une nouvelle tentative via le callback (qui // regénère + appelle requestUpgrade). Si le callback prend la main, diff --git a/rhythm_coach/test/session_break_fail_gate_test.dart b/rhythm_coach/test/session_break_fail_gate_test.dart new file mode 100644 index 00000000..7c699794 --- /dev/null +++ b/rhythm_coach/test/session_break_fail_gate_test.dart @@ -0,0 +1,379 @@ +import 'dart:async'; + +import 'package:beat_bitch/controllers/session_controller.dart'; +import 'package:beat_bitch/models/posture.dart'; +import 'package:beat_bitch/models/punishment.dart'; +import 'package:beat_bitch/models/session.dart'; +import 'package:beat_bitch/models/session_step.dart'; +import 'package:beat_bitch/services/ambience_engine.dart'; +import 'package:beat_bitch/services/beep_engine.dart'; +import 'package:beat_bitch/services/punishment_loader.dart'; +import 'package:beat_bitch/services/random_comments_loader.dart'; +import 'package:beat_bitch/services/tts_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Runtime de la machine de break scénarisé (issue #77), sur les chemins qui +/// rebattent la timeline **sans** passer par `_exitBreak`. +/// +/// La sortie d'un break qui impose une nouvelle posture arme un gate +/// (`_awaitingReady`) : la séance se fige jusqu'au « JE SUIS EN PLACE » ou au +/// timeout de sécurité de 90 s. Ce gel est délibéré — tant qu'il est armé, +/// `_checkSteps()` retourne tôt et `_onTick` décrémente `_timelineOffset`, +/// donc plus aucun step n'est consommé et l'horloge logique n'avance plus. +/// +/// Trois chemins peuvent traverser cette fenêtre : le bouton « je peux pas » +/// (le seul contrôle de séance visible en production, jamais masqué par le +/// break ni par le gate), le bouton « Utilise-moi » et la régénération de +/// retry milestone (toutes deux via `requestUpgrade`). Aucun ne passe par +/// `_exitBreak` : sans nettoyage explicite, le gate survit intact au saut de +/// timeline et le gel délibéré devient un gel définitif — ticker actif, plus +/// rien qui avance, jusqu'au timeout. +/// +/// Le dernier test couvre l'autre versant : le retour d'arrière-plan +/// (notification, appel, écran verrouillé) relançait le loop d'effort en +/// pleine pause scénarisée. +/// +/// Tests en temps réel : le `Stopwatch` du controller n'est pas simulé par +/// `flutter_test`, `pump()` n'avance pas son horloge. Les fenêtres sont donc +/// tenues courtes (break de 2 s) — c'est la durée du flow fail (phrase + +/// respiration + punition) qui domine le temps d'exécution. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const ttsChannel = MethodChannel('flutter_tts'); + const codec = StandardMethodCodec(); + + Future pushFromEngine(String method, [Object? args]) { + return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + 'flutter_tts', + codec.encodeMethodCall(MethodCall(method, args)), + (_) {}, + ); + } + + /// Moteur TTS factice qui complète toujours : sans lui, l'anti-coupure de + /// `_checkSteps` différerait les steps et brouillerait la mesure. + void installFakeTtsEngine() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ttsChannel, (call) async { + switch (call.method) { + case 'speak': + unawaited(pushFromEngine('speak.onStart', true)); + Timer(const Duration(milliseconds: 40), + () => pushFromEngine('speak.onComplete', true)); + return 1; + case 'stop': + unawaited(pushFromEngine('speak.onCancel', true)); + return 1; + case 'getVoices': + return []; + default: + return 1; + } + }); + } + + const audioChannels = [ + MethodChannel('xyz.luan/audioplayers.global'), + MethodChannel('xyz.luan/audioplayers'), + ]; + const wakelockChannels = [ + 'dev.flutter.pigeon.wakelock_plus_platform_interface.WakelockPlusApi.toggle', + 'dev.flutter.pigeon.wakelock_plus_platform_interface.WakelockPlusApi' + '.isEnabled', + ]; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + SharedPreferences.setMockInitialValues({}); + installFakeTtsEngine(); + for (final c in audioChannels) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(c, (call) async => null); + } + for (final name in wakelockChannels) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + name, + (_) async => + const StandardMessageCodec().encodeMessage([null]), + ); + } + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ttsChannel, null); + for (final c in audioChannels) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(c, null); + } + }); + + const punishments = PunishmentBundle( + failPhrases: ['tu craques'], + punishments: [ + Punishment( + id: 'p', + name: 'p', + durationSeconds: 1, + steps: [SessionStep(time: 0, mode: SessionMode.breath, duration: 1)], + ), + ], + ); + + // Break [1,3) imposant `kneeling` à la reprise, et le step d'effort que le + // générateur pose toujours juste après le trou. Profil de stamina plein : + // la respiration du flow fail est alors tirée dans sa fourchette courte. + SessionController buildController({BeepEngine? beep}) => SessionController( + staminaProfile: List.filled(120, 100), + session: const Session( + id: 'break-gate', + name: 'break-gate', + description: '', + durationSeconds: 60, + defaultMode: SessionMode.rhythm, + initialPose: Posture.free, + breaks: [ + ScriptedBreak( + time: 1, + durationSeconds: 2, + newPose: Posture.kneeling, + ), + ], + steps: [ + SessionStep( + time: 0, text: 'debut', mode: SessionMode.rhythm, bpm: 80), + SessionStep( + time: 3, + text: 'apres-break', + mode: SessionMode.rhythm, + bpm: 90), + SessionStep( + time: 30, text: 'suite', mode: SessionMode.rhythm, bpm: 100), + ], + ), + tts: TtsService(), + beep: beep ?? _SilentBeepEngine(), + ambience: _SilentAmbienceEngine(), + punishmentBundle: punishments, + randomComments: const RandomCommentsBundle( + comments: [], + minIntervalSeconds: 999, + maxIntervalSeconds: 999, + scriptedCooldownSeconds: 4, + ), + ); + + /// Amène la séance jusqu'au gate armé à la sortie du break. + Future reachPostureGate(SessionController ctrl) async { + await ctrl.start(); + await Future.delayed(const Duration(milliseconds: 3600)); + expect(ctrl.breakActive, isFalse, reason: 'break fini à t≈3,6 s'); + expect(ctrl.awaitingPostureReady, isTrue, + reason: 'newPose=kneeling → le gate s\'arme à la sortie du break'); + } + + test( + 'un « je peux pas » pendant l\'attente de mise en place ne gèle pas ' + 'la séance', () async { + final ctrl = buildController(); + await reachPostureGate(ctrl); + + // Le bouton n'est masqué ni par la bannière de pause ni par l'attente de + // mise en place : ce sont des blocs indépendants empilés au-dessus de lui. + expect(ctrl.canTriggerFail, isTrue); + + await ctrl.triggerFail(); + + expect(ctrl.isRunning, isTrue, reason: 'le flow fail est allé à son terme'); + expect(ctrl.awaitingPostureReady, isFalse, + reason: 'le flow fail rebat la timeline sans passer par `_exitBreak` : ' + 'il doit lever le gate lui-même, sinon `_checkSteps` ne consomme ' + 'plus jamais de step'); + + // L'horloge logique doit repartir. Sans le nettoyage, `_onTick` décrémente + // `_timelineOffset` à chaque tick et `elapsedSeconds` reste figé à la + // valeur posée par le saut de section. + final resumedAt = ctrl.elapsedSeconds; + await Future.delayed(const Duration(milliseconds: 1600)); + expect(ctrl.elapsedSeconds, greaterThan(resumedAt), + reason: 'la séance progresse à nouveau après le flow fail'); + + await ctrl.stop(); + }, timeout: const Timeout(Duration(seconds: 40))); + + test('un « je peux pas » pendant une pause scénarisée ne gèle pas la séance', + () async { + final ctrl = buildController(); + await ctrl.start(); + + // t≈1,6 s : dans la fenêtre du break, avant sa fin naturelle à t=3. + await Future.delayed(const Duration(milliseconds: 1600)); + expect(ctrl.breakActive, isTrue); + expect(ctrl.awaitingPostureReady, isFalse, + reason: 'le gate ne s\'arme qu\'à la SORTIE du break'); + expect(ctrl.canTriggerFail, isTrue); + + await ctrl.triggerFail(); + + expect(ctrl.isRunning, isTrue); + expect(ctrl.breakActive, isFalse, + reason: 'le flow fail a sauté la timeline hors du trou d\'effort : le ' + 'break ne peut pas rester actif derrière lui'); + + // Sans nettoyage, `_exitBreak` rattrapait son retard au tick suivant et + // armait un gate pour un step déjà consommé par le saut de section. + await Future.delayed(const Duration(milliseconds: 600)); + expect(ctrl.awaitingPostureReady, isFalse, + reason: 'aucun gate ne doit s\'armer après coup pour un break que le ' + 'flow fail a déjà annulé'); + + final resumedAt = ctrl.elapsedSeconds; + await Future.delayed(const Duration(milliseconds: 1600)); + expect(ctrl.elapsedSeconds, greaterThan(resumedAt), + reason: 'la séance progresse à nouveau après le flow fail'); + + await ctrl.stop(); + }, timeout: const Timeout(Duration(seconds: 40))); + + test( + 'une régénération mi-séance pendant l\'attente de mise en place ne gèle ' + 'pas la séance', () async { + final ctrl = buildController(); + await reachPostureGate(ctrl); + + // Chemin du bouton « Utilise-moi » et du retry milestone : la suite est + // remplacée, l'état de break est remis à zéro — le gate doit tomber avec. + await ctrl.requestUpgrade( + insistentBeg: const SessionStep( + time: 0, + text: 'supplie', + mode: SessionMode.beg, + duration: 2, + ), + upcomingSession: const Session( + id: 'upgrade', + name: 'upgrade', + description: '', + durationSeconds: 30, + defaultMode: SessionMode.rhythm, + steps: [ + SessionStep( + time: 0, text: 'suite-regen', mode: SessionMode.rhythm, bpm: 95), + ], + ), + ); + + expect(ctrl.awaitingPostureReady, isFalse, + reason: 'la régénération remet l\'état de break à zéro : le gate posé ' + 'par ce break doit tomber avec lui'); + + final resumedAt = ctrl.elapsedSeconds; + await Future.delayed(const Duration(milliseconds: 1600)); + expect(ctrl.elapsedSeconds, greaterThan(resumedAt), + reason: 'la séance progresse à nouveau après la régénération'); + + await ctrl.stop(); + }, timeout: const Timeout(Duration(seconds: 40))); + + test( + 'le retour d\'arrière-plan pendant une pause scénarisée ne relance pas ' + 'l\'audio d\'effort', () async { + final beep = _TrackingBeepEngine(); + final ctrl = buildController(beep: beep); + await ctrl.start(); + + // t≈1,6 s : `_enterBreak` a déjà coupé le loop d'effort. + await Future.delayed(const Duration(milliseconds: 1600)); + expect(ctrl.breakActive, isTrue); + expect(beep.pauseCalls, greaterThanOrEqualTo(1)); + final resumesBefore = beep.resumeCalls; + + // Exactement ce que fait `didChangeAppLifecycleState` sur `paused` / + // `inactive` / `hidden` — ce handler n'est gaté par aucun flag de debug, + // une notification suffit à le déclencher. + await ctrl.pause(); + expect(ctrl.isPaused, isTrue); + await ctrl.resume(); + + expect(ctrl.breakActive, isTrue, reason: 'toujours dans le break (t<3)'); + expect(beep.resumeCalls, resumesBefore, + reason: 'le loop d\'effort a été coupé par l\'entrée dans le break : ' + 'le reprendre ferait repartir l\'effort en pleine pause'); + + // Et il doit bien repartir ensuite : c'est le step d'effort posé après le + // trou qui le reconfigure, une fois la mise en place validée. + await Future.delayed(const Duration(milliseconds: 1600)); + expect(ctrl.awaitingPostureReady, isTrue); + ctrl.confirmPostureReady(); + await Future.delayed(const Duration(milliseconds: 600)); + expect(beep.appliedSteps.map((s) => s.text), contains('apres-break'), + reason: 'la séance n\'est pas restée muette pour autant'); + + await ctrl.stop(); + }, timeout: const Timeout(Duration(seconds: 40))); +} + +class _SilentBeepEngine extends BeepEngine { + @override + Future init() async {} + @override + Future applyStep(SessionStep step, SessionMode sessionMode) async {} + @override + Future pause() async {} + @override + Future resume() async {} + @override + Future stop() async {} + @override + Future dispose() async {} +} + +class _TrackingBeepEngine extends BeepEngine { + int pauseCalls = 0; + int resumeCalls = 0; + final List appliedSteps = []; + + @override + Future init() async {} + @override + Future applyStep(SessionStep step, SessionMode sessionMode) async { + appliedSteps.add(step); + } + + @override + Future pause() async { + pauseCalls++; + } + + @override + Future resume() async { + resumeCalls++; + } + + @override + Future stop() async {} + @override + Future dispose() async {} +} + +class _SilentAmbienceEngine extends AmbienceEngine { + @override + Future play(String? assetPath) async {} + @override + Future playForMode(SessionMode mode) async {} + @override + Future pause() async {} + @override + Future resume() async {} + @override + Future stop() async {} + @override + Future dispose() async {} +} From a7454de188fe3d10dc530b49875a4827d3e95df6 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:23:11 +0200 Subject: [PATCH 4/9] =?UTF-8?q?fix(postures):=20d=C3=A9river=20le=20gel=20?= =?UTF-8?q?de=20posture=20de=20la=20sc=C3=A8ne=20qui=20l'a=20ordonn=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le gel de mise en place était un drapeau armé à la sortie d'une pause et levé par un seul chemin nominal. Quatre chemins distincts l'ont laissé orphelin derrière eux, et chaque correctif ciblé n'a jamais rien dit du suivant : on cesse de fermer des chemins. `PostureGate` porte désormais ce qui *justifie* le gel — identité de la timeline, tête de lecture, décalage d'horloge, numéro de flow d'échec — et `awaitingPostureReady` rejoue cette justification à chaque lecture. Les trois premières composantes sont les seules variables qui portent la progression d'une séance : rebattre la timeline sans en toucher aucune est impossible. Un chemin qui la rebat n'a donc rien à lever, aujourd'hui comme demain — les trois levées explicites de la passe 2 sont retirées. Une garde centrale enterre à chaque battement une ancre dont la justification est tombée (timeout de sécurité compris), pour qu'un gel enterré ne ressuscite pas si sa justification redevenait vraie. Ferme au passage le quatrième chemin, jamais corrigé : un défi armé au même tick qu'un ordre de posture n'empile plus son bandeau sur lui, et sa clôture ne laisse plus la séance gelée. --- .../lib/controllers/posture_gate.dart | 67 ++++ .../lib/controllers/session_controller.dart | 77 +++- .../session_controller_career_hooks.dart | 8 +- .../session_controller_fail_flow.dart | 15 +- .../test/posture_gate_invariant_test.dart | 370 ++++++++++++++++++ .../test/session_break_fail_gate_test.dart | 44 ++- 6 files changed, 528 insertions(+), 53 deletions(-) create mode 100644 rhythm_coach/lib/controllers/posture_gate.dart create mode 100644 rhythm_coach/test/posture_gate_invariant_test.dart diff --git a/rhythm_coach/lib/controllers/posture_gate.dart b/rhythm_coach/lib/controllers/posture_gate.dart new file mode 100644 index 00000000..78041b82 --- /dev/null +++ b/rhythm_coach/lib/controllers/posture_gate.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; + +import '../models/session.dart'; + +/// Ce qui **justifie** le gel de posture (issue #77), et rien d'autre. +/// +/// Le gel n'est pas un drapeau qu'on arme d'un côté et qu'on pense à baisser +/// de l'autre : c'est une conséquence de la scène qui l'a ordonné, et il +/// cesse d'exister dès que cette scène n'est plus la scène courante. +/// [stillHolds] rejoue la justification à chaque lecture — un chemin qui +/// rebat la timeline n'a rien à lever, le gel tombe parce que ce qui le +/// tenait debout a disparu. C'est la propriété qui compte : elle vaut pour +/// les chemins qui existent aujourd'hui **comme pour ceux qu'on écrira +/// demain**, sans qu'ils aient à connaître ce gel. +/// +/// Les trois premières composantes sont **les seules variables qui portent la +/// progression d'une séance** (`elapsed = _stopwatch + _timelineOffset`, step +/// courant = `_session.steps[_nextStepIndex]`) : rebattre la timeline sans en +/// toucher aucune est impossible. La quatrième couvre le flow d'échec, qui +/// n'a pas besoin de rebattre la timeline pour annuler la mise en scène de la +/// pause (cf. `triggerFail`). +@immutable +class PostureGate { + const PostureGate({ + required this.session, + required this.nextStepIndex, + required this.timelineOffset, + required this.failGeneration, + }); + + /// Identité — pas égalité — de la timeline sur laquelle l'ordre a été + /// donné. Toute régénération en vol construit une nouvelle `Session`. + final Session session; + + /// Prochain step à consommer au moment de l'ordre. Le gel interdit + /// justement toute consommation : s'il a bougé, c'est que quelqu'un + /// d'autre a déplacé la tête de lecture. + final int nextStepIndex; + + /// Décalage d'horloge au moment de l'ordre. Un gel ne fait que le + /// **décrémenter** (un tick par battement, comme le report TTS) : toute + /// valeur supérieure signe un saut en avant dans la timeline. + final Duration timelineOffset; + + /// Numéro du dernier flow d'échec démarré (monotone croissant, jamais + /// remis à zéro). + final int failGeneration; + + /// Rejoue la justification contre la situation courante. + /// + /// [otherSceneActive] : une autre mise en scène tient l'écran (défi en + /// cours, respiration post-défi). L'ordre de posture n'est alors plus la + /// scène courante — deux consignes concurrentes ne s'empilent pas. + bool stillHolds({ + required Session session, + required int nextStepIndex, + required Duration timelineOffset, + required int failGeneration, + required bool otherSceneActive, + }) { + if (!identical(session, this.session)) return false; + if (nextStepIndex != this.nextStepIndex) return false; + if (timelineOffset > this.timelineOffset) return false; + if (failGeneration != this.failGeneration) return false; + return !otherSceneActive; + } +} diff --git a/rhythm_coach/lib/controllers/session_controller.dart b/rhythm_coach/lib/controllers/session_controller.dart index d5445829..0dfabd0b 100644 --- a/rhythm_coach/lib/controllers/session_controller.dart +++ b/rhythm_coach/lib/controllers/session_controller.dart @@ -32,6 +32,7 @@ import '../services/saliva_engine.dart'; import '../services/stamina_engine.dart'; import '../services/stats_service.dart'; import '../services/tts_service.dart'; +import 'posture_gate.dart'; part 'session_controller_challenge.dart'; part 'session_controller_fail_flow.dart'; @@ -452,14 +453,29 @@ class SessionController extends ChangeNotifier { /// afficher la pose à prendre avant validation. `free` si aucune. Posture get initialPose => _session.initialPose; - /// Gate de validation posture (issue #77) : `true` quand la séance est gelée - /// en attente que la joueuse confirme être en position (step `awaitReady` - /// d'une milestone posture, ou sortie de break qui change de pose). Gel - /// identique au défi (`elapsedSeconds` figé via `_timelineOffset` dans - /// `_onTick`, stopwatch/ticker intacts). Levé par [confirmPostureReady] ou le - /// timeout de sécurité. - bool _awaitingReady = false; - bool get awaitingPostureReady => _awaitingReady; + /// Gate de validation posture (issue #77) : ce qui justifie le gel courant, + /// `null` s'il n'y en a pas. Armé par un step `awaitReady` (milestone + /// posture) ou une sortie de break qui change de pose. Gel identique au défi + /// (`elapsedSeconds` figé via `_timelineOffset` dans `_onTick`, + /// stopwatch/ticker intacts). + /// + /// Ce n'est **pas** un drapeau à baisser : [awaitingPostureReady] rejoue la + /// justification à chaque lecture (cf. [PostureGate]), donc un chemin qui + /// rebat la timeline n'a rien à lever — le gel tombe parce que la scène qui + /// l'a ordonné n'est plus la scène courante. + PostureGate? _postureGate; + + /// `true` tant que la séance est gelée en attente que la joueuse confirme + /// être en position. Dérivé : pas d'état à maintenir, pas de levée à oublier. + bool get awaitingPostureReady => + _postureGate?.stillHolds( + session: _session, + nextStepIndex: _nextStepIndex, + timelineOffset: _timelineOffset, + failGeneration: _failGen, + otherSceneActive: isChallengeActive || _inPostChallengeBreath, + ) ?? + false; Timer? _readyTimeout; /// Garde-fou anti-soft-lock : si la joueuse ne valide pas la posture, on @@ -1006,7 +1022,7 @@ class SessionController extends ChangeNotifier { _nextBreakIndex = 0; _breakOrderLastAtSec = 0; _currentPose = _session.initialPose; - _awaitingReady = false; + _postureGate = null; _readyTimeout?.cancel(); _readyTimeout = null; _capabilityTracker?.onSessionStart(); @@ -1188,7 +1204,7 @@ class SessionController extends ChangeNotifier { // sans passer par les contrôles de séance de debug. Même paire de gardes // que le gel d'accrual dans `_onTick`. L'ambiance, elle, n'est pas coupée // par le break : elle reprend inconditionnellement. - if (!_breakActive && !_awaitingReady) await _beep.resume(); + if (!_breakActive && !awaitingPostureReady) await _beep.resume(); await _ambience.resume(); notifyListeners(); } @@ -1279,6 +1295,10 @@ class SessionController extends ChangeNotifier { } void _onTick() { + // Gel de posture : enterre une ancre dont la justification est tombée + // (cf. `_burySpentPostureGate`). Rien à décider ici — le gel lui-même est + // dérivé, cette passe ne fait que libérer ce qui traîne derrière lui. + _burySpentPostureGate(); // `_updateChallengePhase` AVANT `_checkSteps` : si on franchit la fin // nominale du step défi à ce tick, la phase doit basculer en `atSeuil` // avant que `_checkSteps` ne consomme le step suivant naturel — sinon @@ -1298,7 +1318,7 @@ class SessionController extends ChangeNotifier { // Gel de l'effort pendant un break : pas de crédit hold/saliva/stamina/ // mini-punition ni de marqueurs de progression (la pause est de la récup // mise en scène, pas de l'effort). L'horloge `elapsed`, elle, continue. - if (!_breakActive && !_awaitingReady) { + if (!_breakActive && !awaitingPostureReady) { _accrueHoldSecond(); _checkProgressMarkers(); } @@ -1313,7 +1333,7 @@ class SessionController extends ChangeNotifier { // brut, jamais freezé) pour rester indépendantes de ce gel — sans // cela, `_inPostChallengeBreath` ne se terminerait jamais (son seuil // ne serait jamais franchi par un `elapsedSeconds` gelé). - if (isChallengeActive || _inPostChallengeBreath || _awaitingReady) { + if (isChallengeActive || _inPostChallengeBreath || awaitingPostureReady) { _timelineOffset -= _tickInterval; } if (elapsedSeconds >= session.durationSeconds) { @@ -1461,7 +1481,7 @@ class SessionController extends ChangeNotifier { // Gate posture (issue #77) : tant que la joueuse n'a pas validé sa // position, on ne consomme aucun step — l'horloge est gelée dans `_onTick` // et le step d'effort qui suit attend la confirmation. - if (_awaitingReady) return; + if (awaitingPostureReady) return; final s = elapsedSeconds; var modeChanged = false; while (_nextStepIndex < session.steps.length && @@ -1628,14 +1648,19 @@ class SessionController extends ChangeNotifier { /// L'annonce de la posture a déjà été prononcée par le step qui déclenche la /// gate ; ici on ne fait qu'attendre. void _enterAwaitReady() { - if (_awaitingReady) return; + if (awaitingPostureReady) return; // Fixe `_currentPose` sur la posture enseignée par la milestone qui arme // ce gate, AVANT de notifier — sinon l'indicateur du gate affiche encore // l'ancienne pose. `_updateMilestonePose` est déjà appelé dans `_onTick`, // mais pas quand le gate s'arme depuis `start()` (`_checkSteps` du premier // step, cas d'une milestone posture qui remplace l'intro). _updateMilestonePose(); - _awaitingReady = true; + _postureGate = PostureGate( + session: _session, + nextStepIndex: _nextStepIndex, + timelineOffset: _timelineOffset, + failGeneration: _failGen, + ); _readyTimeout?.cancel(); _readyTimeout = Timer(_readyTimeoutDuration, confirmPostureReady); notifyListeners(); @@ -1645,13 +1670,31 @@ class SessionController extends ChangeNotifier { /// reprend au tick suivant (l'horloge ré-avance, le step d'effort suivant /// s'applique). Aussi appelée par le timeout de sécurité. No-op hors gate. void confirmPostureReady() { - if (!_awaitingReady) return; - _awaitingReady = false; + if (_postureGate == null) return; + _postureGate = null; _readyTimeout?.cancel(); _readyTimeout = null; notifyListeners(); } + /// Garde centrale du gel de posture, jouée à chaque battement : une ancre + /// dont la justification est tombée est enterrée ici, timeout de sécurité + /// compris. [awaitingPostureReady] vaut déjà `false` à cet instant — la + /// purge sert à ce qu'un gel enterré ne **ressuscite** pas si sa + /// justification redevenait vraie (une scène concurrente qui se termine), + /// et à ne pas laisser un `Timer` de 90 s courir derrière une séance qui a + /// repris. + void _burySpentPostureGate() { + if (_postureGate == null || awaitingPostureReady) return; + if (kDebugMode) { + debugPrint('[SessionController] gel de posture levé : la scène qui ' + "l'a ordonné n'est plus la scène courante"); + } + _postureGate = null; + _readyTimeout?.cancel(); + _readyTimeout = null; + } + /// Wrapper autour de `_tts.speak` qui marque le dernier instant scripté, /// pour permettre au scheduler de commentaires aléatoires de respecter /// son cooldown. Coupe explicitement un éventuel random en cours avant diff --git a/rhythm_coach/lib/controllers/session_controller_career_hooks.dart b/rhythm_coach/lib/controllers/session_controller_career_hooks.dart index 035dbd8d..63e9a18f 100644 --- a/rhythm_coach/lib/controllers/session_controller_career_hooks.dart +++ b/rhythm_coach/lib/controllers/session_controller_career_hooks.dart @@ -151,10 +151,6 @@ extension CareerHooksOrchestrator on SessionController { _breakActive = false; _activeBreak = null; _nextBreakIndex = 0; - // Le gate de posture posé à la sortie de ce break tombe avec lui : sans - // ça il survit à la régénération et fige la nouvelle suite (`_checkSteps` - // ne consomme plus rien, `_onTick` gèle l'horloge). No-op hors gate. - confirmPostureReady(); // Reset du flag chime : la régen apporte son propre step final + // apothéose. Si l'ancienne session avait déjà tiré son chime (cas // rare où Supplier est cliqué pile entre final et fin), on doit @@ -209,12 +205,10 @@ extension CareerHooksOrchestrator on SessionController { // suite régénérée serait plus courte que prévu. _ttsDeferredTicks = 0; // Reset de l'état break (issue #77), même raison que `requestUpgrade` : - // la suite régénérée n'a pas de break, on évite un `_breakActive` coincé - // et un gate de posture qui figerait la nouvelle suite. + // la suite régénérée n'a pas de break, on évite un `_breakActive` coincé. _breakActive = false; _activeBreak = null; _nextBreakIndex = 0; - confirmPostureReady(); // Reset du flag chime : la régen apporte son propre step final + apothéose. _finalChimePlayed = false; _finaleChimeStarted = false; diff --git a/rhythm_coach/lib/controllers/session_controller_fail_flow.dart b/rhythm_coach/lib/controllers/session_controller_fail_flow.dart index 84b742c3..97e6996d 100644 --- a/rhythm_coach/lib/controllers/session_controller_fail_flow.dart +++ b/rhythm_coach/lib/controllers/session_controller_fail_flow.dart @@ -52,19 +52,18 @@ extension FailFlowOrchestrator on SessionController { return; } - // Break scénarisé / gate posture (issue #77) : tout ce qui suit rebat la - // timeline (saut de section, ou régénération de retry) sans jamais passer - // par `_exitBreak`. Un gate laissé armé y survit intact, et il fige la - // séance pour de bon : `_checkSteps()` retourne tôt, `_onTick` décrémente - // `_timelineOffset` — ticker actif, plus rien qui avance jusqu'au timeout - // de 90 s. En amont du retry milestone pour couvrir aussi ce chemin. + // Break scénarisé (issue #77) : tout ce qui suit rebat la timeline (saut + // de section, ou régénération de retry) sans jamais passer par + // `_exitBreak`, qui resterait sinon coincé à `true` sur une timeline qui + // n'a plus de trou d'effort. // // La pose du break n'est pas appliquée : sa phrase de changement ne sera // pas prononcée, imposer une position sans l'ordonner serait un ordre - // muet. Le break est annulé, sa mise en scène avec. + // muet. Le break est annulé, sa mise en scène avec. Le gel de posture, + // lui, n'a rien à lever ici : il est dérivé de la scène qui l'a ordonné + // (`PostureGate`) et tombe de lui-même dès que ce flow prend la main. _breakActive = false; _activeBreak = null; - confirmPostureReady(); // Retry milestone : si on rate dans la fenêtre pédagogique, on tente // d'abord de proposer une nouvelle tentative via le callback (qui diff --git a/rhythm_coach/test/posture_gate_invariant_test.dart b/rhythm_coach/test/posture_gate_invariant_test.dart new file mode 100644 index 00000000..3788239c --- /dev/null +++ b/rhythm_coach/test/posture_gate_invariant_test.dart @@ -0,0 +1,370 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:beat_bitch/career/models/challenge.dart'; +import 'package:beat_bitch/controllers/posture_gate.dart'; +import 'package:beat_bitch/controllers/session_controller.dart'; +import 'package:beat_bitch/models/posture.dart'; +import 'package:beat_bitch/models/session.dart'; +import 'package:beat_bitch/models/session_step.dart'; +import 'package:beat_bitch/services/ambience_engine.dart'; +import 'package:beat_bitch/services/beep_engine.dart'; +import 'package:beat_bitch/services/capability_axis.dart'; +import 'package:beat_bitch/services/punishment_loader.dart'; +import 'package:beat_bitch/services/random_comments_loader.dart'; +import 'package:beat_bitch/services/tts_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// **Invariant du gel de posture (issue #77)** : un gel dont la justification +/// est tombée ne survit pas. +/// +/// Ce fichier ne teste pas une liste de chemins — quatre chemins distincts ont +/// déjà laissé un gel orphelin derrière eux, et chaque correctif ciblé n'a +/// jamais rien dit du suivant. Il teste la *propriété* qui les rend tous +/// inoffensifs, y compris ceux qu'on écrira demain : +/// +/// 1. la justification elle-même, composante par composante ([PostureGate]) ; +/// 2. son branchement en séance, sur un chemin de production auquel aucune +/// ligne de code ne parle du gel (la clôture d'un défi) ; +/// 3. une garde qui devient rouge si quelqu'un recrée le motif — un chemin +/// qui « pense à lever le gate » plutôt que de le laisser tomber seul. +/// +/// Tests runtime en temps réel : le `Stopwatch` du controller n'est pas simulé +/// par `flutter_test`, `pump()` n'avance pas son horloge. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('la justification, composante par composante', () { + // Deux instances distinctes au contenu identique : c'est bien l'identité + // de la timeline qui compte, pas son égalité de valeur. Deux `const + // Session` identiques seraient canonicalisées en une seule instance et le + // test ne dirait plus rien. + // ignore: prefer_const_constructors + Session buildSession() => Session( + id: 'anchor', + name: 'anchor', + description: '', + durationSeconds: 60, + defaultMode: SessionMode.rhythm, + steps: const [ + SessionStep(time: 0, mode: SessionMode.rhythm, bpm: 80), + ], + ); + + final session = buildSession(); + final gate = PostureGate( + session: session, + nextStepIndex: 3, + timelineOffset: const Duration(seconds: -2), + failGeneration: 1, + ); + + bool holdsWith({ + Session? session_, + int? nextStepIndex, + Duration? timelineOffset, + int? failGeneration, + bool otherSceneActive = false, + }) => + gate.stillHolds( + session: session_ ?? session, + nextStepIndex: nextStepIndex ?? 3, + timelineOffset: timelineOffset ?? const Duration(seconds: -2), + failGeneration: failGeneration ?? 1, + otherSceneActive: otherSceneActive, + ); + + test('la situation où l\'ordre a été donné : le gel tient', () { + expect(holdsWith(), isTrue); + }); + + test('horloge reculée d\'un tick : le gel tient — c\'est lui qui recule', + () { + expect( + holdsWith( + timelineOffset: const Duration(seconds: -2, milliseconds: -200)), + isTrue); + }); + + test('timeline remplacée : le gel tombe', () { + expect(holdsWith(session_: buildSession()), isFalse); + }); + + test('tête de lecture déplacée : le gel tombe', () { + expect(holdsWith(nextStepIndex: 4), isFalse); + expect(holdsWith(nextStepIndex: 2), isFalse); + }); + + test('horloge avancée : le gel tombe', () { + expect(holdsWith(timelineOffset: const Duration(seconds: -1)), isFalse); + }); + + test('un flow d\'échec a pris la main : le gel tombe', () { + expect(holdsWith(failGeneration: 2), isFalse); + }); + + test('une autre mise en scène tient l\'écran : le gel tombe', () { + expect(holdsWith(otherSceneActive: true), isFalse); + }); + }); + + group('en séance, sur un chemin qui ignore tout du gel', () { + const ttsChannel = MethodChannel('flutter_tts'); + const codec = StandardMethodCodec(); + + Future pushFromEngine(String method, [Object? args]) { + return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + 'flutter_tts', + codec.encodeMethodCall(MethodCall(method, args)), + (_) {}, + ); + } + + void installFakeTtsEngine() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ttsChannel, (call) async { + switch (call.method) { + case 'speak': + unawaited(pushFromEngine('speak.onStart', true)); + Timer(const Duration(milliseconds: 40), + () => pushFromEngine('speak.onComplete', true)); + return 1; + case 'stop': + unawaited(pushFromEngine('speak.onCancel', true)); + return 1; + case 'getVoices': + return []; + default: + return 1; + } + }); + } + + const audioChannels = [ + MethodChannel('xyz.luan/audioplayers.global'), + MethodChannel('xyz.luan/audioplayers'), + ]; + const wakelockChannels = [ + 'dev.flutter.pigeon.wakelock_plus_platform_interface.WakelockPlusApi' + '.toggle', + 'dev.flutter.pigeon.wakelock_plus_platform_interface.WakelockPlusApi' + '.isEnabled', + ]; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + SharedPreferences.setMockInitialValues({}); + installFakeTtsEngine(); + for (final c in audioChannels) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(c, (call) async => null); + } + for (final name in wakelockChannels) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + name, + (_) async => + const StandardMessageCodec().encodeMessage([null]), + ); + } + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ttsChannel, null); + for (final c in audioChannels) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(c, null); + } + }); + + // Pause [1,3) imposant `kneeling` à la reprise, et un défi dont le trigger + // tombe exactement à `break.endTime` — ce que le générateur produisait + // avant l'espacement des deux plannings, et ce qu'un décalage d'horloge + // peut encore produire. Rien dans la clôture d'un défi + // (`_completeChallenge` → `_excisChallengeFromSession`) ne connaît le gel + // de posture : c'est précisément ce qui en fait un bon test d'invariant. + SessionController buildController() => SessionController( + staminaProfile: List.filled(400, 100), + session: const Session( + id: 'gate-vs-challenge', + name: 'gate-vs-challenge', + description: '', + durationSeconds: 300, + defaultMode: SessionMode.rhythm, + initialPose: Posture.free, + breaks: [ + ScriptedBreak( + time: 1, + durationSeconds: 2, + newPose: Posture.kneeling, + ), + // Pause encore à venir au moment du défi : elle doit lui + // survivre, décalée comme les steps. + ScriptedBreak( + time: 100, + durationSeconds: 60, + newPose: Posture.standing, + ), + ], + challenges: [ + Challenge( + axis: CapabilityAxis.holdThroatStreak, + kind: ChallengeAxisKind.duration, + targetThreshold: 5, + mode: SessionMode.hold, + ), + ], + challengeTriggerTimes: [3], + steps: [ + SessionStep( + time: 0, text: 'debut', mode: SessionMode.rhythm, bpm: 80), + SessionStep( + time: 200, text: 'suite', mode: SessionMode.rhythm, bpm: 100), + ], + ), + tts: TtsService(), + beep: _SilentBeepEngine(), + ambience: _SilentAmbienceEngine(), + punishmentBundle: const PunishmentBundle( + failPhrases: ['tu craques'], + punishments: [], + ), + randomComments: const RandomCommentsBundle( + comments: [], + minIntervalSeconds: 999, + maxIntervalSeconds: 999, + scriptedCooldownSeconds: 4, + ), + ); + + test( + 'une consigne concurrente n\'empile pas son bandeau sur l\'ordre de ' + 'posture', () async { + final ctrl = buildController(); + await ctrl.start(); + await Future.delayed(const Duration(milliseconds: 3600)); + + expect(ctrl.breakActive, isFalse, reason: 'pause finie à t≈3,6 s'); + expect(ctrl.challengePhase, ChallengePhase.breath, + reason: 'le trigger défi est planifié à `break.endTime` : il s\'arme ' + 'au même tick que la sortie de pause'); + expect(ctrl.awaitingPostureReady, isFalse, + reason: 'le défi tient l\'écran : l\'ordre de posture n\'est plus la ' + 'scène courante, deux consignes ne s\'empilent pas'); + + await ctrl.stop(); + }, timeout: const Timeout(Duration(seconds: 40))); + + test( + 'la clôture d\'un défi ne laisse pas de gel derrière elle — et rien ' + 'dans cette clôture ne parle du gel', () async { + final ctrl = buildController(); + await ctrl.start(); + await Future.delayed(const Duration(milliseconds: 3600)); + expect(ctrl.challengePhase, ChallengePhase.breath); + + // PASSE : `_completeChallenge` → `_startPostChallengeBreath` → + // `_excisChallengeFromSession`. Ce chemin remplace la timeline sans + // rien savoir de la pause qui vient de finir. + ctrl.triggerChallengePass(); + expect(ctrl.challengePhase, ChallengePhase.ended); + expect(ctrl.awaitingPostureReady, isFalse); + + // Respiration post-défi (10 s) puis reprise : c'est le gel lui-même + // qu'on mesure, pas son drapeau — sans l'invariant, `_checkSteps` + // retournerait tôt et `_onTick` décrémenterait l'horloge à chaque tick. + final frozenAt = ctrl.elapsedSeconds; + await Future.delayed(const Duration(milliseconds: 11500)); + expect(ctrl.awaitingPostureReady, isFalse); + expect(ctrl.elapsedSeconds, greaterThan(frozenAt), + reason: 'la séance repart après la respiration post-défi'); + + await ctrl.stop(); + }, timeout: const Timeout(Duration(seconds: 60))); + + test('les pauses encore à venir survivent au défi', () async { + final ctrl = buildController(); + await ctrl.start(); + await Future.delayed(const Duration(milliseconds: 3600)); + expect(ctrl.challengePhase, ChallengePhase.breath); + + ctrl.triggerChallengePass(); + + // L'excision reconstruit la séance ; sans report explicite, + // `Session.breaks` retombait à `const []` et toute la mise en scène + // restante disparaissait dès qu'un défi avait eu lieu. + expect(ctrl.session.breaks.length, 2, + reason: 'la liste garde sa longueur — `_nextBreakIndex` pointe ' + 'dedans'); + // Fenêtre excisée = countdown (13 s) + durée nominale du défi (5 s) : + // la pause d'après suit le même décalage que les steps. + expect(ctrl.session.breaks[1].time, 100 - (13 + 5)); + expect(ctrl.session.breaks[1].newPose, Posture.standing); + + await ctrl.stop(); + }, timeout: const Timeout(Duration(seconds: 40))); + }); + + test('aucun chemin ne lève le gel de posture à la main', () { + // Le motif qui a produit les quatre gels orphelins : un chemin qui rebat + // la timeline et qu'on corrige en lui faisant lever le gate. Il n'y a plus + // rien à lever — si cette liste se remplit, c'est que le motif est revenu + // et que quelqu'un compte à nouveau sur une énumération de chemins. + final offenders = []; + final sources = Directory('lib/controllers') + .listSync() + .whereType() + .where((f) => f.path.endsWith('.dart')); + for (final file in sources) { + final lines = file.readAsLinesSync(); + for (var i = 0; i < lines.length; i++) { + final line = lines[i]; + if (!line.contains('confirmPostureReady')) continue; + // Sa définition et l'armement du timeout de sécurité sont les deux + // seuls usages côté contrôleur ; la validation joueuse vient de l'UI. + if (line.contains('void confirmPostureReady()')) continue; + if (line.contains('_readyTimeoutDuration')) continue; + offenders.add('${file.path}:${i + 1} — ${line.trim()}'); + } + } + expect(offenders, isEmpty, + reason: 'le gel de posture est dérivé de la scène qui l\'a ordonné : ' + 'un chemin qui rebat la timeline n\'a rien à lever'); + }); +} + +class _SilentBeepEngine extends BeepEngine { + @override + Future init() async {} + @override + Future applyStep(SessionStep step, SessionMode sessionMode) async {} + @override + Future pause() async {} + @override + Future resume() async {} + @override + Future stop() async {} + @override + Future dispose() async {} +} + +class _SilentAmbienceEngine extends AmbienceEngine { + @override + Future play(String? assetPath) async {} + @override + Future playForMode(SessionMode mode) async {} + @override + Future pause() async {} + @override + Future resume() async {} + @override + Future stop() async {} + @override + Future dispose() async {} +} diff --git a/rhythm_coach/test/session_break_fail_gate_test.dart b/rhythm_coach/test/session_break_fail_gate_test.dart index 7c699794..b4e358db 100644 --- a/rhythm_coach/test/session_break_fail_gate_test.dart +++ b/rhythm_coach/test/session_break_fail_gate_test.dart @@ -18,19 +18,20 @@ import 'package:shared_preferences/shared_preferences.dart'; /// Runtime de la machine de break scénarisé (issue #77), sur les chemins qui /// rebattent la timeline **sans** passer par `_exitBreak`. /// -/// La sortie d'un break qui impose une nouvelle posture arme un gate -/// (`_awaitingReady`) : la séance se fige jusqu'au « JE SUIS EN PLACE » ou au -/// timeout de sécurité de 90 s. Ce gel est délibéré — tant qu'il est armé, -/// `_checkSteps()` retourne tôt et `_onTick` décrémente `_timelineOffset`, -/// donc plus aucun step n'est consommé et l'horloge logique n'avance plus. +/// La sortie d'un break qui impose une nouvelle posture arme un gel de +/// posture : la séance se fige jusqu'au « JE SUIS EN PLACE » ou au timeout de +/// sécurité de 90 s. Ce gel est délibéré — tant qu'il tient, `_checkSteps()` +/// retourne tôt et `_onTick` décrémente `_timelineOffset`, donc plus aucun +/// step n'est consommé et l'horloge logique n'avance plus. /// -/// Trois chemins peuvent traverser cette fenêtre : le bouton « je peux pas » -/// (le seul contrôle de séance visible en production, jamais masqué par le -/// break ni par le gate), le bouton « Utilise-moi » et la régénération de -/// retry milestone (toutes deux via `requestUpgrade`). Aucun ne passe par -/// `_exitBreak` : sans nettoyage explicite, le gate survit intact au saut de -/// timeline et le gel délibéré devient un gel définitif — ticker actif, plus -/// rien qui avance, jusqu'au timeout. +/// Trois chemins traversent cette fenêtre : le bouton « je peux pas » (le seul +/// contrôle de séance visible en production, jamais masqué par le break ni par +/// le gel), le bouton « Utilise-moi » et la régénération de retry milestone +/// (toutes deux via `requestUpgrade`). Aucun ne passe par `_exitBreak`, et +/// aucun ne lève le gel : il est dérivé de la scène qui l'a ordonné +/// (`PostureGate`) et tombe de lui-même quand cette scène disparaît. +/// L'invariant lui-même est testé dans `posture_gate_invariant_test.dart` ; +/// ce fichier vérifie qu'il tient sur ces trois chemins réels. /// /// Le dernier test couvre l'autre versant : le retour d'arrière-plan /// (notification, appel, écran verrouillé) relançait le loop d'effort en @@ -193,9 +194,9 @@ void main() { expect(ctrl.isRunning, isTrue, reason: 'le flow fail est allé à son terme'); expect(ctrl.awaitingPostureReady, isFalse, - reason: 'le flow fail rebat la timeline sans passer par `_exitBreak` : ' - 'il doit lever le gate lui-même, sinon `_checkSteps` ne consomme ' - 'plus jamais de step'); + reason: 'le flow fail a pris la main et sauté la timeline : la scène ' + 'qui ordonnait la mise en place n\'existe plus, le gel tombe avec ' + 'elle — sinon `_checkSteps` ne consomme plus jamais de step'); // L'horloge logique doit repartir. Sans le nettoyage, `_onTick` décrémente // `_timelineOffset` à chaque tick et `elapsedSeconds` reste figé à la @@ -227,12 +228,13 @@ void main() { reason: 'le flow fail a sauté la timeline hors du trou d\'effort : le ' 'break ne peut pas rester actif derrière lui'); - // Sans nettoyage, `_exitBreak` rattrapait son retard au tick suivant et - // armait un gate pour un step déjà consommé par le saut de section. + // Sans le nettoyage de `triggerFail`, `_exitBreak` rattrapait son retard + // au tick suivant et ordonnait une posture pour un step déjà consommé par + // le saut de section. await Future.delayed(const Duration(milliseconds: 600)); expect(ctrl.awaitingPostureReady, isFalse, - reason: 'aucun gate ne doit s\'armer après coup pour un break que le ' - 'flow fail a déjà annulé'); + reason: 'aucune mise en place ne doit être ordonnée après coup pour un ' + 'break que le flow fail a déjà annulé'); final resumedAt = ctrl.elapsedSeconds; await Future.delayed(const Duration(milliseconds: 1600)); @@ -271,8 +273,8 @@ void main() { ); expect(ctrl.awaitingPostureReady, isFalse, - reason: 'la régénération remet l\'état de break à zéro : le gate posé ' - 'par ce break doit tomber avec lui'); + reason: 'la régénération remplace la timeline : le gel posé par un ' + 'break de l\'ancienne tombe avec elle'); final resumedAt = ctrl.elapsedSeconds; await Future.delayed(const Duration(milliseconds: 1600)); From 73fbbfe6b300982dc4219dadc3c646cae4097e61 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:23:21 +0200 Subject: [PATCH 5/9] =?UTF-8?q?fix(postures):=20espacer=20les=20pauses=20s?= =?UTF-8?q?c=C3=A9naris=C3=A9es=20des=20cr=C3=A9neaux=20de=20d=C3=A9fi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les horaires de pause et de défi étaient calculés par deux fonctions indépendantes qui visaient toutes deux le milieu de la séance. Le trou d'effort de la pause avalait le trigger du défi médian, que la boucle de génération réémettait à `break.endTime` : l'ordre de se mettre en position et l'annonce du défi tombaient au même instant. Le planificateur de pauses reçoit les créneaux de défi et cède le passage — les horaires de défi ne bougent pas. Sans défi, les horaires nominaux sont inchangés. Balayage sur le générateur réel (8 durées × 4 comptes de défis × 60 graines, 1920 générations) : 175 collisions avant, 0 après. --- .../generation/career_session_generator.dart | 74 +++++++++- .../test/scripted_breaks_challenges_test.dart | 135 ++++++++++++++++++ 2 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 rhythm_coach/test/scripted_breaks_challenges_test.dart diff --git a/rhythm_coach/lib/career/services/generation/career_session_generator.dart b/rhythm_coach/lib/career/services/generation/career_session_generator.dart index 5201b5b7..0f0f0328 100644 --- a/rhythm_coach/lib/career/services/generation/career_session_generator.dart +++ b/rhythm_coach/lib/career/services/generation/career_session_generator.dart @@ -229,19 +229,80 @@ class CareerSessionGenerator { return 0; } + /// Marge (s) laissée entre une pause scénarisée et le créneau d'un défi. + /// Absorbe l'écart entre l'horaire *planifié* d'un bloc et son émission + /// réelle : la boucle main pose la pause à la première frontière de bloc qui + /// suit son trigger, jamais pile dessus. + static const int _breakChallengeGapSeconds = 60; + + /// Plancher de décalage d'une pause, en fraction de `genUntil`. Les + /// fractions nominales (0.34 / 0.5) garantissaient seules « jamais dans les + /// 5 premières minutes » ; dès qu'on décale, il faut le dire. + static const double _breakEarliestFraction = 0.25; + /// `triggerTime` d'insertion des breaks dans la fenêtre de génération /// `genUntil` (≈ durée hors phase finish). 1 break → vers la moitié ; /// 2 breaks → vers le tiers et les deux tiers. Le placement aux frontières /// de blocs (insertion dans la boucle main) et `genUntil < effectiveDuration` /// garantissent structurellement « jamais dans les 5 premières min ni dans /// la phase finish » pour les durées éligibles (≥ 28 min). + /// + /// [challengeWindows] : créneaux `(début, fin)` déjà réservés par les défis + /// planifiés. Sans eux, les deux plannings visent le même milieu de séance — + /// une pause et un défi médian tombent alors au même endroit, le trou + /// d'effort de la pause avale le trigger du défi et la boucle main le + /// réémet à `break.endTime` : la joueuse reçoit l'ordre de se mettre à + /// quatre pattes et l'annonce du défi au même instant. Les horaires de défi + /// ne bougent pas (ils portent la dramaturgie du défi et se coordonnent + /// avec la durée de séance) — c'est la pause qui cède le passage. static List _computeBreakTriggerTimes({ required int count, required int genUntil, + List<(int, int)> challengeWindows = const [], }) { if (count == 0) return const []; - if (count == 1) return [(genUntil * 0.5).round()]; - return [(genUntil * 0.34).round(), (genUntil * 0.67).round()]; + final ideals = count == 1 + ? [(genUntil * 0.5).round()] + : [(genUntil * 0.34).round(), (genUntil * 0.67).round()]; + // Une pause déjà placée devient elle-même un créneau occupé : deux pauses + // décalées vers le même trou libre se recouvriraient. + final busy = [...challengeWindows]; + final times = []; + for (final ideal in ideals) { + final t = _breakSlotClearOf(ideal, genUntil: genUntil, busy: busy); + times.add(t); + busy.add((t, t + _breakMaxDurationSeconds)); + } + return times; + } + + /// Renvoie l'horaire de pause le plus proche de [ideal] dont la fenêtre + /// (`[t, t + durée max]`, marge comprise) ne touche aucun créneau de [busy]. + /// Retourne [ideal] tel quel s'il est déjà libre, ou si aucun créneau + /// acceptable n'existe — mieux vaut une collision que perdre la pause ou la + /// jeter au début de la séance. + static int _breakSlotClearOf( + int ideal, { + required int genUntil, + required List<(int, int)> busy, + }) { + const span = _breakMaxDurationSeconds + _breakChallengeGapSeconds; + bool collides(int t) => busy + .any((w) => t < w.$2 + _breakChallengeGapSeconds && t + span > w.$1); + if (!collides(ideal)) return ideal; + final lowest = (genUntil * _breakEarliestFraction).round(); + final highest = genUntil - span; + int? best; + for (final w in busy) { + for (final candidate in [w.$2 + _breakChallengeGapSeconds, w.$1 - span]) { + if (candidate < lowest || candidate > highest) continue; + if (collides(candidate)) continue; + if (best == null || (candidate - ideal).abs() < (best - ideal).abs()) { + best = candidate; + } + } + } + return best ?? ideal; } /// Enveloppe temporelle conservative réservée par le générateur après @@ -813,6 +874,15 @@ class CareerSessionGenerator { final plannedBreakTimes = _computeBreakTriggerTimes( count: _config.scriptedBreaks ? _computeBreakCount(effectiveDuration) : 0, genUntil: genUntil, + challengeWindows: [ + for (var i = 0; i < plannedTriggerTimes.length; i++) + ( + plannedTriggerTimes[i], + plannedTriggerTimes[i] + + kChallengeBreathDurationSeconds + + _estimatedChallengeDuration(challengeQueue[i]), + ), + ], ); final sessionBreaks = []; var nextBreakIndex = 0; diff --git a/rhythm_coach/test/scripted_breaks_challenges_test.dart b/rhythm_coach/test/scripted_breaks_challenges_test.dart new file mode 100644 index 00000000..e93cdaa4 --- /dev/null +++ b/rhythm_coach/test/scripted_breaks_challenges_test.dart @@ -0,0 +1,135 @@ +import 'package:beat_bitch/career/models/challenge.dart'; +import 'package:beat_bitch/career/models/phrase_bank.dart'; +import 'package:beat_bitch/career/services/generation/career_session_generator.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Pauses scénarisées et défis intra-séance partagent la même séance +/// (issue #77) — les deux sont actifs par défaut. +/// +/// Leurs horaires étaient calculés par deux fonctions indépendantes qui +/// visaient toutes deux le milieu de la séance : le trou d'effort de la pause +/// avalait le trigger du défi, que la boucle de génération réémettait à +/// `break.endTime`. La joueuse recevait alors l'ordre de changer de position +/// et l'annonce du défi au même instant — deux bandeaux, deux consignes. +/// +/// Le planificateur de pauses connaît désormais les créneaux de défi et cède +/// le passage (les horaires de défi, eux, ne bougent pas). +void main() { + List phrases(List t) => + t.map((s) => PhraseEntry(text: s)).toList(); + + PhraseBank bank() => PhraseBank( + byMode: { + for (final m in SessionMode.values) + m: { + 'soft': phrases(['s']), + 'medium': phrases(['m']), + 'hard': phrases(['h']), + 'finale': phrases(['f']), + 'attempt': phrases(['a']), + 'extension': phrases(['e']), + 'fail': phrases(['fa']), + 'stop': phrases(['st']), + 'success': phrases(['su']), + 'skip': phrases(['sk']), + }, + }, + congrats: phrases(['bravo']), + intros: phrases(['intro']), + ); + + const challengePool = [ + Challenge( + axis: CapabilityAxis.holdThroatStreak, + kind: ChallengeAxisKind.duration, + targetThreshold: 10, + mode: SessionMode.hold, + ), + Challenge( + axis: CapabilityAxis.gorgeApneeStreak, + kind: ChallengeAxisKind.duration, + targetThreshold: 8, + mode: SessionMode.hold, + ), + Challenge( + axis: CapabilityAxis.biffleStreak, + kind: ChallengeAxisKind.duration, + targetThreshold: 12, + mode: SessionMode.biffle, + ), + Challenge( + axis: CapabilityAxis.rhythmMotionStreak, + kind: ChallengeAxisKind.duration, + targetThreshold: 15, + mode: SessionMode.rhythm, + ), + ]; + + test( + 'aucun trigger de défi ne tombe dans la fenêtre de mise en place d\'une ' + 'pause, sur tout le domaine où les deux coexistent', () { + // Countdown du breath de défi, dupliqué : la constante est privée au + // générateur. La fenêtre à risque va du début de la pause à la fin de son + // ordre de posture. + const challengeBreathSeconds = 13; + final collisions = []; + var generatedWithBoth = 0; + + for (final minutes in [28, 32, 40, 45, 50, 60, 75, 90]) { + for (final challengeCount in [1, 2, 3, 4]) { + for (var seed = 0; seed < 60; seed++) { + final session = CareerSessionGenerator(seed: seed) + .generate( + level: 14, + bank: bank(), + durationSeconds: minutes * 60, + unlockedKeys: UnlockKey.values.toSet(), + scriptedBreaks: true, + challenge: ChallengeInputs( + challenges: challengePool.take(challengeCount).toList(), + ), + ) + .session; + if (session.breaks.isEmpty || session.challengeTriggerTimes.isEmpty) { + continue; + } + generatedWithBoth++; + for (final b in session.breaks) { + // Pas d'ordre de posture, pas de mise en place à attendre. + if (b.newPose == null) continue; + for (final t in session.challengeTriggerTimes) { + if (t >= b.time && t < b.endTime + challengeBreathSeconds) { + collisions.add('${minutes}min ×$challengeCount défis seed=$seed' + ' : pause [${b.time},${b.endTime}) vs trigger $t'); + } + } + } + } + } + } + + expect(generatedWithBoth, greaterThan(0), + reason: 'sans séance portant les deux, le balayage ne teste rien'); + expect(collisions, isEmpty, + reason: 'les deux plannings se connaissent : la pause cède le passage'); + }); + + test('sans défi, les pauses gardent leurs horaires nominaux', () { + // Le décalage ne doit s'appliquer qu'en présence d'un créneau occupé — + // sinon il déplacerait les pauses de toutes les séances sans défi. + for (final minutes in [28, 45, 60]) { + final withoutChallenges = CareerSessionGenerator(seed: 7) + .generate( + level: 14, + bank: bank(), + durationSeconds: minutes * 60, + unlockedKeys: UnlockKey.values.toSet(), + scriptedBreaks: true, + ) + .session; + final expectedCount = minutes >= 45 ? 2 : 1; + expect(withoutChallenges.breaks.length, expectedCount, + reason: '$minutes min → $expectedCount pause(s)'); + } + }); +} From 23632f762fc6f1921555555a135947aa600021c3 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:23:21 +0200 Subject: [PATCH 6/9] =?UTF-8?q?fix(postures):=20garder=20les=20pauses=20re?= =?UTF-8?q?stantes=20quand=20un=20d=C3=A9fi=20se=20termine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_excisChallengeFromSession` reconstruit la séance sans passer `breaks:` ni `initialPose:` — le défaut par défaut de `Session.breaks` est `const []`, donc toute la mise en scène restante disparaissait dès qu'un défi avait eu lieu, sur la fonctionnalité qu'on est en train d'allumer. La liste garde sa longueur (`_nextBreakIndex` pointe dedans) et ses horaires suivent le même décalage que les steps. --- .../controllers/session_controller_challenge.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/rhythm_coach/lib/controllers/session_controller_challenge.dart b/rhythm_coach/lib/controllers/session_controller_challenge.dart index f086de82..0e6e32d9 100644 --- a/rhythm_coach/lib/controllers/session_controller_challenge.dart +++ b/rhythm_coach/lib/controllers/session_controller_challenge.dart @@ -1021,6 +1021,21 @@ extension ChallengeOrchestrator on SessionController { silentFinishStartTime: shiftLate(_session.silentFinishStartTime), finalStepTime: shiftLate(_session.finalStepTime), noStats: _session.noStats, + initialPose: _session.initialPose, + // Les pauses scénarisées survivent au défi (issue #77) : sans ce report + // explicite, `Session.breaks` retombe à `const []` et toute la mise en + // scène restante disparaît dès qu'un défi a eu lieu. La liste garde sa + // longueur et son ordre — `_nextBreakIndex` pointe dedans — et ses + // horaires suivent le même shift que les steps. + breaks: [ + for (final b in _session.breaks) + ScriptedBreak( + time: shiftLate(b.time)!, + durationSeconds: b.durationSeconds, + newPose: b.newPose, + orders: b.orders, + ), + ], // L'excise du défi en cours retire ses 2 steps (breath + défi) de // la timeline et shifte tout ce qui est après. On préserve la liste // complète des défis, mais on shifte les trigger times des défis From bbf01acf8dc42f31dba74dcedd6abd3d676acc0b Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:24:51 +0200 Subject: [PATCH 7/9] =?UTF-8?q?fix(postures):=20lib=C3=A9rer=20le=20timeou?= =?UTF-8?q?t=20de=20mise=20en=20place=20aux=20bornes=20de=20la=20s=C3=A9an?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stop()` et `_finish()` remettaient la timeline à zéro mais laissaient courir le `Timer` de sécurité du gel de posture : ~90 s plus tard, il réveillait un contrôleur devenu inactif ou l'écran de fin. Il n'y a pas de gel à lever — il est tombé de lui-même — juste une ressource à libérer, aux trois bornes de vie d'une séance (`start` le faisait déjà). Entre elles, c'est la garde du battement qui s'en charge. --- .../lib/controllers/session_controller.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/rhythm_coach/lib/controllers/session_controller.dart b/rhythm_coach/lib/controllers/session_controller.dart index 0dfabd0b..6b734cca 100644 --- a/rhythm_coach/lib/controllers/session_controller.dart +++ b/rhythm_coach/lib/controllers/session_controller.dart @@ -1245,6 +1245,13 @@ class SessionController extends ChangeNotifier { _hadFailThisSession = false; _currentHoldFullDuration = 0; _lastHoldTickAtSecond = -1; + // Remise à zéro du gel de posture, comme dans `start()` : il ne tient + // déjà plus (la timeline vient d'être remise à zéro), mais son `Timer` de + // sécurité survivait à l'arrêt et réveillait ~90 s plus tard un + // contrôleur devenu inactif. + _postureGate = null; + _readyTimeout?.cancel(); + _readyTimeout = null; // Phase 1 défis — reset complet de la machine d'états. _challengePhase = ChallengePhase.none; _challengeStepStartedAtSec = null; @@ -1766,6 +1773,14 @@ class SessionController extends ChangeNotifier { _stopwatch.stop(); _ticker?.cancel(); _ticker = null; + // Le `Timer` de sécurité du gel de posture est libéré aux trois bornes de + // vie d'une séance (`start`, `stop`, ici) : entre elles, c'est la garde du + // battement qui s'en charge, et elle ne tourne plus sans ticker. Il n'y a + // pas de gel à lever — il est tombé de lui-même — juste une ressource qui + // réveillerait l'écran de fin 90 s plus tard. + _postureGate = null; + _readyTimeout?.cancel(); + _readyTimeout = null; // Garde-fou : sur un backend audio engorgé (seek/stop qui ne rendent plus // la main sur les longues séances), `_beep.stop()` pouvait bloquer ici et // la session ne passait jamais en `finished` (écran de fin absent). On From 47d2569ab3334beec33fa0af8eb2af61b2e10920 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:18:59 +0200 Subject: [PATCH 8/9] =?UTF-8?q?test(postures):=20garder=20le=20champ=20du?= =?UTF-8?q?=20gel,=20pas=20le=20nom=20de=20la=20m=C3=A9thode=20qui=20le=20?= =?UTF-8?q?levait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La garde ne cherchait que la chaîne `confirmPostureReady`. Tous les fichiers du contrôleur sont des `part of` : écrire `_postureGate = null` depuis n'importe lequel d'entre eux est valide et passait au travers sans un mot — exactement le motif que ces passes ont servi à éliminer. Elle scanne désormais les écritures du champ dans toute sa library privée et exige que l'ensemble des membres qui y touchent soit exactement les six sites légitimes : naissance de l'ancre, validation joueuse, ramasse-miettes du battement, et les trois bornes de vie d'une séance. --- .../test/posture_gate_invariant_test.dart | 68 +++++++++++++------ 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/rhythm_coach/test/posture_gate_invariant_test.dart b/rhythm_coach/test/posture_gate_invariant_test.dart index 3788239c..70f5ff48 100644 --- a/rhythm_coach/test/posture_gate_invariant_test.dart +++ b/rhythm_coach/test/posture_gate_invariant_test.dart @@ -311,31 +311,61 @@ void main() { }, timeout: const Timeout(Duration(seconds: 40))); }); - test('aucun chemin ne lève le gel de posture à la main', () { + test('rien d\'autre que la scène qui l\'ordonne ne touche au gel de posture', + () { // Le motif qui a produit les quatre gels orphelins : un chemin qui rebat - // la timeline et qu'on corrige en lui faisant lever le gate. Il n'y a plus - // rien à lever — si cette liste se remplit, c'est que le motif est revenu - // et que quelqu'un compte à nouveau sur une énumération de chemins. - final offenders = []; - final sources = Directory('lib/controllers') - .listSync() - .whereType() - .where((f) => f.path.endsWith('.dart')); - for (final file in sources) { - final lines = file.readAsLinesSync(); + // la timeline et qu'on corrige en lui faisant lever le gate. La garde + // porte sur le **champ**, pas sur le nom d'une méthode : `_postureGate` + // est privé à la library de `session_controller.dart`, donc chacun de ses + // `part` peut y écrire sans jamais taper `confirmPostureReady`. + const owner = 'lib/controllers/session_controller.dart'; + final library = [owner]; + for (final line in File(owner).readAsLinesSync()) { + final part = RegExp(r"^part '([^']+)';").firstMatch(line); + if (part != null) library.add('lib/controllers/${part.group(1)}'); + } + + // Naissance de l'ancre, validation joueuse, ramasse-miettes du battement, + // et les trois bornes de vie d'une séance. Ailleurs, il n'y a rien à + // lever : le gel tombe seul quand sa justification tombe. + const authorized = { + '_enterAwaitReady', + 'confirmPostureReady', + '_burySpentPostureGate', + 'start', + 'stop', + '_finish', + }; + + final touchedBy = >{}; + final write = RegExp(r'_postureGate\s*=(?!=)'); + // Une déclaration de membre, et rien du corps qui la suit : `dart format` + // pose les membres à exactement deux espaces et tout leur contenu plus + // loin — d'où le « pas d'espace de plus » qui distingue les deux. + final member = RegExp(r'^ (?=\S)[\w<>?,\s\[\]]*\b(\w+)\s*\('); + for (final path in library) { + final lines = File(path).readAsLinesSync(); + var current = ''; for (var i = 0; i < lines.length; i++) { final line = lines[i]; - if (!line.contains('confirmPostureReady')) continue; - // Sa définition et l'armement du timeout de sécurité sont les deux - // seuls usages côté contrôleur ; la validation joueuse vient de l'UI. - if (line.contains('void confirmPostureReady()')) continue; - if (line.contains('_readyTimeoutDuration')) continue; - offenders.add('${file.path}:${i + 1} — ${line.trim()}'); + final signature = member.firstMatch(line); + if (signature != null) current = signature.group(1)!; + if (!write.hasMatch(line) && !line.contains('confirmPostureReady')) { + continue; + } + touchedBy + .putIfAbsent(current, () => []) + .add('$path:${i + 1} — ${line.trim()}'); } } - expect(offenders, isEmpty, + + final detail = touchedBy.entries + .map((e) => ' ${e.key}\n ${e.value.join('\n ')}') + .join('\n'); + expect(touchedBy.keys.toSet(), authorized, reason: 'le gel de posture est dérivé de la scène qui l\'a ordonné : ' - 'un chemin qui rebat la timeline n\'a rien à lever'); + 'un chemin qui rebat la timeline n\'a rien à lever, et aucun ' + 'autre n\'a à y toucher.\nSites trouvés :\n$detail'); }); } From 87cba4431b85f3530c143a92b5b38fce7065cda4 Mon Sep 17 00:00:00 2001 From: BB Studio <282851981+bbstudioapp@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:19:03 +0200 Subject: [PATCH 9/9] =?UTF-8?q?docs(postures):=20=C3=A9crire=20l'hypoth?= =?UTF-8?q?=C3=A8se=20d'immuabilit=C3=A9=20que=20porte=20la=20d=C3=A9rivat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La clause d'identité protège contre le remplacement de la séance, pas contre une mutation en place de ses listes. Aucun code ne le fait aujourd'hui, rien ne l'empêche demain et rien ne le détecterait : le noter là où la décision vit plutôt que de laisser « par définition » porter plus qu'il ne prouve. --- rhythm_coach/lib/controllers/posture_gate.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rhythm_coach/lib/controllers/posture_gate.dart b/rhythm_coach/lib/controllers/posture_gate.dart index 78041b82..4fc9218d 100644 --- a/rhythm_coach/lib/controllers/posture_gate.dart +++ b/rhythm_coach/lib/controllers/posture_gate.dart @@ -19,6 +19,14 @@ import '../models/session.dart'; /// toucher aucune est impossible. La quatrième couvre le flow d'échec, qui /// n'a pas besoin de rebattre la timeline pour annuler la mise en scène de la /// pause (cf. `triggerFail`). +/// +/// **Cette impossibilité repose sur une hypothèse** : une [Session] et ses +/// listes (`steps`, `breaks`, `challenges`) ne sont jamais modifiées après +/// construction — toute réécriture de la timeline en vol produit une nouvelle +/// instance (cf. `_excisChallengeFromSession`). La clause d'identité ne voit +/// pas une mutation en place : un `session.steps[i] = …` sur une séance déjà +/// démarrée rebattrait la timeline sans faire tomber le gel, et rien ne le +/// signalerait. @immutable class PostureGate { const PostureGate({