diff --git a/rhythm_coach/assets/debug/profiles/mi_carriere_profondeur.json b/rhythm_coach/assets/debug/profiles/mi_carriere_profondeur.json index 62e5352..c4ab3c3 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 35b300c..dc4d2ef 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 b56ac2f..43f4e71 100644 --- a/rhythm_coach/lib/career/screens/career_screen.dart +++ b/rhythm_coach/lib/career/screens/career_screen.dart @@ -2096,9 +2096,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 f841757..4446f8c 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 8707fab..63004ad 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 @@ -560,11 +621,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 @@ -825,13 +884,22 @@ 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). final plannedBreakTimes = _computeBreakTriggerTimes( count: _config.scriptedBreaks ? _computeBreakCount(effectiveDuration) : 0, genUntil: ctx.genUntil, + challengeWindows: [ + for (var i = 0; i < plannedTriggerTimes.length; i++) + ( + plannedTriggerTimes[i], + plannedTriggerTimes[i] + + kChallengeBreathDurationSeconds + + _estimatedChallengeDuration(challengeQueue[i]), + ), + ], ); final sessionBreaks = []; var nextBreakIndex = 0; @@ -1626,12 +1694,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 20cdef2..785e21d 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/controllers/posture_gate.dart b/rhythm_coach/lib/controllers/posture_gate.dart new file mode 100644 index 0000000..4fc9218 --- /dev/null +++ b/rhythm_coach/lib/controllers/posture_gate.dart @@ -0,0 +1,75 @@ +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`). +/// +/// **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({ + 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 3fdd522..ea67f36 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'; @@ -463,14 +464,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 @@ -1028,7 +1044,7 @@ class SessionController extends ChangeNotifier { _nextBreakIndex = 0; _breakOrderLastAtSec = 0; _currentPose = _session.initialPose; - _awaitingReady = false; + _postureGate = null; _readyTimeout?.cancel(); _readyTimeout = null; _capabilityTracker?.onSessionStart(); @@ -1202,7 +1218,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 && !awaitingPostureReady) await _beep.resume(); await _ambience.resume(); notifyListeners(); } @@ -1244,6 +1268,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; @@ -1299,6 +1330,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 @@ -1318,7 +1353,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(); } @@ -1333,7 +1368,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) { @@ -1481,7 +1516,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 && @@ -1648,14 +1683,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(); @@ -1665,13 +1705,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 @@ -1743,6 +1801,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 diff --git a/rhythm_coach/lib/controllers/session_controller_challenge.dart b/rhythm_coach/lib/controllers/session_controller_challenge.dart index 1079d38..cd7e557 100644 --- a/rhythm_coach/lib/controllers/session_controller_challenge.dart +++ b/rhythm_coach/lib/controllers/session_controller_challenge.dart @@ -1038,6 +1038,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, + ), + ], // On préserve la liste complète des défis, mais on shifte les trigger // times des défis suivants (ceux après `endOfChallenge`) pour qu'ils // restent synchronisés avec la nouvelle timeline — les deux listes diff --git a/rhythm_coach/lib/controllers/session_controller_fail_flow.dart b/rhythm_coach/lib/controllers/session_controller_fail_flow.dart index b12cfa2..f7e0cb2 100644 --- a/rhythm_coach/lib/controllers/session_controller_fail_flow.dart +++ b/rhythm_coach/lib/controllers/session_controller_fail_flow.dart @@ -52,6 +52,19 @@ extension FailFlowOrchestrator on SessionController { return; } + // 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. 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; + // 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/lib/l10n/app_de.arb b/rhythm_coach/lib/l10n/app_de.arb index b44e024..d5d6b9e 100644 --- a/rhythm_coach/lib/l10n/app_de.arb +++ b/rhythm_coach/lib/l10n/app_de.arb @@ -652,8 +652,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", @@ -1106,6 +1104,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 089eb98..4ecaaca 100644 --- a/rhythm_coach/lib/l10n/app_en.arb +++ b/rhythm_coach/lib/l10n/app_en.arb @@ -652,8 +652,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", @@ -1106,6 +1104,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 97ba6ab..a5bf05e 100644 --- a/rhythm_coach/lib/l10n/app_es.arb +++ b/rhythm_coach/lib/l10n/app_es.arb @@ -652,8 +652,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", @@ -1106,6 +1104,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 c994467..8a1788f 100644 --- a/rhythm_coach/lib/l10n/app_fr.arb +++ b/rhythm_coach/lib/l10n/app_fr.arb @@ -652,8 +652,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", @@ -1106,6 +1104,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 5b5799e..33711e6 100644 --- a/rhythm_coach/lib/l10n/app_localizations.dart +++ b/rhythm_coach/lib/l10n/app_localizations.dart @@ -2370,18 +2370,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: @@ -4002,6 +3990,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 583438d..ac5a1a2 100644 --- a/rhythm_coach/lib/l10n/app_localizations_de.dart +++ b/rhythm_coach/lib/l10n/app_localizations_de.dart @@ -1342,13 +1342,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'; @@ -2271,6 +2264,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 ad23901..1900c53 100644 --- a/rhythm_coach/lib/l10n/app_localizations_en.dart +++ b/rhythm_coach/lib/l10n/app_localizations_en.dart @@ -1336,13 +1336,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'; @@ -2260,6 +2253,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 b15757b..3a47d7b 100644 --- a/rhythm_coach/lib/l10n/app_localizations_es.dart +++ b/rhythm_coach/lib/l10n/app_localizations_es.dart @@ -1342,13 +1342,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'; @@ -2269,6 +2262,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 be8e7c0..43299e4 100644 --- a/rhythm_coach/lib/l10n/app_localizations_fr.dart +++ b/rhythm_coach/lib/l10n/app_localizations_fr.dart @@ -1345,13 +1345,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'; @@ -2271,6 +2264,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 48f31b7..575a830 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 b2d9b97..5da1209 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 1338bb1..40c87d5 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 7531244..b244c0c 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/posture_gate_invariant_test.dart b/rhythm_coach/test/posture_gate_invariant_test.dart new file mode 100644 index 0000000..70f5ff4 --- /dev/null +++ b/rhythm_coach/test/posture_gate_invariant_test.dart @@ -0,0 +1,400 @@ +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('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. 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]; + 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()}'); + } + } + + 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, et aucun ' + 'autre n\'a à y toucher.\nSites trouvés :\n$detail'); + }); +} + +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/posture_milestones_advanced_profile_test.dart b/rhythm_coach/test/posture_milestones_advanced_profile_test.dart new file mode 100644 index 0000000..0ed1385 --- /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')); + }); + }); +} 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 0000000..e93cdaa --- /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)'); + } + }); +} 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 0000000..ae7d875 --- /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); + }); + }); +} 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 0000000..b4e358d --- /dev/null +++ b/rhythm_coach/test/session_break_fail_gate_test.dart @@ -0,0 +1,381 @@ +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 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 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 +/// 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 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 + // 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 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: '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)); + 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 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)); + 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 {} +}