import 'dart:convert'; 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'; 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('00:45'), findsOneWidget); }); testWidgets('les mesures cumulées respectent la hiérarchie visuelle', ( tester, ) async { final scoreController = TextEditingController(); addTearDown(scoreController.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, onRepsChanged: (_) {}, ), ), ), ); final timeY = tester.getTopLeft(find.text('Temps')).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 s’affiche 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('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); 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('la mesure temps enregistre une durée réelle de 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, 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.pump(const Duration(seconds: 2)); await tester.tap(find.text('Terminer la série')); await tester.pump(); expect(repository.results.single.actualTimeMs, greaterThan(0)); }); 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( '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 1/1 · Exercice 1/1 · Série 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 1/1 · Exercice 1/1 · Série 2/3'), findsOneWidget, ); }, ); } Finder _textFieldWithLabel(String label) { return find.byWidgetPredicate( (widget) => widget is TextField && widget.decoration?.labelText == label, description: 'TextField with label "$label"', ); } 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', ); } 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}) { return jsonEncode({ 'name': 'Séance jambes', 'programs': [ { 'id': 'template-program-1', 'programNameSnapshot': 'Programme jambes', 'programSnapshotJson': jsonEncode({ 'exercises': [ { 'id': 'exercise-1', 'exerciseNameSnapshot': 'Squat', 'setsCount': setsCount, 'timeEnabled': true, 'repsEnabled': true, 'scoreEnabled': true, 'targetTimeSeconds': 45, 'targetReps': 10, 'targetScore': 80, 'scoreUnitSnapshot': 'kg', 'restSecondsOverride': restSeconds, }, ], }), }, ], 'overrides': const [], }); } 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 _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; final results = []; final restStates = []; @override Future findById(String id) async { return session?.metadata.id == id ? session : null; } @override Future findOpen() async => session; @override Future findRestStateById(String id) async { for (final restState in restStates) { if (restState.metadata.id == id) { return restState; } } return null; } @override Future> listRestStates(String sessionId) async { return restStates .where((restState) => restState.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listSetResults(String sessionId) async { return results; } @override Future save(ActiveWorkoutSession session) async { this.session = session; } @override Future 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 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 findById(String id) async => null; @override Future> listActive() async => const []; @override Future replaceComposition( WorkoutTemplate template, DateTime deletedAt, ) async {} @override Future save(WorkoutTemplate template) async {} @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @override Future saveProgram(WorkoutTemplateProgram program) async {} } final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future delete(String id, DateTime deletedAt) async {} @override Future findById(String id) async => null; @override Future> listActive() async => const []; @override Future save(WorkoutHistory history) async {} @override Future saveSetResult(WorkoutHistorySetResult result) async {} } final class _FakeProgramRepository implements ProgramRepository { @override Future replaceExercises(Program program, DateTime deletedAt) async {} @override Future findById(String id) async => null; @override Future> listActive() async => const []; @override Future save(Program program) async {} @override Future saveExercise(ProgramExercise exercise) async {} }