Files
GameTime/test/presentation/exercise_library_screen_test.dart
Blomios ebba22db56 feat(exercice): exécution persistante des étapes et éditeur d'étapes (tickets #58, #59)
Ajoute la persistance de l'exécution par étapes avec résultats par
passage (ports, use cases, entités, modèle Drift avec migration) et
l'éditeur d'exercice avec séquence d'étapes sur
exercise_library_screen.dart. Développés dans le même worktree
partagé par DevBackend et DevFrontend ; commit combiné plutôt que
séparé car les fakes de repository de test corrigés (nouvelles
interfaces #58) touchent plusieurs écrans sans lien avec l'éditeur
lui-même (historique, accueil, exécution, séance-modèle). Corrige au
passage un ListTile sans ancêtre Material dans le nouvel éditeur
d'étapes (Material(type: MaterialType.transparency)). flutter pub get
OK, build_runner OK, analyze propre (mêmes infos préexistantes),
93/93 tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:52:43 +02:00

753 lines
22 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.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 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, '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 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('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, '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 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, '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 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('Le supprimer le retirera de ces programmes.'),
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;
@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 {
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;
}