import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; import 'package:gametime/application/app_bootstrap.dart'; import 'package:gametime/domain/domain.dart'; import 'package:gametime/presentation/home_screen.dart'; void main() { testWidgets('l’entrée Progression est présente après Historique', ( tester, ) async { await tester.pumpWidget( MaterialApp( navigatorObservers: [homeRouteObserver], home: HomeScreen( bootstrap: _FakeBootstrap(_FakeActiveSessionRepository()), ), ), ); await tester.pump(); expect(find.text('Historique'), findsOneWidget); expect(find.text('Progression'), findsOneWidget); expect( find.text('Volume, régularité et évolution par exercice'), findsOneWidget, ); expect( tester.getTopLeft(find.text('Progression')).dy, greaterThan(tester.getTopLeft(find.text('Historique')).dy), ); }); testWidgets('l’entrée Profil est présente sur l’accueil', (tester) async { await tester.pumpWidget( MaterialApp( navigatorObservers: [homeRouteObserver], home: HomeScreen( bootstrap: _FakeBootstrap(_FakeActiveSessionRepository()), ), ), ); await tester.pump(); expect(find.text('Profil'), findsOneWidget); expect(find.text('Compte, synchronisation et partages'), findsOneWidget); }); testWidgets('le bandeau de reprise apparaît au retour sur l’accueil', ( tester, ) async { final navigatorKey = GlobalKey(); final activeRepository = _FakeActiveSessionRepository(); await tester.pumpWidget( MaterialApp( navigatorKey: navigatorKey, navigatorObservers: [homeRouteObserver], home: HomeScreen(bootstrap: _FakeBootstrap(activeRepository)), ), ); await tester.pump(); await tester.pump(); expect(find.textContaining('Séance en cours'), findsNothing); unawaited( navigatorKey.currentState!.push( MaterialPageRoute( builder: (context) => const Scaffold(body: Text('Séance active')), ), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 350)); activeRepository.session = ActiveWorkoutSession( metadata: _metadata('session-1'), sourceWorkoutTemplateId: 'template-1', status: ActiveWorkoutStatus.paused, startedAt: DateTime.utc(2026, 7, 17, 12), pausedAt: DateTime.utc(2026, 7, 17, 12, 5), lastPersistedAt: DateTime.utc(2026, 7, 17, 12, 5), elapsedActiveMs: 300000, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 1, resolvedTemplateSnapshotJson: _sessionSnapshot(), ); navigatorKey.currentState!.pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 350)); await tester.pump(); expect( find.text('Séance en cours — Reprendre · Squat, série 2/3'), findsOneWidget, ); }); testWidgets('le bandeau de reprise masque une séance invalide', ( tester, ) async { final activeRepository = _FakeActiveSessionRepository() ..session = ActiveWorkoutSession( metadata: _metadata('session-1'), sourceWorkoutTemplateId: 'template-1', status: ActiveWorkoutStatus.paused, startedAt: DateTime.utc(2026, 7, 17, 12), pausedAt: DateTime.utc(2026, 7, 17, 12, 5), lastPersistedAt: DateTime.utc(2026, 7, 17, 12, 5), elapsedActiveMs: 300000, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: _emptySessionSnapshot(), ); await tester.pumpWidget( MaterialApp( navigatorObservers: [homeRouteObserver], home: HomeScreen(bootstrap: _FakeBootstrap(activeRepository)), ), ); await tester.pump(); await tester.pump(); expect(find.textContaining('Séance en cours'), findsNothing); expect(tester.takeException(), isNull); }); testWidgets('le bandeau de reprise disparaît au retour après abandon', ( tester, ) async { final navigatorKey = GlobalKey(); final activeRepository = _FakeActiveSessionRepository() ..session = ActiveWorkoutSession( metadata: _metadata('session-1'), sourceWorkoutTemplateId: 'template-1', status: ActiveWorkoutStatus.paused, startedAt: DateTime.utc(2026, 7, 17, 12), pausedAt: DateTime.utc(2026, 7, 17, 12, 5), lastPersistedAt: DateTime.utc(2026, 7, 17, 12, 5), elapsedActiveMs: 300000, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 1, resolvedTemplateSnapshotJson: _sessionSnapshot(), ); await tester.pumpWidget( MaterialApp( navigatorKey: navigatorKey, navigatorObservers: [homeRouteObserver], home: HomeScreen(bootstrap: _FakeBootstrap(activeRepository)), ), ); await tester.pump(); await tester.pump(); expect(find.textContaining('Séance en cours'), findsOneWidget); unawaited( navigatorKey.currentState!.push( MaterialPageRoute( builder: (context) => const Scaffold(body: Text('Séance active')), ), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 350)); activeRepository.session = null; navigatorKey.currentState!.pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 350)); await tester.pump(); expect(find.textContaining('Séance en cours'), findsNothing); }); } final class _FakeBootstrap implements AppDependencies { _FakeBootstrap(_FakeActiveSessionRepository activeRepository) : authUseCases = AuthUseCases( tokenStore: _FakeAuthTokenStore(), accountRepository: _FakeOnlineAccountRepository(), remoteAuthApi: _FakeRemoteAuthApi(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), ), syncUseCases = SyncUseCases( tokenStore: _FakeAuthTokenStore(), remoteSyncApi: _FakeRemoteSyncApi(), metadataRepository: _FakeSyncMetadataRepository(), mappingRepository: _FakeRemoteResourceMappingRepository(), localChanges: _FakeLocalSyncChangeRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), deviceId: 'device-1', ), shareUseCases = ShareUseCases( tokenStore: _FakeAuthTokenStore(), remoteShareApi: _FakeRemoteShareApi(), inboxRepository: _FakeShareInboxRepository(), pendingActionRepository: _FakePendingShareActionRepository(), localChanges: _FakeLocalSyncChangeRepository(), programRepository: _FakeProgramRepository(), templateRepository: _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ), activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases( sessionRepository: activeRepository, templateRepository: _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ), activeExerciseStepUseCases = ActiveExerciseStepUseCases( sessionRepository: activeRepository, clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ), activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases( clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ), workoutTelemetryUseCases = WorkoutTelemetryUseCases( repository: _FakeWorkoutTelemetryRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), ), closeWorkoutSessionUseCase = CloseWorkoutSessionUseCase( sessionRepository: activeRepository, historyRepository: _FakeWorkoutHistoryRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ), exerciseUseCases = ExerciseUseCases( repository: _FakeExerciseRepository(), programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ), mediaUseCases = MediaUseCases( mediaRepository: _FakeMediaAssetRepository(), exerciseRepository: _FakeExerciseRepository(), storage: _FakeMediaStorage(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ), programUseCases = ProgramUseCases( programRepository: _FakeProgramRepository(), exerciseRepository: _FakeExerciseRepository(), templateRepository: _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ), workoutTemplateUseCases = WorkoutTemplateUseCases( templateRepository: _FakeWorkoutTemplateRepository(), programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ), workoutHistoryUseCases = WorkoutHistoryUseCases( repository: _FakeWorkoutHistoryRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ), progressionStatsUseCase = ProgressionStatsUseCase( repository: _FakeProgressionStatsRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ), exercisePerformanceReferenceUseCase = ExercisePerformanceReferenceUseCase( repository: _FakeExercisePerformanceReferenceRepository(), ), dataExportUseCase = DataExportUseCase( repository: _FakeLocalDataBackupRepository(), mediaStore: _FakeLocalBackupMediaStore(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ), dataImportUseCase = DataImportUseCase( repository: _FakeLocalDataBackupRepository(), mediaStore: _FakeLocalBackupMediaStore(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ); @override final AuthUseCases authUseCases; @override final SyncUseCases syncUseCases; @override final ShareUseCases shareUseCases; @override final ExerciseUseCases exerciseUseCases; @override final MediaUseCases mediaUseCases; @override final ProgramUseCases programUseCases; @override final WorkoutTemplateUseCases workoutTemplateUseCases; @override final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; @override final ActiveExerciseStepUseCases activeExerciseStepUseCases; @override final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases; @override final WorkoutTelemetryUseCases workoutTelemetryUseCases; @override final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; @override final WorkoutHistoryUseCases workoutHistoryUseCases; @override final ProgressionStatsUseCase progressionStatsUseCase; @override final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase; @override final DataExportUseCase dataExportUseCase; @override final DataImportUseCase dataImportUseCase; } String _sessionSnapshot() { return jsonEncode({ 'name': 'Séance jambes', 'programs': [ { 'id': 'template-program-1', 'programNameSnapshot': 'Programme jambes', 'programSnapshotJson': jsonEncode({ 'exercises': [ { 'id': 'exercise-1', 'exerciseNameSnapshot': 'Squat', 'setsCount': 3, 'timeEnabled': true, 'repsEnabled': false, 'scoreEnabled': false, 'restSecondsOverride': 0, }, ], }), }, ], 'overrides': const [], }); } String _emptySessionSnapshot() { return jsonEncode({ 'name': 'Séance vide', 'programs': [ { 'id': 'template-program-1', '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', ); } final class _FakeClock implements Clock { const _FakeClock(this.value); final DateTime value; @override DateTime now() => value; } final class _FakeIds implements IdGenerator { var _next = 0; @override String newId() { _next += 1; return 'id-$_next'; } } final class _FakeAuthTokenStore implements AuthTokenStore { @override Future clearToken() async {} @override Future readToken() async => null; @override Future saveToken(String token, DateTime expiresAt) async {} } final class _FakeOnlineAccountRepository implements OnlineAccountRepository { @override Future clearSession() async {} @override Future currentSession() async => null; @override Future saveSession(UserAccountSession session) async {} } final class _FakeRemoteAuthApi implements RemoteAuthApi { @override Future login({ required String email, required String password, }) async { throw const RemoteAuthException(RemoteAuthFailure.network); } @override Future logout(String token) async {} @override Future register({ required String email, required String password, String? displayName, }) async { throw const RemoteAuthException(RemoteAuthFailure.network); } } final class _FakeRemoteSyncApi implements RemoteSyncApi { @override Future pull({ required String? since, required String token, }) async { return const RemoteSyncPullResult(serverCursor: null, items: []); } @override Future push({ required String deviceId, required List items, required String token, }) async { return const RemoteSyncPushResult(serverCursor: null, results: []); } } final class _FakeSyncMetadataRepository implements SyncMetadataRepository { SyncMetadataSnapshot _metadata = const SyncMetadataSnapshot(); @override Future read() async => _metadata; @override Future save(SyncMetadataSnapshot metadata) async { _metadata = metadata; } } final class _FakeRemoteResourceMappingRepository implements RemoteResourceMappingRepository { @override Future find({ required SyncResourceType resourceType, required String clientId, }) async { return null; } @override Future save(RemoteResourceMapping mapping) async {} } final class _FakeLocalSyncChangeRepository implements LocalSyncChangeRepository { @override Future applyRemoteItem(RemoteSyncedItem item) async => false; @override Future> listPendingChanges() async => const []; @override Future markChangesSynced( List changeLogIds, DateTime syncedAt, ) async {} } final class _FakeRemoteShareApi implements RemoteShareApi { @override Future acceptShare(String shareId, String token) async { throw const RemoteAuthException(RemoteAuthFailure.network); } @override Future declineShare(String shareId, String token) async {} @override Future> fetchInbox(String token) async => const []; @override Future revokeShare(String shareId, String token) async {} @override Future sendShare({ required ShareResourceType resourceType, required Map payload, required List recipientEmails, required String token, }) async { return const RemoteShareSendResult( shareId: 'share-1', recipientUserIds: [], unresolvedEmails: [], ); } } final class _FakeShareInboxRepository implements ShareInboxRepository { @override Future findByShareId(String shareId) async => null; @override Future> listAll() async => const []; @override Future markStatus( String shareId, ShareInboxStatus status, DateTime respondedAt, ) async {} @override Future upsert(ShareInboxItem item) async {} @override Future upsertAll(List items) async {} } final class _FakePendingShareActionRepository implements PendingShareActionRepository { @override Future add(PendingShareAction action) async {} @override Future> listPending() async => const []; @override Future markFailed(String id, DateTime attemptedAt) async {} @override Future markSucceeded(String id, DateTime attemptedAt) async {} } final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; final restStates = []; final setTimerStates = []; final scoreStopwatchStates = []; final results = []; @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 { return null; } @override Future findManualScoreState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { return null; } @override Future findSetTimerState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { return null; } @override Future findExerciseStepProgressState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { return null; } @override Future> listRestStates(String sessionId) async { return restStates; } @override Future> listScoreStopwatchStates( String sessionId, ) async { return scoreStopwatchStates; } @override Future> listManualScoreStates( String sessionId, ) async { return const []; } @override Future> listSetTimerStates(String sessionId) async { return setTimerStates; } @override Future> listSetResults(String sessionId) async { return results; } @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 { restStates.add(restState); } @override Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async { scoreStopwatchStates.add(state); } @override Future saveManualScoreState(ActiveManualScoreState state) async {} @override Future saveSetTimerState(ActiveSetTimerState state) async { setTimerStates.add(state); } @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 { results.add(result); } } final class _FakeExerciseRepository implements ExerciseRepository { @override Future findById(String id) async => null; @override Future isReferencedByProgram(String id) async => false; @override Future> listActive() async => const []; @override Future save(Exercise exercise) async {} } final class _FakeMediaAssetRepository implements MediaAssetRepository { @override Future findById(String id) async => null; @override Future> listActive() async => const []; @override Future save(MediaAsset mediaAsset) async {} } final class _FakeMediaStorage implements LocalMediaStorage { @override Future deleteByLocalUri(String localUri) async {} @override Future importFile({ required String sourcePath, required MediaKind kind, required String stableFileName, }) async { return StoredMediaFile( localUri: 'file:///$stableFileName', mimeType: 'image/png', sizeBytes: 0, ); } @override Future> listManagedLocalUris() async => const {}; } final class _FakeLocalDataBackupRepository implements LocalDataBackupRepository { @override Future applyImportSnapshot({ required LocalDataExportSnapshot snapshot, required LocalBackupImportMode mode, required DateTime importedAt, }) async { return const LocalBackupImportResult( insertedCount: 0, updatedCount: 0, ignoredOlderCount: 0, deletedByReplaceCount: 0, missingMediaCount: 0, ); } @override Future hasAnyUserData() async => false; @override Future hasOpenActiveWorkoutSession() async => false; @override Future readExportSnapshot( DateTime exportedAt, ) async { return LocalDataExportSnapshot( exportedAt: exportedAt, appSchemaVersion: 1, originDeviceId: 'device-1', mediaAssets: const [], exercises: const [], programs: const [], workoutTemplates: const [], workoutHistories: const [], ); } } final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore { @override Future> readEmbeddableFiles( List mediaAssets, ) async { return const []; } @override Future> restoreEmbeddedFiles( List files, ) async { return const {}; } } 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 _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 _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 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 _FakeWorkoutTelemetryRepository implements WorkoutTelemetryRepository { @override Future saveSample(WorkoutTelemetrySample sample) async => true; @override Future> listSamples(String sessionId) async { return const []; } @override Future replaceAggregatesForSession({ required String sessionId, required List aggregates, }) async {} @override Future> listAggregates( String sessionId, ) async { return const []; } @override Future findAggregate({ required String sessionId, required WorkoutTelemetryAggregateScope scope, int? programIndex, int? exerciseIndex, int? setIndex, int? passageIndex, int? stepIndex, }) async { return null; } } final class _FakeProgressionStatsRepository implements ProgressionStatsRepository { @override Future readGlobalStats( ProgressionDateRange range, ) async { return const ProgressionGlobalStatsData( completedSessionCount: 0, totalActiveMs: 0, activeWeekStarts: [], hasAnyCompletedHistory: false, ); } @override Future> listExerciseOptions( ProgressionDateRange range, ) async { return const []; } @override Future> listMeasureOptions({ required ProgressionDateRange range, required String exerciseKey, }) async { return const []; } @override Future readExerciseSeries({ required ProgressionDateRange range, required String exerciseKey, required ProgressionMeasure measure, }) async { return ProgressionExerciseSeriesData( exerciseKey: exerciseKey, exerciseNameSnapshot: exerciseKey, measure: ProgressionMeasureOptionData( measure: measure, label: 'Score', lowerIsBetter: false, ), points: const [], hasAnyAllTimeData: false, hasAnyCompletedExerciseResult: false, ); } } final class _FakeExercisePerformanceReferenceRepository implements ExercisePerformanceReferenceRepository { @override Future hasAnyCompletedHistoryForExercise(String exerciseId) async { return false; } @override Future findLatestSetPerformance({ required String exerciseId, required ActivePerformanceMeasures activeMeasures, required int currentSetIndex, }) async { return null; } @override Future findBestMetricPerformance({ required String exerciseId, required PerformanceMetric metric, required ScoreInputMode scoreInputMode, }) async { return null; } }