Files
GameTime/test/presentation/exercise_library_screen_test.dart
Blomios 8bda4758f2 feat(exercice): galerie de 5 images par exercice (ticket #35)
Étend le modèle Drift (tables.dart, app_database.dart/.g.dart), les
entités/use cases/repositories et exercise_library_screen.dart pour
supporter jusqu'à 5 images par exercice au lieu d'une seule. flutter
analyze propre, 63/63 tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:02:40 +02:00

465 lines
13 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(
'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);
},
);
}
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;
}