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>
This commit is contained in:
2026-07-19 19:52:43 +02:00
parent 4731a257a1
commit ebba22db56
14 changed files with 11371 additions and 2 deletions

View File

@ -216,6 +216,204 @@ void main() {
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 {
@ -335,6 +533,30 @@ void main() {
);
}
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,