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 validation au moins une mesure active bloque la sauvegarde', ( tester, ) async { final exerciseRepository = _FakeExerciseRepository(); final exerciseUseCases = _exerciseUseCases(exerciseRepository); await tester.pumpWidget( MaterialApp( home: ExerciseFormScreen( exerciseUseCases: exerciseUseCases, mediaUseCases: _mediaUseCases(exerciseRepository), ), ), ); await tester.enterText(find.byType(TextFormField).first, 'Gainage'); await tester.tap(find.widgetWithText(SwitchListTile, 'Temps')); await tester.pump(); await tester.dragUntilVisible( find.text('Enregistrer'), find.byType(ListView), const Offset(0, -200), ); await tester.pumpAndSettle(); await tester.tap(find.text('Enregistrer')); await tester.pump(); expect( find.text('Active au moins une mesure pour enregistrer.'), findsOneWidget, ); expect(exerciseRepository.saved, isEmpty); }); testWidgets('la liste affiche les badges des mesures disponibles', ( tester, ) async { final exercise = Exercise( metadata: _metadata('exercise-1'), name: 'Squat', description: 'Jambes · charge libre', hasTimeMeasure: true, hasRepsMeasure: true, hasScoreMeasure: true, scoreLabel: 'Charge', scoreUnit: 'kg', defaultTargetTimeSeconds: 45, defaultTargetReps: 10, defaultTargetScore: 80, ); await tester.pumpWidget( MaterialApp( home: Scaffold(body: ExerciseListTile(exercise: exercise)), ), ); expect(find.text('Squat'), findsOneWidget); expect(find.text('Jambes · charge libre'), findsOneWidget); expect(find.text('Temps'), findsOneWidget); expect(find.text('Répétitions'), findsOneWidget); expect(find.text('Score (kg)'), findsOneWidget); }); testWidgets('basculer en chrono intégré masque l’unité et affiche le badge', ( tester, ) async { final exerciseRepository = _FakeExerciseRepository(); await tester.binding.setSurfaceSize(const Size(400, 2400)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( MaterialApp( home: ExerciseFormScreen( exerciseUseCases: _exerciseUseCases(exerciseRepository), mediaUseCases: _mediaUseCases(exerciseRepository), ), ), ); await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Run'); await tester.enterText( find.widgetWithText(TextFormField, 'Temps par défaut (s)'), '30', ); await tester.tap(find.widgetWithText(SwitchListTile, 'Score')); await tester.pump(); expect(find.widgetWithText(TextFormField, 'Unité'), findsOneWidget); await tester.tap(find.text('Chrono intégré')); await tester.pump(); expect(find.text('Temps réalisé'), findsOneWidget); expect(find.widgetWithText(TextFormField, 'Unité'), findsNothing); await tester.tap(find.text('Enregistrer')); await tester.pumpAndSettle(); final saved = exerciseRepository.saved.single; expect(saved.scoreInputMode, ScoreInputMode.stopwatch); expect(saved.defaultTargetTimeSeconds, 30); expect(saved.defaultTargetScoreTimeMs, isNull); await tester.pumpWidget( MaterialApp( home: Scaffold(body: ExerciseListTile(exercise: saved)), ), ); await tester.pumpAndSettle(); expect(find.text('Score chrono'), findsOneWidget); expect(find.text('Score'), findsNothing); }); testWidgets( 'sélectionner une image déclenche l’import et confirme le fichier', (tester) async { final exerciseRepository = _FakeExerciseRepository(); final storage = _FakeMediaStorage(); await tester.pumpWidget( MaterialApp( home: ExerciseFormScreen( exerciseUseCases: _exerciseUseCases(exerciseRepository), mediaUseCases: _mediaUseCases(exerciseRepository, storage: storage), mediaPicker: _FakeMediaPicker(imagePath: '/tmp/imports/photo.png'), ), ), ); await tester.tap(find.text('Ajouter une image')); await tester.pump(); expect(storage.imports.single.sourcePath, '/tmp/imports/photo.png'); expect(storage.imports.single.kind, MediaKind.image); expect(find.text('photo.png'), findsOneWidget); expect(find.text('Image ajoutée.'), findsOneWidget); }, ); testWidgets('ajouter des images bloque la sixième image', (tester) async { final exerciseRepository = _FakeExerciseRepository(); final storage = _FakeMediaStorage(); await tester.binding.setSurfaceSize(const Size(400, 1200)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( MaterialApp( home: ExerciseFormScreen( exerciseUseCases: _exerciseUseCases(exerciseRepository), mediaUseCases: _mediaUseCases(exerciseRepository, storage: storage), mediaPicker: _FakeMediaPicker( imagePaths: const [ '/tmp/imports/photo-1.png', '/tmp/imports/photo-2.png', '/tmp/imports/photo-3.png', '/tmp/imports/photo-4.png', '/tmp/imports/photo-5.png', '/tmp/imports/photo-6.png', ], ), ), ), ); for (var i = 0; i < 5; i++) { await tester.tap(find.text('Ajouter une image')); await tester.pump(); } expect(storage.imports, hasLength(5)); expect(find.text('photo-5.png'), findsOneWidget); expect(find.text('Maximum 5 images par exercice.'), findsOneWidget); await tester.tap(find.text('Ajouter une image')); await tester.pump(); expect(storage.imports, hasLength(5)); expect(find.text('photo-6.png'), findsNothing); expect(find.text('Maximum 5 images par exercice.'), findsWidgets); }); testWidgets('supprimer une image la retire de la galerie', (tester) async { final exerciseRepository = _FakeExerciseRepository(); final storage = _FakeMediaStorage(); await tester.pumpWidget( MaterialApp( home: ExerciseFormScreen( exerciseUseCases: _exerciseUseCases(exerciseRepository), mediaUseCases: _mediaUseCases(exerciseRepository, storage: storage), mediaPicker: _FakeMediaPicker(imagePath: '/tmp/imports/photo.png'), ), ), ); await tester.tap(find.text('Ajouter une image')); await tester.pump(); expect(find.text('photo.png'), findsOneWidget); await tester.tap(find.byTooltip('Supprimer l’image')); await tester.pump(); expect(find.text('photo.png'), findsNothing); expect(find.text('Aucune image sélectionnée'), findsOneWidget); }); testWidgets('activer les étapes permet d’ajouter une étape temps', ( tester, ) async { final exerciseRepository = _FakeExerciseRepository(); await tester.binding.setSurfaceSize(const Size(400, 3200)); addTearDown(() => tester.binding.setSurfaceSize(null)); await _pumpExerciseForm(tester, exerciseRepository); await tester.tap( find.widgetWithText( SwitchListTile, 'Rythmer cet exercice avec des étapes', ), ); await tester.pump(); await tester.tap(find.text('Ajouter une étape')); await tester.pump(); await tester.enterText( find.widgetWithText(TextFormField, 'Nom de l’étape').last, 'Dribble main droite', ); await tester.enterText( find.widgetWithText(TextFormField, 'Durée par défaut (s)').last, '12', ); await tester.pump(); expect(find.text('1. Dribble main droite'), findsOneWidget); expect(find.text('Temps · 12 s'), findsOneWidget); }); testWidgets('ajouter une étape répétitions avec score manuel', ( tester, ) async { final exerciseRepository = _FakeExerciseRepository(); await tester.binding.setSurfaceSize(const Size(400, 3600)); addTearDown(() => tester.binding.setSurfaceSize(null)); await _pumpExerciseForm(tester, exerciseRepository); await tester.tap( find.widgetWithText( SwitchListTile, 'Rythmer cet exercice avec des étapes', ), ); await tester.pump(); await tester.tap(find.text('Ajouter une étape')); await tester.pump(); await tester.tap(_stepTypeRadio('Répétitions').last); await tester.pump(); await tester.enterText( find.widgetWithText(TextFormField, 'Nom de l’étape').last, 'Pompes', ); await tester.enterText( find.widgetWithText(TextFormField, 'Répétitions par défaut').last, '10', ); await tester.tap(find.widgetWithText(SwitchListTile, 'Score d’étape')); await tester.pump(); await tester.enterText( find.widgetWithText(TextFormField, 'Score à saisir').last, 'Réussites', ); await tester.enterText( find.widgetWithText(TextFormField, 'Unité').last, 'paniers', ); await tester.enterText( find.widgetWithText(TextFormField, 'Score par défaut').last, '0', ); await tester.pump(); expect(find.text('1. Pompes'), findsOneWidget); expect(find.text('Répétitions · 10'), findsOneWidget); expect(find.text('Mode de score'), findsOneWidget); }); testWidgets('réordonner les étapes via Monter Descendre', (tester) async { final exerciseRepository = _FakeExerciseRepository(); await tester.binding.setSurfaceSize(const Size(400, 4200)); addTearDown(() => tester.binding.setSurfaceSize(null)); await _pumpExerciseForm(tester, exerciseRepository); await tester.tap( find.widgetWithText( SwitchListTile, 'Rythmer cet exercice avec des étapes', ), ); await tester.pump(); await tester.tap(find.text('Ajouter une étape')); await tester.pump(); await tester.enterText( find.widgetWithText(TextFormField, 'Nom de l’étape').at(0), 'Première', ); await tester.tap(find.text('Ajouter une étape')); await tester.pump(); await tester.enterText( find.widgetWithText(TextFormField, 'Nom de l’étape').at(1), 'Deuxième', ); await tester.pump(); await tester.tap(find.byTooltip('Actions de l’étape 2')); await tester.pumpAndSettle(); await tester.tap(find.text('Monter')); await tester.pumpAndSettle(); expect(find.text('1. Deuxième'), findsOneWidget); expect(find.text('2. Première'), findsOneWidget); }); testWidgets('supprimer une étape demande confirmation', (tester) async { final exerciseRepository = _FakeExerciseRepository(); await tester.binding.setSurfaceSize(const Size(400, 3200)); addTearDown(() => tester.binding.setSurfaceSize(null)); await _pumpExerciseForm(tester, exerciseRepository); await tester.tap( find.widgetWithText( SwitchListTile, 'Rythmer cet exercice avec des étapes', ), ); await tester.pump(); await tester.tap(find.text('Ajouter une étape')); await tester.pump(); await tester.tap(find.byTooltip('Actions de l’étape 1')); await tester.pumpAndSettle(); await tester.tap(find.text('Supprimer')); await tester.pumpAndSettle(); expect(find.text('Supprimer cette étape ?'), findsOneWidget); await tester.tap(find.widgetWithText(FilledButton, 'Supprimer')); await tester.pumpAndSettle(); expect(find.text('Nouvelle étape'), findsNothing); }); testWidgets('la limite de 8 étapes bloque l’ajout d’une neuvième', ( tester, ) async { final exerciseRepository = _FakeExerciseRepository(); await tester.binding.setSurfaceSize(const Size(400, 8000)); addTearDown(() => tester.binding.setSurfaceSize(null)); await _pumpExerciseForm(tester, exerciseRepository); await tester.tap( find.widgetWithText( SwitchListTile, 'Rythmer cet exercice avec des étapes', ), ); await tester.pump(); for (var index = 0; index < 8; index++) { await tester.tap(find.text('Ajouter une étape')); await tester.pump(); } expect(find.text('Limite atteinte'), findsOneWidget); expect( find.text('Un exercice peut contenir jusqu’à 8 étapes.'), findsOneWidget, ); expect(find.text('Ajouter une étape'), findsNothing); }); testWidgets('sauvegarder transmet les étapes au use case', (tester) async { final exerciseRepository = _FakeExerciseRepository(); await tester.binding.setSurfaceSize(const Size(400, 3600)); addTearDown(() => tester.binding.setSurfaceSize(null)); await _pumpExerciseForm(tester, exerciseRepository); await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo'); await tester.enterText( find.widgetWithText(TextFormField, 'Temps par défaut (s)'), '30', ); await tester.tap( find.widgetWithText( SwitchListTile, 'Rythmer cet exercice avec des étapes', ), ); await tester.pump(); await tester.tap(find.text('Ajouter une étape')); await tester.pump(); await tester.enterText( find.widgetWithText(TextFormField, 'Nom de l’étape').last, 'Dribble main droite', ); await tester.enterText( find.widgetWithText(TextFormField, 'Durée par défaut (s)').last, '12', ); await tester.ensureVisible(find.text('Enregistrer')); await tester.tap(find.text('Enregistrer')); await tester.pumpAndSettle(); final saved = exerciseRepository.saved.single; expect(saved.steps, hasLength(1)); expect(saved.steps.single.name, 'Dribble main droite'); expect(saved.steps.single.type, ExerciseStepType.time); expect(saved.steps.single.defaultTargetValue, 12); }); testWidgets('désigner une image comme icône l’indique visuellement', ( tester, ) async { final exerciseRepository = _FakeExerciseRepository(); final storage = _FakeMediaStorage(); await tester.binding.setSurfaceSize(const Size(400, 1600)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( MaterialApp( home: ExerciseFormScreen( exerciseUseCases: _exerciseUseCases(exerciseRepository), mediaUseCases: _mediaUseCases(exerciseRepository, storage: storage), mediaPicker: _FakeMediaPicker( imagePaths: const [ '/tmp/imports/photo-1.png', '/tmp/imports/photo-2.png', ], ), ), ), ); await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Squat'); await tester.enterText( find.widgetWithText(TextFormField, 'Temps par défaut (s)'), '45', ); await tester.tap(find.text('Ajouter une image')); await tester.pump(); await tester.tap(find.text('Ajouter une image')); await tester.pump(); await tester.tap(find.byTooltip('Définir comme icône').last); await tester.pump(); expect(find.byTooltip('Icône choisie'), findsOneWidget); await tester.tap(find.text('Enregistrer')); await tester.pumpAndSettle(); expect(exerciseRepository.saved.single.iconMediaId, 'id-2'); }); testWidgets('ExerciseListTile utilise l’icône choisie', (tester) async { final exercise = Exercise( metadata: _metadata('exercise-1'), name: 'Squat', imageMediaIds: const ['image-1', 'image-2'], iconMediaId: 'image-2', hasTimeMeasure: true, hasRepsMeasure: false, hasScoreMeasure: false, defaultTargetTimeSeconds: 45, ); await tester.pumpWidget( MaterialApp( home: Scaffold(body: ExerciseListTile(exercise: exercise)), ), ); expect(find.byTooltip('Icône d’exercice image-2'), findsOneWidget); }); testWidgets( 'supprimer un exercice demande confirmation puis le retire de la liste', (tester) async { final exerciseRepository = _FakeExerciseRepository() ..referenced = true ..exercises.add( Exercise( metadata: _metadata('exercise-1'), name: 'Squat', hasTimeMeasure: true, hasRepsMeasure: false, hasScoreMeasure: false, defaultTargetTimeSeconds: 45, ), ); await tester.pumpWidget( MaterialApp( home: ExerciseLibraryScreen( exerciseUseCases: _exerciseUseCases(exerciseRepository), mediaUseCases: _mediaUseCases(exerciseRepository), ), ), ); await tester.pump(); await tester.pump(); await tester.tap(find.byTooltip('Supprimer l’exercice')); await tester.pumpAndSettle(); expect(find.text('Supprimer Squat ?'), findsOneWidget); expect( find.textContaining('Les programmes existants restent inchangés.'), findsOneWidget, ); await tester.tap(find.text('Annuler')); await tester.pumpAndSettle(); expect(find.text('Squat'), findsOneWidget); expect(exerciseRepository.saved, isEmpty); await tester.tap(find.byTooltip('Supprimer l’exercice')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Supprimer')); await tester.pumpAndSettle(); expect(find.text('Squat'), findsNothing); expect(exerciseRepository.saved.single.metadata.deletedAt, isNotNull); }, ); } Future _pumpExerciseForm( WidgetTester tester, _FakeExerciseRepository exerciseRepository, ) { return tester.pumpWidget( MaterialApp( home: ExerciseFormScreen( exerciseUseCases: _exerciseUseCases(exerciseRepository), mediaUseCases: _mediaUseCases(exerciseRepository), ), ), ); } Finder _stepTypeRadio(String title) { return find.byWidgetPredicate( (widget) => widget is RadioListTile && widget.title is Text && (widget.title as Text).data == title, description: 'RadioListTile "$title"', ); } ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) { return ExerciseUseCases( repository: repository, programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); } 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 {} } MediaUseCases _mediaUseCases( _FakeExerciseRepository exerciseRepository, { _FakeMediaStorage? storage, }) { return MediaUseCases( mediaRepository: _FakeMediaAssetRepository(), exerciseRepository: exerciseRepository, storage: storage ?? _FakeMediaStorage(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); } final class _FakeMediaPicker implements MediaSourcePicker { _FakeMediaPicker({this.imagePath, this.imagePaths = const []}); final String? imagePath; final List imagePaths; var _nextImagePath = 0; @override Future pickPath(MediaKind kind) async { return switch (kind) { MediaKind.image => _pickImagePath(), MediaKind.video => null, }; } String? _pickImagePath() { if (imagePaths.isEmpty) { return imagePath; } if (_nextImagePath >= imagePaths.length) { return null; } final path = imagePaths[_nextImagePath]; _nextImagePath += 1; return path; } } 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 _FakeExerciseRepository implements ExerciseRepository { final exercises = []; final saved = []; var referenced = false; @override Future findById(String id) async { for (final exercise in exercises) { if (exercise.metadata.id == id) { return exercise; } } return null; } @override Future isReferencedByProgram(String id) async => referenced; @override Future> listActive() async { return exercises .where((exercise) => exercise.metadata.deletedAt == null) .toList(); } @override Future save(Exercise exercise) async { saved.add(exercise); exercises.removeWhere((item) => item.metadata.id == exercise.metadata.id); exercises.add(exercise); } } final class _FakeMediaAssetRepository implements MediaAssetRepository { final saved = []; @override Future findById(String id) async { for (final asset in saved) { if (asset.metadata.id == id) { return asset; } } return null; } @override Future> listActive() async => saved; @override Future save(MediaAsset mediaAsset) async { saved.add(mediaAsset); } } final class _FakeMediaStorage implements LocalMediaStorage { final imports = <_MediaImportCall>[]; @override Future deleteByLocalUri(String localUri) async {} @override Future importFile({ required String sourcePath, required MediaKind kind, required String stableFileName, }) async { imports.add( _MediaImportCall( sourcePath: sourcePath, kind: kind, stableFileName: stableFileName, ), ); return StoredMediaFile( localUri: 'file:///tmp/$stableFileName', mimeType: kind == MediaKind.image ? 'image/png' : 'video/mp4', sizeBytes: 1, ); } @override Future> listManagedLocalUris() async => const {}; } final class _MediaImportCall { const _MediaImportCall({ required this.sourcePath, required this.kind, required this.stableFileName, }); final String sourcePath; final MediaKind kind; final String stableFileName; }