Files
GameTime/test/presentation/exercise_library_screen_test.dart
Blomios fe3608cc4a feat(server): implemente synchronisation et serveur avec fixtures (#187)
- Implémente la couche de synchronisation avec le serveur
- Ajoute les fixtures versionnées pour les tests
- Met à jour Drift database et repositories pour le support sync
- Améliore les tests de synchronisation
- Corrige et améliore le watch companion pour la collecte de métriques

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 23:07:31 +02:00

1247 lines
37 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 '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.dragUntilVisible(
find.widgetWithText(SwitchListTile, 'Temps'),
find.byType(ListView),
const Offset(0, -200),
);
await tester.pumpAndSettle();
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,
category: ExerciseCategory.finishing,
isExample: true,
);
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);
expect(find.text('Finition'), findsOneWidget);
expect(find.text('Exemple'), findsOneWidget);
});
testWidgets('ajouter un tag à un exercice le sauvegarde normalisé', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.pumpWidget(
MaterialApp(
home: ExerciseFormScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository),
tagSuggestions: const ['match'],
),
),
);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Tirs');
await tester.tap(find.text('Ajouter un tag'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Nom du tag'),
' Match ',
);
await tester.tap(find.widgetWithText(FilledButton, 'Ajouter'));
await tester.pumpAndSettle();
expect(find.widgetWithText(Chip, 'match'), findsOneWidget);
await tester.dragUntilVisible(
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
find.byType(ListView),
const Offset(0, -200),
);
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.dragUntilVisible(
find.text('Enregistrer'),
find.byType(ListView),
const Offset(0, -200),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
expect(exerciseRepository.saved.single.tags, ['match']);
});
testWidgets('choisir des types métier les sauvegarde avec exercice', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository();
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),
),
),
);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Départ');
await tester.ensureVisible(find.text('Type dexercice'));
await tester.dragUntilVisible(
find.widgetWithText(FilterChip, 'Dribble'),
find.byType(SingleChildScrollView).last,
const Offset(-180, 0),
);
await tester.tap(find.widgetWithText(FilterChip, 'Dribble'));
await tester.dragUntilVisible(
find.widgetWithText(FilterChip, 'Haute intensité'),
find.byType(SingleChildScrollView).last,
const Offset(-250, 0),
);
await tester.tap(find.widgetWithText(FilterChip, 'Haute intensité'));
await tester.ensureVisible(
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
expect(exerciseRepository.saved.single.businessTypes, [
BusinessExerciseType.dribble,
BusinessExerciseType.highIntensity,
]);
});
testWidgets('filtre les exercices par tag et efface tous les filtres', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository()
..exercises.addAll([
Exercise(
metadata: _metadata('exercise-1'),
name: 'Tirs match',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
tags: const ['match', 'extérieur'],
),
Exercise(
metadata: _metadata('exercise-2'),
name: 'Dribble routine',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
tags: const ['routine'],
),
]);
await tester.pumpWidget(
MaterialApp(
home: ExerciseLibraryScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.widgetWithText(FilterChip, 'match'));
await tester.pump();
expect(find.text('Tirs match'), findsOneWidget);
expect(find.text('Dribble routine'), findsNothing);
expect(find.text('Effacer les filtres'), findsOneWidget);
await tester.tap(find.text('Effacer les filtres'));
await tester.pump();
expect(find.text('Tirs match'), findsOneWidget);
expect(find.text('Dribble routine'), findsOneWidget);
});
testWidgets('filtre les exercices par types métier en OR', (tester) async {
final exerciseRepository = _FakeExerciseRepository()
..exercises.addAll([
Exercise(
metadata: _metadata('exercise-1'),
name: 'Tir hérité',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
category: ExerciseCategory.shoot,
),
Exercise(
metadata: _metadata('exercise-2'),
name: 'Dribble appuyé',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
businessTypes: const [BusinessExerciseType.dribble],
),
Exercise(
metadata: _metadata('exercise-3'),
name: 'Routine libre',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
),
]);
await tester.pumpWidget(
MaterialApp(
home: ExerciseLibraryScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository),
),
),
);
await tester.pump();
await tester.pump();
expect(find.widgetWithText(FilterChip, 'Tir'), findsOneWidget);
expect(find.widgetWithText(FilterChip, 'Dribble'), findsOneWidget);
expect(find.widgetWithText(FilterChip, 'Libre / autre'), findsOneWidget);
await tester.tap(find.widgetWithText(FilterChip, 'Tir'));
await tester.pump();
expect(find.text('Tir hérité'), findsOneWidget);
expect(find.text('Dribble appuyé'), findsNothing);
expect(find.text('Routine libre'), findsNothing);
await tester.tap(find.widgetWithText(FilterChip, 'Dribble'));
await tester.pump();
expect(find.text('Tir hérité'), findsOneWidget);
expect(find.text('Dribble appuyé'), findsOneWidget);
expect(find.text('Routine libre'), findsNothing);
await tester.tap(find.widgetWithText(FilterChip, 'Libre / autre'));
await tester.pump();
expect(find.text('Tir hérité'), findsOneWidget);
expect(find.text('Dribble appuyé'), findsOneWidget);
expect(find.text('Routine libre'), findsOneWidget);
await tester.tap(find.text('Effacer les filtres'));
await tester.pump();
expect(
tester
.widget<FilterChip>(find.widgetWithText(FilterChip, 'Tir'))
.selected,
isFalse,
);
expect(
tester
.widget<FilterChip>(find.widgetWithText(FilterChip, 'Dribble'))
.selected,
isFalse,
);
expect(
tester
.widget<FilterChip>(find.widgetWithText(FilterChip, 'Libre / autre'))
.selected,
isFalse,
);
});
testWidgets('masque le filtre type quand un seul type est disponible', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository()
..exercises.addAll([
Exercise(
metadata: _metadata('exercise-1'),
name: 'Tir proche',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
category: ExerciseCategory.shoot,
),
Exercise(
metadata: _metadata('exercise-2'),
name: 'Tir loin',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
category: ExerciseCategory.shoot,
),
]);
await tester.pumpWidget(
MaterialApp(
home: ExerciseLibraryScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository),
),
),
);
await tester.pump();
await tester.pump();
expect(find.text('Type'), findsNothing);
expect(find.widgetWithText(FilterChip, 'Tir'), findsNothing);
});
testWidgets('basculer en chrono intégré masque lunité 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, 'Durée 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 limport 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 limage'));
await tester.pump();
expect(find.text('photo.png'), findsNothing);
expect(find.text('Aucune image sélectionnée'), findsOneWidget);
});
testWidgets('activer les étapes permet dajouter 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('une étape peut utiliser le score de la série', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 4200));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Tirs');
await tester.enterText(
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.tap(find.widgetWithText(SwitchListTile, 'Score'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Score à saisir').first,
'Paniers',
);
await tester.enterText(find.widgetWithText(TextFormField, 'Unité'), 'pts');
await tester.enterText(
find.widgetWithText(TextFormField, 'Score par défaut'),
'0',
);
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,
'Tirs main droite',
);
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.tap(
find.widgetWithText(SwitchListTile, 'Utiliser le score de la série'),
);
await tester.pump();
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
final step = exerciseRepository.saved.single.steps.single;
expect(step.linkedToSeriesScore, isTrue);
expect(step.scoreLabel, 'Paniers');
expect(step.scoreUnit, 'pts');
expect(step.defaultTargetScore, isNull);
});
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 lajout dune 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, 'Durée 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.id, isNot(startsWith('step-draft-')));
expect(saved.steps.single.name, 'Dribble main droite');
expect(saved.steps.single.type, ExerciseStepType.time);
expect(saved.steps.single.defaultTargetValue, 12);
});
testWidgets('une étape invalide affiche une erreur inline sans sauvegarder', (
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, 'Durée 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,
'abc',
);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pump();
expect(find.text('Saisis une durée supérieure à 0.'), findsOneWidget);
expect(exerciseRepository.saved, isEmpty);
expect(find.byType(CircularProgressIndicator), findsNothing);
});
testWidgets('un échec inattendu de sauvegarde libère le bouton', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository()..throwOnSave = true;
await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
await tester.enterText(
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
expect(
find.text(
'Impossible denregistrer lexercice. Vérifie les champs puis réessaie.',
),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Enregistrer'),
)
.onPressed,
isNotNull,
);
});
testWidgets(
'le réglage denchaînement automatique des étapes est sauvegardé',
(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, 'Durée par défaut (s)'),
'30',
);
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Rythmer cet exercice avec des étapes',
),
);
await tester.pump();
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Enchaîner automatiquement les chronos consécutifs',
),
);
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.autoStartNextTimedStep, isFalse);
},
);
testWidgets('désigner une image comme icône lindique 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, 'Durée 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 licô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 dexercice 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 lexercice'));
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 lexercice'));
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<void> _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<ExerciseStepType> &&
widget.title is Text &&
(widget.title as Text).data == title,
description: 'RadioListTile<ExerciseStepType> "$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<Program?> findById(String id) async => null;
@override
Future<List<Program>> listActive() async => const [];
@override
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
@override
Future<void> save(Program program) async {}
@override
Future<void> 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<String> imagePaths;
var _nextImagePath = 0;
@override
Future<String?> 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 = <Exercise>[];
final saved = <Exercise>[];
var referenced = false;
var throwOnSave = false;
@override
Future<Exercise?> findById(String id) async {
for (final exercise in exercises) {
if (exercise.metadata.id == id) {
return exercise;
}
}
return null;
}
@override
Future<bool> isReferencedByProgram(String id) async => referenced;
@override
Future<List<Exercise>> listActive() async {
return exercises
.where((exercise) => exercise.metadata.deletedAt == null)
.toList();
}
@override
Future<void> save(Exercise exercise) async {
if (throwOnSave) {
throw Exception('save failed');
}
saved.add(exercise);
exercises.removeWhere((item) => item.metadata.id == exercise.metadata.id);
exercises.add(exercise);
}
}
final class _FakeMediaAssetRepository implements MediaAssetRepository {
final saved = <MediaAsset>[];
@override
Future<MediaAsset?> findById(String id) async {
for (final asset in saved) {
if (asset.metadata.id == id) {
return asset;
}
}
return null;
}
@override
Future<List<MediaAsset>> listActive() async => saved;
@override
Future<void> save(MediaAsset mediaAsset) async {
saved.add(mediaAsset);
}
}
final class _FakeMediaStorage implements LocalMediaStorage {
final imports = <_MediaImportCall>[];
@override
Future<void> deleteByLocalUri(String localUri) async {}
@override
Future<StoredMediaFile> 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<Set<String>> 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;
}