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('la liste expose le lien secondaire Progression', (tester) async { var opened = false; await tester.pumpWidget( MaterialApp( home: HistoryListScreen( historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), workoutTemplateUseCases: _workoutTemplateUseCases(), activeUseCases: _activeUseCases(), closeUseCase: _closeUseCase(), onOpenProgression: () => opened = true, ), ), ); await tester.tap(find.text('Progression')); await tester.pump(); expect(opened, isTrue); }); testWidgets('la liste groupe les séances par période', (tester) async { final groups = groupHistoriesByPeriod([ _history(id: 'today', startedAt: DateTime.utc(2026, 7, 17, 9)), _history(id: 'week', startedAt: DateTime.utc(2026, 7, 15, 9)), _history(id: 'old', startedAt: DateTime.utc(2026, 7, 1, 9)), ], DateTime.utc(2026, 7, 17, 12)); await tester.pumpWidget( MaterialApp( home: Scaffold( body: Column(children: [for (final key in groups.keys) Text(key)]), ), ), ); expect(find.text("Aujourd'hui"), findsOneWidget); expect(find.text('Cette semaine'), findsOneWidget); expect(find.text('Plus ancien'), findsOneWidget); }); testWidgets('le détail affiche temps répétitions et score avec unité', ( tester, ) async { await tester.pumpWidget( MaterialApp( home: HistoryDetailScreen( history: _history(id: 'history-1'), historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), workoutTemplateUseCases: _workoutTemplateUseCases(), activeUseCases: _activeUseCases(), closeUseCase: _closeUseCase(), ), ), ); expect(find.text('Programme jambes'), findsOneWidget); expect(find.text('Squat'), findsOneWidget); expect( find.text('Temps 00:45 · 10 répétitions · Score 80 kg'), findsOneWidget, ); }); testWidgets('le détail affiche les stats montre quand elles existent', ( tester, ) async { await tester.pumpWidget( MaterialApp( home: HistoryDetailScreen( history: _history( id: 'history-1', withTelemetry: true, minHeartRateBpm: 88, averageHeartRateBpm: 126.4, maxHeartRateBpm: 171, totalDistanceMeters: 1234, totalCaloriesKcal: 83, ), historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), workoutTemplateUseCases: _workoutTemplateUseCases(), activeUseCases: _activeUseCases(), telemetryUseCases: _telemetryUseCases(), closeUseCase: _closeUseCase(), ), ), ); await tester.pumpAndSettle(); expect(find.text('Stats montre'), findsOneWidget); expect(find.text('Fréquence cardiaque'), findsOneWidget); expect(find.text('Min'), findsAtLeastNWidgets(1)); expect(find.text('Moyenne'), findsOneWidget); expect(find.text('Max'), findsAtLeastNWidgets(1)); expect(find.text('Distance'), findsAtLeastNWidgets(1)); expect(find.text('Calories'), findsAtLeastNWidgets(1)); expect(find.text('Étape'), findsOneWidget); expect(find.text('Série'), findsAtLeastNWidgets(1)); expect(find.text('Exercice'), findsAtLeastNWidgets(1)); expect(find.text('Séance'), findsAtLeastNWidgets(1)); expect(find.text('FC'), findsOneWidget); expect(find.text('Période'), findsOneWidget); expect(find.text('30:00'), findsAtLeastNWidgets(1)); expect(find.text('Fréquence cardiaque · Séance · 03:00'), findsOneWidget); expect( find.byKey(const ValueKey('history-watch-stats-graph')), findsOneWidget, ); expect(find.text('88 bpm', findRichText: true), findsOneWidget); expect(find.text('126 bpm', findRichText: true), findsOneWidget); expect(find.text('171 bpm', findRichText: true), findsOneWidget); expect(find.text('1.23 km', findRichText: true), findsOneWidget); expect(find.text('83 kcal', findRichText: true), findsOneWidget); }); testWidgets('le détail affiche le score chrono comme temps réalisé', ( tester, ) async { await tester.pumpWidget( MaterialApp( home: HistoryDetailScreen( history: _history(id: 'history-1', stopwatchScore: true), historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), workoutTemplateUseCases: _workoutTemplateUseCases(), activeUseCases: _activeUseCases(), closeUseCase: _closeUseCase(), ), ), ); expect( find.text( 'Temps 00:45 · 10 répétitions · ' 'Temps réalisé : 00:38.7 / objectif 00:45', ), findsOneWidget, ); expect(find.textContaining('Score 38.7'), findsNothing); }); testWidgets('le détail affiche les résultats d’étapes par passage', ( tester, ) async { await tester.pumpWidget( MaterialApp( home: HistoryDetailScreen( history: _history(id: 'history-1', withStepResults: true), historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), workoutTemplateUseCases: _workoutTemplateUseCases(), activeUseCases: _activeUseCases(), closeUseCase: _closeUseCase(), ), ), ); expect(find.text('Étapes réalisées'), findsOneWidget); expect(find.text('Passage 1'), findsOneWidget); expect( find.text( 'Étape 1 · Dribble gauche · Temps 00:10 · Réussites : 4 paniers', ), findsOneWidget, ); expect(find.text('Étape 2 · Dribble droite'), findsOneWidget); expect(find.text('Passée'), findsOneWidget); }); testWidgets('la suppression demande une confirmation', (tester) async { final repository = _FakeWorkoutHistoryRepository(); await tester.pumpWidget( MaterialApp( home: HistoryDetailScreen( history: _history(id: 'history-1'), historyUseCases: _historyUseCases(repository), workoutTemplateUseCases: _workoutTemplateUseCases(), activeUseCases: _activeUseCases(), closeUseCase: _closeUseCase(), ), ), ); await tester.tap(find.text("Supprimer de l'historique")); await tester.pumpAndSettle(); expect(repository.deletedIds, isEmpty); expect(find.text("Supprimer de l'historique ?"), findsOneWidget); await tester.tap(find.text('Annuler')); await tester.pumpAndSettle(); expect(repository.deletedIds, isEmpty); await tester.tap(find.text("Supprimer de l'historique")); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Supprimer')); await tester.pumpAndSettle(); expect(repository.deletedIds, ['history-1']); }); testWidgets('relancer un historique vide affiche une erreur sans navigation', ( tester, ) async { final activeRepository = _FakeActiveSessionRepository(); await tester.pumpWidget( MaterialApp( home: HistoryDetailScreen( history: _history(id: 'history-empty', emptySnapshot: true), historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), workoutTemplateUseCases: _workoutTemplateUseCases(), activeUseCases: _activeUseCases(activeRepository), closeUseCase: _closeUseCase(), ), ), ); await tester.tap(find.text('Relancer cette séance')); await tester.pump(); expect( find.text( 'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.', ), findsOneWidget, ); expect(find.byType(WorkoutExecutionScreen), findsNothing); }); testWidgets('une session legacy invalide ne bloque pas une relance valide', ( tester, ) async { final activeRepository = _FakeActiveSessionRepository() ..session = ActiveWorkoutSession( metadata: _metadata('legacy-session'), sourceWorkoutTemplateId: 'template-legacy', status: ActiveWorkoutStatus.paused, startedAt: DateTime.utc(2026, 7, 17, 9), lastPersistedAt: DateTime.utc(2026, 7, 17, 9), elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: _emptyResolvedSnapshot(), ); await tester.pumpWidget( MaterialApp( home: HistoryDetailScreen( history: _history(id: 'history-1'), historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), workoutTemplateUseCases: _workoutTemplateUseCases(), activeUseCases: _activeUseCases(activeRepository), closeUseCase: _closeUseCase(), ), ), ); await tester.tap(find.text('Relancer cette séance')); await tester.pumpAndSettle(); expect(find.textContaining('Une séance est déjà en cours.'), findsNothing); expect(find.byType(WorkoutExecutionScreen), findsOneWidget); expect(activeRepository.session?.sourceWorkoutTemplateId, isNull); }); } WorkoutHistory _history({ required String id, DateTime? startedAt, bool stopwatchScore = false, bool withStepResults = false, bool withTelemetry = false, bool emptySnapshot = false, int? minHeartRateBpm, double? averageHeartRateBpm, int? maxHeartRateBpm, double? totalDistanceMeters, double? totalCaloriesKcal, }) { final start = startedAt ?? DateTime.utc(2026, 7, 17, 10); return WorkoutHistory( metadata: _metadata(id), sourceWorkoutTemplateId: 'template-1', sourceActiveWorkoutSessionId: 'session-1', nameSnapshot: 'Séance jambes', startedAt: start, endedAt: start.add(const Duration(minutes: 30)), totalActiveMs: 1800000, minHeartRateBpm: minHeartRateBpm, averageHeartRateBpm: averageHeartRateBpm, maxHeartRateBpm: maxHeartRateBpm, totalDistanceMeters: totalDistanceMeters, totalCaloriesKcal: totalCaloriesKcal, completed: true, historySnapshotJson: jsonEncode({ 'sessionId': 'session-1', 'resolvedTemplateSnapshotJson': emptySnapshot ? _emptyResolvedSnapshot() : _resolvedSnapshot( stopwatchScore: stopwatchScore, withSteps: withStepResults, ), 'results': [ { 'programSnapshotId': 'program-snapshot-1', 'exerciseSnapshotId': 'exercise-snapshot-1', 'programIndex': 0, 'exerciseIndex': 0, 'setIndex': 0, 'actualTimeMs': 45000, 'actualReps': 10, if (stopwatchScore) 'actualScoreTimeMs': 38700, if (!stopwatchScore) 'actualScore': 80, 'scoreLabelSnapshot': 'Charge', 'scoreInputModeSnapshot': stopwatchScore ? ScoreInputMode.stopwatch.name : ScoreInputMode.manual.name, if (!stopwatchScore) 'scoreUnitSnapshot': 'kg', if (stopwatchScore) 'targetScoreTimeMsSnapshot': 45000, }, ], if (withTelemetry) 'telemetrySamples': [ { 'id': 'telemetry-1', 'sessionId': 'session-1', 'capturedAt': start.toUtc().toIso8601String(), 'programIndex': 0, 'exerciseIndex': 0, 'setIndex': 0, 'stepIndex': 0, 'heartRateBpm': 88, 'distanceMeters': 0, 'caloriesKcal': 0, }, { 'id': 'telemetry-2', 'sessionId': 'session-1', 'capturedAt': start .add(const Duration(minutes: 1, seconds: 30)) .toUtc() .toIso8601String(), 'programIndex': 0, 'exerciseIndex': 0, 'setIndex': 0, 'stepIndex': 0, 'heartRateBpm': 126, 'distanceMeters': 600, 'caloriesKcal': 40, }, { 'id': 'telemetry-3', 'sessionId': 'session-1', 'capturedAt': start .add(const Duration(minutes: 3)) .toUtc() .toIso8601String(), 'programIndex': 0, 'exerciseIndex': 0, 'setIndex': 0, 'stepIndex': 0, 'heartRateBpm': 171, 'distanceMeters': 1234, 'caloriesKcal': 83, }, ], }), stepResults: withStepResults ? [ WorkoutHistoryStepResult( metadata: _metadata('step-result-1'), workoutHistoryId: id, programSnapshotId: 'program-snapshot-1', exerciseSnapshotId: 'exercise-snapshot-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, passageIndex: 0, stepIndex: 0, stepSnapshotId: 'step-1', stepNameSnapshot: 'Dribble gauche', stepTypeSnapshot: ExerciseStepType.time, targetValueSnapshot: 10, hasScoreSnapshot: true, scoreInputModeSnapshot: ScoreInputMode.manual, scoreLabelSnapshot: 'Réussites', scoreUnitSnapshot: 'paniers', targetScoreSnapshot: 3, status: SetResultStatus.completed, actualTimeMs: 10000, actualScore: 4, ), WorkoutHistoryStepResult( metadata: _metadata('step-result-2'), workoutHistoryId: id, programSnapshotId: 'program-snapshot-1', exerciseSnapshotId: 'exercise-snapshot-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, passageIndex: 0, stepIndex: 1, stepSnapshotId: 'step-2', stepNameSnapshot: 'Dribble droite', stepTypeSnapshot: ExerciseStepType.reps, targetValueSnapshot: 8, hasScoreSnapshot: false, status: SetResultStatus.skipped, ), ] : const [], ); } String _resolvedSnapshot({ bool stopwatchScore = false, bool withSteps = false, }) { return jsonEncode({ 'name': 'Séance jambes', 'programs': [ { 'id': 'program-snapshot-1', 'programNameSnapshot': 'Programme jambes', 'programSnapshotJson': jsonEncode({ 'exercises': [ { 'id': 'exercise-snapshot-1', 'exerciseNameSnapshot': 'Squat', 'setsCount': 1, 'timeEnabled': true, 'repsEnabled': true, 'scoreEnabled': true, 'targetTimeSeconds': 45, 'targetReps': 10, if (stopwatchScore) 'targetScoreTimeMs': 45000, if (!stopwatchScore) 'targetScore': 80, 'scoreInputModeSnapshot': stopwatchScore ? ScoreInputMode.stopwatch.name : ScoreInputMode.manual.name, 'scoreLabelSnapshot': 'Charge', if (!stopwatchScore) 'scoreUnitSnapshot': 'kg', 'restSecondsOverride': 0, if (withSteps) 'exerciseStepsSnapshot': [ { 'id': 'step-1', 'position': 0, 'name': 'Dribble gauche', 'type': ExerciseStepType.time.name, 'defaultTargetValue': 10, 'hasScore': true, 'scoreInputMode': ScoreInputMode.manual.name, 'scoreLabel': 'Réussites', 'scoreUnit': 'paniers', 'defaultTargetScore': 3, }, { 'id': 'step-2', 'position': 1, 'name': 'Dribble droite', 'type': ExerciseStepType.reps.name, 'defaultTargetValue': 8, 'hasScore': false, }, ], }, ], }), }, ], 'overrides': const [], }); } String _emptyResolvedSnapshot() { return jsonEncode({ 'name': 'Séance vide', 'programs': [ { 'id': 'program-snapshot-empty', 'programNameSnapshot': 'Programme vide', 'programSnapshotJson': jsonEncode({'exercises': const []}), }, ], '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', ); } WorkoutHistoryUseCases _historyUseCases( _FakeWorkoutHistoryRepository repository, ) { return WorkoutHistoryUseCases( repository: repository, clock: _FakeClock(DateTime.utc(2026, 7, 17)), ); } WorkoutTemplateUseCases _workoutTemplateUseCases() { return WorkoutTemplateUseCases( templateRepository: _FakeWorkoutTemplateRepository(), programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17)), ids: _FakeIds(), originDeviceId: 'device-1', ); } ActiveWorkoutSessionUseCases _activeUseCases([ _FakeActiveSessionRepository? repository, ]) { return ActiveWorkoutSessionUseCases( sessionRepository: repository ?? _FakeActiveSessionRepository(), templateRepository: _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17)), ids: _FakeIds(), originDeviceId: 'device-1', ); } CloseWorkoutSessionUseCase _closeUseCase() { return CloseWorkoutSessionUseCase( sessionRepository: _FakeActiveSessionRepository(), historyRepository: _FakeWorkoutHistoryRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17)), ids: _FakeIds(), originDeviceId: 'device-1', ); } WorkoutTelemetryUseCases _telemetryUseCases([ _FakeWorkoutTelemetryRepository? repository, ]) { return WorkoutTelemetryUseCases( repository: repository ?? _FakeWorkoutTelemetryRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17)), ids: _FakeIds(), ); } 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 _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { final deletedIds = []; @override Future delete(String id, DateTime deletedAt) async { deletedIds.add(id); } @override Future findById(String id) async => null; @override Future> listActive() async => const []; @override Future save(WorkoutHistory history) async {} @override Future patchHeartRateSummary({ required String historyId, int? minHeartRateBpm, required double averageHeartRateBpm, required int maxHeartRateBpm, double? totalDistanceMeters, double? totalCaloriesKcal, required DateTime patchedAt, }) async {} @override Future saveSetResult(WorkoutHistorySetResult result) async {} @override Future saveStepResult(WorkoutHistoryStepResult result) async {} } 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 saveAll(List templates) async {} @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @override Future saveProgram(WorkoutTemplateProgram program) async {} } final class _FakeProgramRepository implements ProgramRepository { @override Future findById(String id) async => null; @override Future> listActive() async => const []; @override Future replaceExercises(Program program, DateTime deletedAt) async {} @override Future save(Program program) async {} @override Future saveExercise(ProgramExercise exercise) async {} } final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; @override Future findById(String id) async { return session?.metadata.id == id ? session : null; } @override Future findOpen() async => session; @override Future findRestStateById(String id) async => null; @override Future findScoreStopwatchState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async => null; @override Future findManualScoreState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async => null; @override Future findSetTimerState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async => null; @override Future findExerciseStepProgressState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async => null; @override Future> listRestStates(String sessionId) async { return const []; } @override Future> listScoreStopwatchStates( String sessionId, ) async { return const []; } @override Future> listManualScoreStates( String sessionId, ) async { return const []; } @override Future> listSetTimerStates(String sessionId) async { return const []; } @override Future> listSetResults(String sessionId) async { return const []; } @override Future> listExerciseStepProgressStates( String sessionId, ) async { return const []; } @override Future> listExerciseStepResults( String sessionId, ) async { return const []; } @override Future save(ActiveWorkoutSession session) async { this.session = session; } @override Future saveRestState(ActiveRestState restState) async {} @override Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async {} @override Future saveManualScoreState(ActiveManualScoreState state) async {} @override Future saveSetTimerState(ActiveSetTimerState state) async {} @override Future saveExerciseStepProgressState( ActiveExerciseStepProgressState state, ) async {} @override Future saveExerciseStepResult(ActiveExerciseStepResult result) async {} @override Future deleteScoreStopwatchState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, required DateTime deletedAt, }) async {} @override Future deleteManualScoreState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, required DateTime deletedAt, }) async {} @override Future saveSetResult(ActiveSetResult result) async {} } final class _FakeWorkoutTelemetryRepository implements WorkoutTelemetryRepository { final samples = []; @override Future saveSample(WorkoutTelemetrySample sample) async { samples.add(sample); return true; } @override Future findWindowState( String sessionId, ) async => null; @override Future saveWindowState(ActiveWorkoutTelemetryWindowState state) async {} @override Future deleteWindowState(String sessionId) async {} @override Future> listSamples(String sessionId) async { return samples .where((sample) => sample.sessionId == sessionId) .toList(growable: false); } @override Future> listSamplesForScope({ required String sessionId, required WorkoutTelemetryAggregateScope scope, int? programIndex, int? exerciseIndex, int? setIndex, int? passageIndex, int? stepIndex, }) async { return samples .where( (sample) => sample.sessionId == sessionId && _fakeTelemetrySampleMatchesScope( sample, scope: scope, programIndex: programIndex, exerciseIndex: exerciseIndex, setIndex: setIndex, passageIndex: passageIndex, stepIndex: stepIndex, ), ) .toList(growable: false); } @override Future replaceAggregatesForSession({ required String sessionId, required List aggregates, }) async {} @override Future> listAggregates( String sessionId, ) async => const []; @override Future findAggregate({ required String sessionId, required WorkoutTelemetryAggregateScope scope, int? programIndex, int? exerciseIndex, int? setIndex, int? passageIndex, int? stepIndex, }) async => null; } bool _fakeTelemetrySampleMatchesScope( WorkoutTelemetrySample sample, { required WorkoutTelemetryAggregateScope scope, int? programIndex, int? exerciseIndex, int? setIndex, int? passageIndex, int? stepIndex, }) { return switch (scope) { WorkoutTelemetryAggregateScope.session => true, WorkoutTelemetryAggregateScope.exercise => sample.programIndex == programIndex && sample.exerciseIndex == exerciseIndex, WorkoutTelemetryAggregateScope.set => sample.programIndex == programIndex && sample.exerciseIndex == exerciseIndex && sample.setIndex == setIndex, WorkoutTelemetryAggregateScope.step => sample.programIndex == programIndex && sample.exerciseIndex == exerciseIndex && sample.setIndex == setIndex && (passageIndex == null || sample.passageIndex == passageIndex) && sample.stepIndex == stepIndex, }; }