Files
GameTime/test/presentation/workout_execution_screen_test.dart

3038 lines
98 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/presentation.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
testWidgets('le temps affiché est recalculé depuis les horodatages', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 11, 59),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 30000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(
_FakeActiveSessionRepository(),
clock,
),
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
expect(find.text('Squat'), findsOneWidget);
expect(find.text('Durée de série : 00:45'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsOneWidget);
});
testWidgets('une séance invalide est abandonnée avec un message clair', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 11, 59),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 30000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _emptySessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(find.text('Séance indisponible'), findsOneWidget);
expect(
find.text(
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.',
),
findsOneWidget,
);
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
});
testWidgets('les répétitions sont initialisées avec la cible', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(targetReps: 8),
);
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(
_FakeActiveSessionRepository(),
clock,
),
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
expect(find.text('Répétitions'), findsOneWidget);
expect(find.text('8'), findsOneWidget);
});
testWidgets('affiche les statistiques montre live disponibles', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(hours: 1))
.millisecondsSinceEpoch,
heartRateBpm: 124,
),
)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now.millisecondsSinceEpoch,
heartRateBpm: 124,
distanceMeters: 840,
caloriesKcal: 186,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 124 bpm'), findsOneWidget);
expect(find.text('840 m'), findsOneWidget);
expect(find.text('186 kcal'), findsOneWidget);
});
testWidgets('naffiche aucun indicateur capteur sans donnée FC', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.textContaining('bpm'), findsNothing);
expect(find.textContaining('kcal'), findsNothing);
expect(find.byIcon(Icons.favorite), findsNothing);
expect(find.byIcon(Icons.local_fire_department), findsNothing);
});
testWidgets('ne fabrique pas de calories sans mesure montre native', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(hours: 1))
.millisecondsSinceEpoch,
heartRateBpm: 124,
),
)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now.millisecondsSinceEpoch,
heartRateBpm: 124,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 124 bpm'), findsOneWidget);
expect(find.textContaining('kcal'), findsNothing);
});
testWidgets('atténue la fréquence cardiaque périmée', (tester) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(seconds: 30))
.millisecondsSinceEpoch,
heartRateBpm: 118,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 118 bpm'), findsOneWidget);
expect(
find.ancestor(
of: find.text('FC 118 bpm'),
matching: find.byWidgetPredicate(
(widget) => widget is Opacity && widget.opacity == 0.45,
),
),
findsOneWidget,
);
});
testWidgets('les références de temps sont formatées sans valeurs brutes', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final performanceRepository = _FakeExercisePerformanceReferenceRepository(
last: WorkoutHistorySetPerformance(
workoutHistoryId: 'history-1',
startedAt: DateTime.utc(2026, 7, 12),
setIndex: 0,
exerciseNameSnapshot: 'Squat',
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
actualTimeMs: 72000,
actualScoreTimeMs: 45100,
),
record: WorkoutHistoryMetricPerformance(
workoutHistoryId: 'history-2',
startedAt: DateTime.utc(2026, 7, 15),
setIndex: 0,
exerciseNameSnapshot: 'Squat',
metric: PerformanceMetric.score,
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
actualScoreTimeMs: 42800,
),
);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
sourceExerciseId: 'exercise-source-1',
scoreInputMode: ScoreInputMode.stopwatch,
targetTimeSeconds: 300,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
performanceReferenceUseCase: ExercisePerformanceReferenceUseCase(
repository: performanceRepository,
),
),
),
);
await tester.pump();
await tester.pump();
expect(find.text('Durée de série : 05:00'), findsOneWidget);
expect(find.text('1:12 · 00:45.1'), findsOneWidget);
expect(find.text('00:42.8'), findsOneWidget);
expect(find.textContaining('72000'), findsNothing);
expect(find.textContaining('42800'), findsNothing);
});
testWidgets('les mesures cumulées respectent la hiérarchie visuelle', (
tester,
) async {
final scoreController = TextEditingController();
final scoreFocusNode = FocusNode();
addTearDown(scoreController.dispose);
addTearDown(scoreFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SetMeasureInput(
exercise: const ExecutionExercise(
id: 'exercise-1',
name: 'Squat',
setsCount: 3,
timeEnabled: true,
repsEnabled: true,
scoreEnabled: true,
restSeconds: 0,
scoreUnit: 'kg',
),
reps: 0,
scoreController: scoreController,
scoreFocusNode: scoreFocusNode,
onScoreSubmitted: (_) {},
onRepsChanged: (_) {},
),
),
),
);
final timeY = tester.getTopLeft(find.text('Durée')).dy;
final repsY = tester.getTopLeft(find.text('Répétitions')).dy;
final scoreY = tester.getTopLeft(find.text('Score (kg)')).dy;
expect(timeY, lessThan(repsY));
expect(repsY, lessThan(scoreY));
});
testWidgets('aucun repos ne saffiche après la toute dernière série', (
tester,
) async {
final plan = WorkoutExecutionPlan.fromSession(
ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 1,
restSeconds: 60,
),
),
);
const position = ExecutionPosition(
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Text(
plan.shouldShowRestAfter(position) ? 'Repos' : 'Pas de repos',
),
),
);
expect(find.text('Pas de repos'), findsOneWidget);
});
testWidgets('le bouton médias est absent sans média', (tester) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(
_FakeActiveSessionRepository(),
clock,
),
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
expect(find.text('Voir médias'), findsNothing);
expect(find.byTooltip('Voir les médias de lexercice'), findsNothing);
});
testWidgets('le bouton médias ouvre la sheet si médias présents', (
tester,
) async {
final imageFile = (await tester.runAsync(_createTestImageFile))!;
addTearDown(() async {
await tester.runAsync(() async {
await imageFile.parent.delete(recursive: true);
});
});
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
imageMediaIds: const ['image-1'],
videoMediaId: 'video-1',
),
);
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(
_FakeActiveSessionRepository(),
clock,
),
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
mediaAssetLoader: (id) async {
if (id == 'image-1') {
return MediaAsset(
metadata: _metadata(id),
kind: MediaKind.image,
localUri: imageFile.uri.toString(),
);
}
if (id == 'video-1') {
return MediaAsset(
metadata: _metadata(id),
kind: MediaKind.video,
localUri: imageFile.uri.toString(),
);
}
return null;
},
videoMediaBuilder: (context, asset) => SizedBox(
height: 260,
child: Center(child: Text('Lecteur vidéo ${asset.metadata.id}')),
),
),
),
);
});
expect(find.byTooltip('Voir les médias de lexercice'), findsOneWidget);
await tester.tap(find.byTooltip('Voir les médias de lexercice'));
await tester.runAsync(() async {
await tester.pumpAndSettle();
});
expect(find.text('Médias de lexercice'), findsOneWidget);
expect(find.text('Images'), findsOneWidget);
expect(find.text('Vidéo'), findsOneWidget);
expect(find.text('1/1'), findsOneWidget);
expect(
find.byKey(const ValueKey('exercise-image-image-1')),
findsOneWidget,
);
expect(find.text('Image 1'), findsNothing);
await tester.tap(find.text('Vidéo'));
await tester.pumpAndSettle();
expect(find.text('Lecteur vidéo video-1'), findsOneWidget);
});
testWidgets('la galerie médias navigue entre les images', (tester) async {
final imageFile = (await tester.runAsync(_createTestImageFile))!;
addTearDown(() async {
await tester.runAsync(() async {
await imageFile.parent.delete(recursive: true);
});
});
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
imageMediaIds: const ['image-1', 'image-2', 'image-3'],
),
);
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(
_FakeActiveSessionRepository(),
clock,
),
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
mediaAssetLoader: (id) async => MediaAsset(
metadata: _metadata(id),
kind: MediaKind.image,
localUri: imageFile.uri.toString(),
),
),
),
);
});
await tester.tap(find.byTooltip('Voir les médias de lexercice'));
await tester.runAsync(() async {
await tester.pumpAndSettle();
});
expect(find.text('1/3'), findsOneWidget);
await tester.drag(find.byType(PageView), const Offset(-400, 0));
await tester.runAsync(() async {
await tester.pumpAndSettle();
});
expect(find.text('2/3'), findsOneWidget);
expect(
find.byKey(const ValueKey('exercise-image-image-2')),
findsOneWidget,
);
});
testWidgets('le chrono score démarre et sarrête avec la bonne durée', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
timeEnabled: false,
scoreInputMode: ScoreInputMode.stopwatch,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
expect(find.text('Chrono score'), findsOneWidget);
expect(find.text('00:00.0'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Lancer'));
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 0, 100);
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('00:00.1'), findsOneWidget);
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 0, 900);
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('00:00.9'), findsOneWidget);
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
await tester.pump(const Duration(seconds: 1));
expect(find.text('00:01.2'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Arrêter'));
await tester.pump();
expect(find.text('00:01.2'), findsOneWidget);
expect(
repository.scoreStopwatchStates.single.status,
ActiveScoreStopwatchStatus.stopped,
);
});
testWidgets('terminer sans chrono démarré affiche une confirmation', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
timeEnabled: false,
scoreInputMode: ScoreInputMode.stopwatch,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.text('Terminer la série'));
await tester.pumpAndSettle();
expect(find.text('Aucun temps chronométré'), findsOneWidget);
expect(find.text('Tu nas pas démarré le chrono score.'), findsOneWidget);
expect(
find.widgetWithText(TextButton, 'Démarrer le chrono'),
findsOneWidget,
);
expect(find.text('Terminer sans chrono'), findsOneWidget);
expect(repository.results, isEmpty);
});
testWidgets('terminer avec chrono en cours larrête et enregistre', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
timeEnabled: false,
scoreInputMode: ScoreInputMode.stopwatch,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first);
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2, 500);
await tester.pump(const Duration(seconds: 1));
await tester.tap(find.text('Terminer la série'));
await tester.pumpAndSettle();
expect(repository.results.single.actualScoreTimeMs, 2500);
expect(
repository.scoreStopwatchStates.single.status,
ActiveScoreStopwatchStatus.stopped,
);
expect(repository.session?.currentSetIndex, 1);
});
testWidgets('un repos actif est repris avec le temps restant recalculé', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
restSeconds: 60,
),
);
repository.session = session;
repository.restStates.add(
ActiveRestState(
metadata: _metadata('rest-1'),
activeWorkoutSessionId: 'session-1',
afterProgramIndex: 0,
afterExerciseIndex: 0,
afterSetIndex: 0,
plannedRestSeconds: 60,
adjustedRestSeconds: 60,
startedAt: DateTime.now().toUtc().subtract(const Duration(seconds: 15)),
),
);
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(find.text('Repos avant la prochaine série'), findsOneWidget);
expect(find.text('00:45'), findsOneWidget);
expect(find.text('Série 2 / 2'), findsOneWidget);
await tester.tap(find.text('+15 s'));
await tester.pump();
expect(find.text('01:00'), findsOneWidget);
expect(repository.restStates.single.adjustedRestSeconds, 75);
await tester.tap(find.text('Ignorer le repos'));
await tester.pump();
expect(repository.restStates.single.skippedAt, isNotNull);
});
testWidgets('les mutations externes de repos resynchronisent lécran', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
restSeconds: 60,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(find.text('Repos avant la prochaine série'), findsNothing);
repository.restStates.add(
ActiveRestState(
metadata: _metadata('rest-1'),
activeWorkoutSessionId: 'session-1',
afterProgramIndex: 0,
afterExerciseIndex: 0,
afterSetIndex: 0,
plannedRestSeconds: 60,
adjustedRestSeconds: 60,
startedAt: DateTime.now().toUtc(),
),
);
await tester.pump(const Duration(seconds: 1));
await tester.pump();
expect(find.text('Repos avant la prochaine série'), findsOneWidget);
repository.restStates.clear();
repository.session = ActiveWorkoutSession(
metadata: session.metadata,
sourceWorkoutTemplateId: session.sourceWorkoutTemplateId,
status: ActiveWorkoutStatus.running,
startedAt: session.startedAt,
lastPersistedAt: session.lastPersistedAt,
elapsedActiveMs: session.elapsedActiveMs,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 1,
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
);
await tester.pump(const Duration(seconds: 1));
await tester.pump();
expect(find.text('Repos avant la prochaine série'), findsNothing);
expect(find.text('Terminer la série'), findsOneWidget);
});
testWidgets('un repos en pause est repris sans compter la durée de pause', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
restSeconds: 60,
),
);
repository.session = session;
final now = DateTime.now().toUtc();
repository.restStates.add(
ActiveRestState(
metadata: _metadata('rest-1'),
activeWorkoutSessionId: 'session-1',
afterProgramIndex: 0,
afterExerciseIndex: 0,
afterSetIndex: 0,
plannedRestSeconds: 60,
adjustedRestSeconds: 60,
startedAt: now.subtract(const Duration(minutes: 10, seconds: 15)),
pausedAt: now.subtract(const Duration(minutes: 10)),
),
);
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(find.text('Repos avant la prochaine série'), findsOneWidget);
expect(find.text('00:45'), findsOneWidget);
});
testWidgets('la mesure temps enregistre une durée réelle de série', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
restSeconds: 0,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first);
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2);
await tester.pump(const Duration(seconds: 2));
await tester.tap(find.text('Terminer la série'));
await tester.pump();
expect(repository.results.single.actualTimeMs, 2000);
});
testWidgets(
'durée et score chrono utilisent un seul chrono visible en exécution',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
timeEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
targetTimeSeconds: 30,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
expect(find.text('Chrono score'), findsNothing);
expect(find.text('Durée de série : 00:30'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first);
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2);
await tester.pump(const Duration(seconds: 2));
expect(find.text('Chrono score'), findsNothing);
expect(find.text('Durée de série : 00:02'), findsOneWidget);
await tester.tap(find.text('Terminer la série'));
await tester.pump();
final result = repository.results.single;
expect(result.actualTimeMs, 2000);
expect(result.actualScoreTimeMs, 2000);
},
);
testWidgets(
'terminer la série 2 avec temps répétitions et score manuel avance',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 1,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 3,
restSeconds: 0,
targetTimeSeconds: 30,
targetReps: 5,
targetScore: 10,
scoreUnit: 'points',
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
expect(find.text('Programme jambes'), findsOneWidget);
expect(find.text('2 / 3'), findsOneWidget);
expect(find.text('Squat'), findsOneWidget);
expect(find.text('Durée de série : 00:30'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first);
await tester.pump();
for (var i = 0; i < 2; i++) {
await tester.tap(find.byIcon(Icons.remove));
await tester.pump();
}
await tester.enterText(
find.widgetWithText(TextField, 'Score (points)'),
'5',
);
await tester.ensureVisible(find.text('Terminer la série'));
await tester.pumpAndSettle();
await tester.tap(find.text('Terminer la série'));
await tester.pumpAndSettle();
expect(repository.results.single.setIndex, 1);
expect(repository.results.single.actualReps, 3);
expect(repository.results.single.actualScore, 5);
expect(repository.session?.currentSetIndex, 2);
expect(find.text('Programme jambes'), findsOneWidget);
expect(find.text('3 / 3'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
},
);
testWidgets(
'la saisie du score téléphone alimente le score vivant de la montre',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final activeUseCases = _activeUseCases(repository, clock);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
restSeconds: 0,
scoreUnit: 'points',
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: activeUseCases,
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.enterText(
find.widgetWithText(TextField, 'Score (points)'),
'7.5',
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(repository.manualScoreStates.single.value, 7.5);
final incremented = await activeUseCases.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(incremented.state.value, 8.5);
},
);
testWidgets('la flèche de retour met la séance active en pause', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
restSeconds: 0,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.byTooltip('Mettre en pause'));
await tester.pumpAndSettle();
expect(find.text('Reprendre'), findsOneWidget);
expect(repository.session?.status, ActiveWorkoutStatus.paused);
});
testWidgets('le plan de séance affiche les états des séries', (tester) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 2,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 4,
restSeconds: 0,
),
);
repository.session = session;
repository.results.add(
ActiveSetResult(
metadata: _metadata('result-1'),
activeWorkoutSessionId: 'session-1',
programSnapshotId: 'template-program-1',
exerciseSnapshotId: 'exercise-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualReps: 10,
actualScore: 7,
scoreUnitSnapshot: 'paniers',
),
);
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.byTooltip('Voir le plan'));
await tester.pumpAndSettle();
expect(find.text('Plan de séance'), findsOneWidget);
expect(find.text('Terminée'), findsOneWidget);
expect(find.text('10 reps · 7 paniers'), findsOneWidget);
expect(find.text('Passée'), findsOneWidget);
expect(find.text('Aucun résultat'), findsOneWidget);
expect(find.text('En cours'), findsOneWidget);
expect(find.text('À faire'), findsOneWidget);
await tester.tap(find.text('En cours'));
await tester.pumpAndSettle();
expect(find.text('Plan de séance'), findsNothing);
});
testWidgets('le plan affiche le score chrono formaté avec objectif', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 1,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
restSeconds: 0,
scoreInputMode: ScoreInputMode.stopwatch,
targetScoreTimeMs: 45000,
),
);
repository.session = session;
repository.results.add(
ActiveSetResult(
metadata: _metadata('result-1'),
activeWorkoutSessionId: 'session-1',
programSnapshotId: 'template-program-1',
exerciseSnapshotId: 'exercise-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScoreTimeMs: 38700,
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
),
);
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.byTooltip('Voir le plan'));
await tester.pumpAndSettle();
expect(find.text('Score chrono 00:38.7 / objectif 00:45'), findsOneWidget);
expect(find.textContaining('Temps réalisé 00:38.7'), findsNothing);
});
testWidgets('le plan affiche le résumé de séquence dune série', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
targetReps: 2,
exerciseSteps: [
_step('step-1', 0, 'Gauche', ExerciseStepType.reps, 4),
_step('step-2', 1, 'Droite', ExerciseStepType.reps, 4),
],
),
);
repository.session = session;
repository.stepResults.add(
ActiveExerciseStepResult(
metadata: _metadata('step-result-1'),
activeWorkoutSessionId: 'session-1',
programSnapshotId: 'template-program-1',
exerciseSnapshotId: 'exercise-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
passageIndex: 0,
stepIndex: 0,
stepSnapshotId: 'step-1',
stepNameSnapshot: 'Gauche',
stepTypeSnapshot: ExerciseStepType.reps,
targetValueSnapshot: 4,
hasScoreSnapshot: false,
status: SetResultStatus.completed,
actualReps: 4,
),
);
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.byTooltip('Voir le plan'));
await tester.pumpAndSettle();
expect(find.text('Séquence : 1/2 étapes · Passage 1/2'), findsOneWidget);
});
testWidgets(
'modifier une série passée la termine sans changer la position courante',
(tester) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 2,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 4,
restSeconds: 0,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.byTooltip('Voir le plan'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Série 2'));
await tester.pumpAndSettle();
expect(find.text('Modifier la série'), findsOneWidget);
await tester.enterText(_textFieldWithLabel('Répétitions'), '12');
await tester.ensureVisible(find.text('Enregistrer le résultat'));
await tester.tap(find.text('Enregistrer le résultat'));
await tester.pumpAndSettle();
expect(repository.session?.currentSetIndex, 2);
expect(repository.results.single.status, SetResultStatus.completed);
expect(repository.results.single.actualReps, 12);
expect(find.text('Série mise à jour'), findsWidgets);
expect(find.text('12 reps'), findsOneWidget);
await tester.tap(find.byTooltip('Fermer'));
await tester.pumpAndSettle();
expect(find.text('Programme jambes'), findsOneWidget);
expect(find.text('3 / 4'), findsOneWidget);
},
);
testWidgets(
'toucher la série courante dans le plan revient à lécran principal',
(tester) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 1,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 3,
restSeconds: 0,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.byTooltip('Voir le plan'));
await tester.pumpAndSettle();
await tester.tap(find.text('En cours'));
await tester.pumpAndSettle();
expect(find.text('Plan de séance'), findsNothing);
expect(find.text('Modifier la série'), findsNothing);
expect(find.text('Programme jambes'), findsOneWidget);
expect(find.text('2 / 3'), findsOneWidget);
},
);
testWidgets('le module séquence saffiche pour un exercice avec étapes', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
targetReps: 10,
exerciseSteps: [
_step('step-1', 0, 'Dribble main droite', ExerciseStepType.time, 10),
_step('step-2', 1, 'Pompes', ExerciseStepType.reps, 8),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(find.byType(ListView), findsNothing);
expect(find.text('SÉQUENCE'), findsOneWidget);
expect(find.text('Passage 1 / 10'), findsOneWidget);
expect(find.text('ÉTAPE 1 / 2'), findsNothing);
expect(find.text('Dribble main droite'), findsOneWidget);
expect(find.text('00:10'), findsOneWidget);
});
testWidgets('un exercice dense avec étapes et scores reste scrollable', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 3,
targetReps: 20,
scoreEnabled: true,
scoreUnit: 'paniers',
exerciseSteps: [
_step(
'step-1',
0,
'Tirs après dribble',
ExerciseStepType.time,
10,
hasScore: true,
scoreLabel: 'Paniers',
scoreUnit: 'paniers',
defaultTargetScore: 8,
),
_step('step-2', 1, 'Replacement défensif', ExerciseStepType.reps, 6),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('ÉTAPE 1 / 2'), findsNothing);
expect(find.text('Tirs après dribble'), findsOneWidget);
expect(find.text('00:10'), findsOneWidget);
expect(find.text('Passer létape'), findsOneWidget);
expect(find.byType(SingleChildScrollView), findsWidgets);
});
testWidgets('répétitions et chrono score détape tiennent sans overflow', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
targetReps: 12,
exerciseSteps: [
_step(
'step-1',
0,
'Pompes tempo',
ExerciseStepType.reps,
12,
hasScore: true,
scoreInputMode: ScoreInputMode.stopwatch,
),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('RÉPÉTITIONS'), findsOneWidget);
expect(find.text('CHRONO SCORE'), findsOneWidget);
expect(find.byTooltip('Lancer'), findsOneWidget);
expect(find.byTooltip('Réinitialiser'), findsOneWidget);
expect(
find.widgetWithText(OutlinedButton, 'Passer létape'),
findsOneWidget,
);
expect(find.widgetWithText(FilledButton, 'Étape suivante'), findsOneWidget);
expect(find.widgetWithText(TextButton, 'Réinitialiser'), findsNothing);
});
testWidgets('chrono décompte et score libre tiennent avec stepper compact', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
timeEnabled: false,
repsEnabled: false,
scoreEnabled: false,
exerciseSteps: [
_step(
'step-1',
0,
'Maintien défensif',
ExerciseStepType.time,
10,
hasScore: true,
scoreInputMode: ScoreInputMode.manual,
scoreLabel: 'Paniers',
scoreUnit: 'points',
defaultTargetScore: 5,
),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('CHRONO'), findsOneWidget);
expect(find.text('SCORE'), findsOneWidget);
expect(find.text('00:10'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
expect(find.byTooltip('Diminuer'), findsOneWidget);
expect(find.byTooltip('Augmenter'), findsOneWidget);
expect(find.widgetWithText(TextField, 'Score de létape'), findsNothing);
expect(
find.widgetWithText(OutlinedButton, 'Passer létape'),
findsOneWidget,
);
expect(find.widgetWithText(FilledButton, 'Étape suivante'), findsNothing);
});
testWidgets('lécran actif avec séquence longue reste sans scroll', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 900));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 4,
workoutName: 'Séance tirs du soir très complète',
programName:
'Programme finition longue avec nom volontairement interminable',
exerciseName: 'Dribble combo finition contact côté faible très long',
targetReps: 10,
scoreUnit: 'paniers',
exerciseSteps: [
_step(
'step-1',
0,
'Dribble main droite enchaîné avec finition longue',
ExerciseStepType.time,
90,
),
_step('step-2', 1, 'Appuis explosifs', ExerciseStepType.reps, 8),
_step('step-3', 2, 'Tir après contact', ExerciseStepType.reps, 6),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.byType(ListView), findsNothing);
expect(find.text('SÉRIE'), findsOneWidget);
expect(find.text('Passage 1 / 10'), findsOneWidget);
expect(find.text('Objectif : 1:30'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Lancer'), findsOneWidget);
expect(find.textContaining('Passages réalisés'), findsNothing);
});
testWidgets('une étape répétitions peut être validée', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
targetReps: 1,
exerciseSteps: [_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 8)],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(find.text('8'), findsWidgets);
expect(find.byType(SingleChildScrollView), findsNothing);
expect(
find.widgetWithText(OutlinedButton, 'Passer létape'),
findsOneWidget,
);
final nextStepButton = find.widgetWithText(FilledButton, 'Étape suivante');
final skipStepButton = find.widgetWithText(
OutlinedButton,
'Passer létape',
);
expect(nextStepButton, findsOneWidget);
expect(
tester.getTopLeft(nextStepButton).dy,
lessThan(tester.getTopLeft(skipStepButton).dy),
);
expect(
tester.getSize(nextStepButton).width,
greaterThan(tester.getSize(skipStepButton).width),
);
await tester.tap(find.text('Étape suivante'));
await tester.pump();
expect(repository.stepResults.single.actualReps, 8);
expect(find.text('Séquence terminée'), findsWidgets);
});
testWidgets('une étape temps affiche un décompte recalculé', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final audio = _FakeStepAudioCuePlayer();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
targetReps: 1,
exerciseSteps: [
_step('step-1', 0, 'Gainage', ExerciseStepType.time, 5),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: audio,
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(find.text('00:05'), findsOneWidget);
expect(
find.byWidgetPredicate(
(widget) => widget is FittedBox && widget.fit == BoxFit.contain,
),
findsWidgets,
);
await tester.tap(find.widgetWithText(FilledButton, 'Lancer'));
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2);
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('00:03'), findsOneWidget);
expect(audio.shortBeeps, 1);
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 3);
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('00:02'), findsOneWidget);
expect(audio.shortBeeps, 2);
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 4);
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('00:01'), findsOneWidget);
expect(audio.shortBeeps, 3);
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 5, 200);
await tester.pump(const Duration(milliseconds: 200));
await tester.pump();
expect(audio.longBeeps, 1);
});
testWidgets(
'terminer sans démarrer la première étape temps affiche une confirmation',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final audio = _FakeStepAudioCuePlayer();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
timeEnabled: false,
scoreEnabled: false,
targetReps: 1,
exerciseSteps: [
_step('step-1', 0, 'Gainage', ExerciseStepType.time, 5),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: audio,
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
await tester.tap(find.text('Terminer la série'));
await tester.pumpAndSettle();
expect(find.text('Chrono non lancé'), findsOneWidget);
expect(
find.text('Tu nas pas démarré le chrono de cette série.'),
findsOneWidget,
);
expect(find.widgetWithText(TextButton, 'Démarrer'), findsOneWidget);
expect(find.text('Terminer sans chrono'), findsOneWidget);
expect(repository.results, isEmpty);
},
);
testWidgets('deux étapes temps consécutives senchaînent automatiquement', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final audio = _FakeStepAudioCuePlayer();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
targetReps: 1,
exerciseSteps: [
_step('step-1', 0, 'Gauche', ExerciseStepType.time, 1),
_step('step-2', 1, 'Droite', ExerciseStepType.time, 2),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: audio,
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Lancer'));
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
await tester.pump(const Duration(milliseconds: 200));
await tester.pump();
expect(repository.stepResults.single.stepIndex, 0);
expect(repository.stepResults.single.status, SetResultStatus.completed);
expect(find.text('ÉTAPE 2 / 2'), findsNothing);
expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget);
expect(
repository.stepProgressStates.single.status,
ActiveExerciseStepProgressStatus.runningTimer,
);
expect(audio.longBeeps, 1);
});
testWidgets(
'chrono suivant prêt attend un démarrage manuel quand lenchaînement est désactivé',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
targetReps: 1,
autoStartNextTimedStep: false,
exerciseSteps: [
_step('step-1', 0, 'Gauche', ExerciseStepType.time, 1),
_step('step-2', 1, 'Droite', ExerciseStepType.time, 2),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Lancer'));
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
await tester.pump(const Duration(milliseconds: 200));
await tester.pump();
expect(repository.stepResults.single.stepIndex, 0);
expect(find.text('ÉTAPE 2 / 2'), findsNothing);
expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget);
expect(find.text('Chrono prêt'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Lancer'), findsOneWidget);
expect(
repository.stepProgressStates.single.status,
ActiveExerciseStepProgressStatus.stoppedTimer,
);
expect(repository.stepProgressStates.single.startedAt, isNull);
await tester.tap(find.widgetWithText(FilledButton, 'Lancer'));
await tester.pump();
expect(
repository.stepProgressStates.single.status,
ActiveExerciseStepProgressStatus.runningTimer,
);
expect(repository.stepProgressStates.single.startedAt, clock.value);
},
);
testWidgets('la séquence passe au deuxième passage si répétitions > 1', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
targetReps: 2,
exerciseSteps: [_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4)],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
await tester.tap(find.text('Étape suivante'));
await tester.pump();
expect(find.text('Passage 2 / 2'), findsOneWidget);
expect(find.textContaining('Passages réalisés'), findsNothing);
});
testWidgets('terminer la série reste utilisable avant la fin de séquence', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 1800));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
restSeconds: 0,
targetReps: 2,
exerciseSteps: [_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4)],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
await tester.ensureVisible(find.text('Terminer la série'));
await tester.tap(find.text('Terminer la série'));
await tester.pumpAndSettle();
expect(repository.results.single.setIndex, 0);
expect(repository.session?.currentSetIndex, 1);
});
testWidgets('les trois niveaux de skip de séquence fonctionnent', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 1800));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
targetReps: 2,
exerciseSteps: [
_step('step-1', 0, 'Gauche', ExerciseStepType.reps, 3),
_step('step-2', 1, 'Droite', ExerciseStepType.reps, 3),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
await tester.tap(find.text('Passer létape'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Passer létape'));
await tester.pumpAndSettle();
expect(repository.stepResults.single.stepIndex, 0);
expect(repository.stepResults.single.status, SetResultStatus.skipped);
await tester.tap(find.byTooltip('Plus dactions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Passer ce passage'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Passer ce passage'));
await tester.pumpAndSettle();
expect(find.text('Passage 2 / 2'), findsOneWidget);
await tester.tap(find.byTooltip('Plus dactions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Passer la séquence'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Passer la séquence'));
await tester.pumpAndSettle();
expect(find.text('Séquence terminée'), findsWidgets);
});
}
Finder _textFieldWithLabel(String label) {
return find.byWidgetPredicate(
(widget) => widget is TextField && widget.decoration?.labelText == label,
description: 'TextField with label "$label"',
);
}
Future<File> _createTestImageFile() async {
final directory = await Directory.systemTemp.createTemp('gametime-media-');
final file = File('${directory.path}/image.png');
const transparentPng =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'
'AAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
await file.writeAsBytes(base64Decode(transparentPng));
return file;
}
ActiveWorkoutSessionUseCases _activeUseCases(
_FakeActiveSessionRepository repository,
_FakeClock clock,
) {
return ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
}
CloseWorkoutSessionUseCase _closeUseCase(
_FakeActiveSessionRepository repository,
_FakeClock clock,
) {
return CloseWorkoutSessionUseCase(
sessionRepository: repository,
historyRepository: _FakeWorkoutHistoryRepository(),
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
}
ActiveExerciseStepUseCases _stepUseCases(
_FakeActiveSessionRepository repository,
_FakeClock clock,
) {
return ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
}
WorkoutHistoryUseCases _historyUseCases(_FakeClock clock) {
return WorkoutHistoryUseCases(
repository: _FakeWorkoutHistoryRepository(),
clock: clock,
);
}
WorkoutTemplateUseCases _workoutTemplateUseCases() {
return WorkoutTemplateUseCases(
templateRepository: _FakeWorkoutTemplateRepository(),
programRepository: _FakeProgramRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
}
String _sessionSnapshot({
int setsCount = 3,
int restSeconds = 0,
bool timeEnabled = true,
bool repsEnabled = true,
bool scoreEnabled = true,
int targetTimeSeconds = 45,
int targetReps = 10,
double targetScore = 80,
int? targetScoreTimeMs,
String scoreUnit = 'kg',
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
List<String> imageMediaIds = const [],
List<Map<String, Object?>> exerciseSteps = const [],
bool autoStartNextTimedStep = true,
String? videoMediaId,
String? sourceExerciseId,
String workoutName = 'Séance jambes',
String programName = 'Programme jambes',
String exerciseName = 'Squat',
}) {
return jsonEncode({
'name': workoutName,
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': programName,
'programSnapshotJson': jsonEncode({
'exercises': [
{
'id': 'exercise-1',
'sourceExerciseId': sourceExerciseId,
'exerciseNameSnapshot': exerciseName,
'setsCount': setsCount,
'timeEnabled': timeEnabled,
'repsEnabled': repsEnabled,
'scoreEnabled': scoreEnabled,
'targetTimeSeconds': targetTimeSeconds,
'targetReps': targetReps,
'targetScore': targetScore,
'targetScoreTimeMs': targetScoreTimeMs,
'scoreInputModeSnapshot': scoreInputMode.name,
'scoreLabelSnapshot': 'Score',
'scoreUnitSnapshot': scoreUnit,
'restSecondsOverride': restSeconds,
'exerciseImageMediaIdsSnapshot': imageMediaIds,
'imageMediaIdsSnapshot': imageMediaIds,
'exerciseVideoMediaIdSnapshot': videoMediaId,
'videoMediaIdSnapshot': videoMediaId,
'exerciseStepsSnapshot': exerciseSteps,
'autoStartNextTimedStepSnapshot': autoStartNextTimedStep,
},
],
}),
},
],
'overrides': const [],
});
}
String _emptySessionSnapshot() {
return jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
});
}
Map<String, Object?> _step(
String id,
int position,
String name,
ExerciseStepType type,
int defaultTargetValue, {
bool hasScore = false,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
String? scoreLabel,
String? scoreUnit,
double? defaultTargetScore,
int? defaultTargetScoreTimeMs,
}) {
return {
'id': id,
'position': position,
'name': name,
'type': type.name,
'defaultTargetValue': defaultTargetValue,
'hasScore': hasScore,
'scoreInputMode': scoreInputMode.name,
'scoreLabel': scoreLabel,
'scoreUnit': scoreUnit,
'defaultTargetScore': defaultTargetScore,
'defaultTargetScoreTimeMs': defaultTargetScoreTimeMs,
};
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
createdAt: DateTime.utc(2026, 7, 17),
updatedAt: DateTime.utc(2026, 7, 17),
originDeviceId: 'device-1',
);
}
final class _FakeClock implements Clock {
_FakeClock(this.value);
DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}
final class _FakeStepAudioCuePlayer implements ExerciseStepAudioCuePlayer {
var shortBeeps = 0;
var longBeeps = 0;
var disposed = false;
@override
Future<void> playShortCountdownBeep() async {
shortBeeps += 1;
}
@override
Future<void> playLongCompletionBeep() async {
longBeeps += 1;
}
@override
Future<void> dispose() async {
disposed = true;
}
}
final class _FakeExercisePerformanceReferenceRepository
implements ExercisePerformanceReferenceRepository {
const _FakeExercisePerformanceReferenceRepository({this.last, this.record});
final WorkoutHistorySetPerformance? last;
final WorkoutHistoryMetricPerformance? record;
@override
Future<bool> hasAnyCompletedHistoryForExercise(String exerciseId) async {
return true;
}
@override
Future<WorkoutHistorySetPerformance?> findLatestSetPerformance({
required String exerciseId,
required ActivePerformanceMeasures activeMeasures,
required int currentSetIndex,
}) async {
return last;
}
@override
Future<WorkoutHistoryMetricPerformance?> findBestMetricPerformance({
required String exerciseId,
required PerformanceMetric metric,
required ScoreInputMode scoreInputMode,
}) async {
return record;
}
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session;
final results = <ActiveSetResult>[];
final restStates = <ActiveRestState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
final manualScoreStates = <ActiveManualScoreState>[];
final setTimerStates = <ActiveSetTimerState>[];
final stepProgressStates = <ActiveExerciseStepProgressState>[];
final stepResults = <ActiveExerciseStepResult>[];
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<ActiveRestState?> findRestStateById(String id) async {
for (final restState in restStates) {
if (restState.metadata.id == id) {
return restState;
}
}
return null;
}
@override
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
for (final state in scoreStopwatchStates) {
if (state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null) {
return state;
}
}
return null;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
for (final state in manualScoreStates) {
if (state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null) {
return state;
}
}
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
for (final state in setTimerStates) {
if (state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null) {
return state;
}
}
return null;
}
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
for (final state in stepProgressStates) {
if (state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null) {
return state;
}
}
return null;
}
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates
.where((restState) => restState.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
return scoreStopwatchStates
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results;
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return stepProgressStates
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return stepResults
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<void> save(ActiveWorkoutSession session) async {
this.session = session;
}
@override
Future<void> saveRestState(ActiveRestState restState) async {
final index = restStates.indexWhere(
(saved) => saved.metadata.id == restState.metadata.id,
);
if (index == -1) {
restStates.add(restState);
} else {
restStates[index] = restState;
}
}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
final index = scoreStopwatchStates.indexWhere(
(saved) => saved.metadata.id == state.metadata.id,
);
if (index == -1) {
scoreStopwatchStates.add(state);
} else {
scoreStopwatchStates[index] = state;
}
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
final index = manualScoreStates.indexWhere(
(saved) => saved.metadata.id == state.metadata.id,
);
if (index == -1) {
manualScoreStates.add(state);
} else {
manualScoreStates[index] = state;
}
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
final index = setTimerStates.indexWhere(
(saved) =>
saved.activeWorkoutSessionId == state.activeWorkoutSessionId &&
saved.programIndex == state.programIndex &&
saved.exerciseIndex == state.exerciseIndex &&
saved.setIndex == state.setIndex,
);
if (index == -1) {
setTimerStates.add(state);
} else {
setTimerStates[index] = state;
}
}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {
final index = stepProgressStates.indexWhere(
(saved) =>
saved.activeWorkoutSessionId == state.activeWorkoutSessionId &&
saved.programIndex == state.programIndex &&
saved.exerciseIndex == state.exerciseIndex &&
saved.setIndex == state.setIndex,
);
if (index == -1) {
stepProgressStates.add(state);
} else {
stepProgressStates[index] = state;
}
}
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
final index = stepResults.indexWhere(
(saved) =>
saved.activeWorkoutSessionId == result.activeWorkoutSessionId &&
saved.programIndex == result.programIndex &&
saved.exerciseIndex == result.exerciseIndex &&
saved.setIndex == result.setIndex &&
saved.passageIndex == result.passageIndex &&
saved.stepIndex == result.stepIndex,
);
if (index == -1) {
stepResults.add(result);
} else {
stepResults[index] = result;
}
}
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
scoreStopwatchStates.removeWhere(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex,
);
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.removeWhere(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex,
);
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
final index = results.indexWhere(
(saved) =>
saved.activeWorkoutSessionId == result.activeWorkoutSessionId &&
saved.programIndex == result.programIndex &&
saved.exerciseIndex == result.exerciseIndex &&
saved.setIndex == result.setIndex,
);
if (index == -1) {
results.add(result);
} else {
results[index] = result;
}
}
}
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override
Future<WorkoutTemplate?> findById(String id) async => null;
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
@override
Future<void> replaceComposition(
WorkoutTemplate template,
DateTime deletedAt,
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
@override
Future<void> saveAll(List<WorkoutTemplate> templates) async {}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> delete(String id, DateTime deletedAt) async {}
@override
Future<WorkoutHistory?> findById(String id) async => null;
@override
Future<List<WorkoutHistory>> listActive() async => const [];
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
int? minHeartRateBpm,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
double? totalDistanceMeters,
double? totalCaloriesKcal,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
}
final class _FakeProgramRepository implements ProgramRepository {
@override
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
@override
Future<Program?> findById(String id) async => null;
@override
Future<List<Program>> listActive() async => const [];
@override
Future<void> save(Program program) async {}
@override
Future<void> saveExercise(ProgramExercise exercise) async {}
}