Files
GameTime/test/presentation/workout_template_screen_test.dart
Blomios 917777e18b chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)
Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:48:54 +02:00

1364 lines
41 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 'dart:convert';
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 liste affiche le badge exemple des séances seedées', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
_workoutTemplate(isExample: true),
]);
final activeRepository = _FakeActiveSessionRepository();
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
expect(find.text('Séance jambes'), findsOneWidget);
expect(find.text('Exemple'), findsOneWidget);
expect(find.byTooltip('Actions'), findsOneWidget);
expect(find.text('Lancer'), findsOneWidget);
});
testWidgets('la liste séances vide conserve le message dédié', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository(const []);
final activeRepository = _FakeActiveSessionRepository();
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
expect(find.text('Aucune séance'), findsOneWidget);
expect(
find.text(
'Crée une séance-modèle pour lancer ton entraînement plus vite.',
),
findsOneWidget,
);
});
testWidgets('une séance sans programme ne peut pas être enregistrée', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository(const []);
final programRepository = _FakeProgramRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateFormScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
),
),
);
expect(
find.text('Ajoute au moins un programme pour enregistrer cette séance.'),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Enregistrer'),
)
.onPressed,
isNull,
);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Vide');
await tester.tap(find.text('Enregistrer'));
await tester.pump();
expect(templateRepository.saved, isEmpty);
});
testWidgets('le lancement est bloqué si aucun exercice ne peut être joué', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
WorkoutTemplate(
metadata: _metadata('template-empty'),
name: 'Séance vide',
programs: [
WorkoutTemplateProgram(
metadata: _metadata('template-program-empty'),
workoutTemplateId: 'template-empty',
position: 0,
programNameSnapshot: 'Programme vide',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({'exercises': const []}),
),
],
),
]);
final activeRepository = _FakeActiveSessionRepository();
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('Lancer'));
await tester.pump();
expect(
find.text(
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
'au moins un exercice avant de la lancer.',
),
findsOneWidget,
);
expect(activeRepository.session, isNull);
});
testWidgets('filtre les séances par tag et peut effacer les filtres', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
_workoutTemplate(tags: const ['match', 'intense']),
WorkoutTemplate(
metadata: _metadata('template-2'),
name: 'Séance routine',
tags: const ['routine'],
),
]);
final activeRepository = _FakeActiveSessionRepository();
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.widgetWithText(FilterChip, 'match'));
await tester.pump();
expect(find.text('Séance jambes'), findsOneWidget);
expect(find.text('Séance routine'), findsNothing);
expect(find.text('Effacer les filtres'), findsOneWidget);
await tester.tap(find.text('Effacer les filtres'));
await tester.pump();
expect(find.text('Séance jambes'), findsOneWidget);
expect(find.text('Séance routine'), findsOneWidget);
});
testWidgets('dupliquer une séance ouvre la copie en édition', (tester) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
_workoutTemplate(tags: const ['match']),
]);
final activeRepository = _FakeActiveSessionRepository();
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Dupliquer'));
await tester.pumpAndSettle();
expect(find.text('Modifier la séance'), findsOneWidget);
expect(find.text('Copie de Séance jambes'), findsOneWidget);
expect(
find.text('Séance dupliquée. Ajuste la copie avant de la lancer.'),
findsOneWidget,
);
expect(find.widgetWithText(Chip, 'match'), findsOneWidget);
});
testWidgets('modifier une séance exemple conserve sa provenance', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
_workoutTemplate(isExample: true),
]);
final programRepository = _FakeProgramRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateFormScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
template: templateRepository.templates.single,
),
),
);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Editée');
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
expect(templateRepository.templates.single.name, 'Editée');
expect(templateRepository.templates.single.isExample, isTrue);
});
testWidgets(
'la vue programme de séance ne permet pas de changer les mesures actives',
(tester) async {
final program = WorkoutTemplateProgramDraft(
clientKey: 'program-1',
programNameSnapshot: 'Jambes',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: '{}',
exercises: [
WorkoutTemplateExerciseDraft(
snapshotExerciseId: 'exercise-1',
exerciseNameSnapshot: 'Squat',
setsCount: 3,
timeEnabled: true,
repsEnabled: true,
scoreEnabled: false,
targetTimeSeconds: 45,
targetReps: 10,
),
],
);
await tester.pumpWidget(
MaterialApp(home: WorkoutTemplateProgramDetailScreen(program: program)),
);
expect(find.byType(CheckboxListTile), findsNothing);
expect(find.text('Mesures à suivre'), findsNothing);
await tester.enterText(find.byType(TextFormField).first, '5');
await tester.pump();
expect(program.exercises.single.setsCountOverride, 5);
expect(find.text('5 séries · Temps + Répétitions'), findsOneWidget);
},
);
testWidgets(
'personnaliser lenchaînement automatique transmet loverride séance',
(tester) async {
final program = WorkoutTemplateProgramDraft(
clientKey: 'program-1',
programNameSnapshot: 'Circuit',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: '{}',
exercises: [
WorkoutTemplateExerciseDraft(
snapshotExerciseId: 'exercise-1',
exerciseNameSnapshot: 'Navettes',
setsCount: 3,
timeEnabled: true,
repsEnabled: false,
scoreEnabled: false,
exerciseStepsSnapshot: [
_step(
id: 'step-1',
position: 0,
name: 'Course',
type: ExerciseStepType.time,
),
_step(
id: 'step-2',
position: 1,
name: 'Retour',
type: ExerciseStepType.time,
),
],
autoStartNextTimedStepSnapshot: true,
targetTimeSeconds: 30,
),
],
);
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(home: WorkoutTemplateProgramDetailScreen(program: program)),
);
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Enchaîner automatiquement les chronos consécutifs',
),
);
await tester.pump();
expect(
find.textContaining('Personnalisé pour cette séance'),
findsOneWidget,
);
final override = program.toOverrides().single;
expect(override.autoStartNextTimedStepOverride, isFalse);
},
);
testWidgets(
'revenir au réglage du programme remet loverride séance à null',
(tester) async {
final program = WorkoutTemplateProgramDraft(
clientKey: 'program-1',
programNameSnapshot: 'Circuit',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: '{}',
exercises: [
WorkoutTemplateExerciseDraft(
snapshotExerciseId: 'exercise-1',
exerciseNameSnapshot: 'Navettes',
setsCount: 3,
timeEnabled: true,
repsEnabled: false,
scoreEnabled: false,
exerciseStepsSnapshot: [
_step(
id: 'step-1',
position: 0,
name: 'Course',
type: ExerciseStepType.time,
),
],
autoStartNextTimedStepSnapshot: false,
autoStartNextTimedStepOverride: true,
targetTimeSeconds: 30,
),
],
);
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(home: WorkoutTemplateProgramDetailScreen(program: program)),
);
expect(
find.textContaining('Personnalisé pour cette séance'),
findsOneWidget,
);
await tester.tap(find.text('Revenir au réglage du programme'));
await tester.pump();
expect(find.textContaining('Réglage du programme'), findsOneWidget);
expect(program.toOverrides(), isEmpty);
},
);
testWidgets(
'lancer une séance alors quune autre est en cours affiche un message',
(tester) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
_workoutTemplate(),
]);
final activeRepository = _FakeActiveSessionRepository()
..session = _activeSession();
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('Lancer'));
await tester.pump();
await tester.pump();
expect(
find.textContaining('Une séance est déjà en cours.'),
findsOneWidget,
);
expect(activeRepository.saveCount, 0);
},
);
testWidgets('une session legacy invalide ne bloque pas le lancement', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
_workoutTemplate(),
]);
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('legacy-session'),
sourceWorkoutTemplateId: 'template-legacy',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 9),
lastPersistedAt: DateTime.utc(2026, 7, 17, 9),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'template-program-empty',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
}),
);
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('Lancer'));
await tester.pumpAndSettle();
expect(find.textContaining('Une séance est déjà en cours.'), findsNothing);
expect(find.byType(WorkoutExecutionScreen), findsOneWidget);
expect(activeRepository.session?.sourceWorkoutTemplateId, 'template-1');
});
testWidgets(
'supprimer une séance demande confirmation puis la retire de la liste',
(tester) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
_workoutTemplate(),
]);
final activeRepository = _FakeActiveSessionRepository();
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Supprimer'));
await tester.pumpAndSettle();
expect(find.text('Supprimer Séance jambes ?'), findsOneWidget);
expect(find.text('Cette action est irréversible.'), findsOneWidget);
await tester.tap(find.text('Annuler'));
await tester.pumpAndSettle();
expect(find.text('Séance jambes'), findsOneWidget);
expect(templateRepository.saved, isEmpty);
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Supprimer'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Supprimer'));
await tester.pumpAndSettle();
expect(find.text('Séance jambes'), findsNothing);
expect(templateRepository.saved.single.metadata.deletedAt, isNotNull);
},
);
testWidgets('partager une séance ouvre le formulaire de partage', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
_workoutTemplate(),
]);
final programRepository = _FakeProgramRepository();
final remoteShareApi = _FakeRemoteShareApi();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateFormScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
shareUseCases: _shareUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
remoteShareApi: remoteShareApi,
),
template: templateRepository.templates.single,
),
),
);
await tester.tap(find.byTooltip('Partager'));
await tester.pumpAndSettle();
expect(find.text('Partager la séance'), findsOneWidget);
await tester.enterText(
find.widgetWithText(TextFormField, 'Emails destinataires'),
'coach@example.com',
);
await tester.tap(find.text('Envoyer le partage'));
await tester.pump();
await tester.pump();
expect(remoteShareApi.lastResourceType, ShareResourceType.workoutTemplate);
expect(remoteShareApi.lastRecipientEmails, ['coach@example.com']);
});
}
WorkoutTemplate _workoutTemplate({
bool isExample = false,
List<String> tags = const [],
}) {
return WorkoutTemplate(
metadata: _metadata('template-1'),
name: 'Séance jambes',
isExample: isExample,
tags: tags,
programs: [
WorkoutTemplateProgram(
metadata: _metadata('template-program-1'),
workoutTemplateId: 'template-1',
position: 0,
programNameSnapshot: 'Programme jambes',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: _programSnapshotJson(),
),
],
);
}
ActiveWorkoutSession _activeSession() {
return ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-active',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({
'name': 'Séance active',
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': 'Programme jambes',
'programSnapshotJson': _programSnapshotJson(),
},
],
'overrides': const [],
}),
);
}
String _programSnapshotJson() {
return jsonEncode({
'exercises': [
{
'id': 'exercise-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': 3,
'timeEnabled': true,
'repsEnabled': false,
'scoreEnabled': false,
'restSecondsOverride': 60,
},
],
});
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
createdAt: DateTime.utc(2026, 7, 17),
updatedAt: DateTime.utc(2026, 7, 17),
originDeviceId: 'device-1',
);
}
ExerciseStep _step({
required String id,
required int position,
required String name,
required ExerciseStepType type,
}) {
return ExerciseStep(
id: id,
position: position,
name: name,
type: type,
defaultTargetValue: type == ExerciseStepType.time ? 30 : 5,
);
}
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 _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
_FakeWorkoutTemplateRepository(this.templates);
final List<WorkoutTemplate> templates;
final saved = <WorkoutTemplate>[];
@override
Future<WorkoutTemplate?> findById(String id) async {
return templates.cast<WorkoutTemplate?>().firstWhere(
(template) => template?.metadata.id == id,
orElse: () => null,
);
}
@override
Future<List<WorkoutTemplate>> listActive() async {
return templates
.where((template) => template.metadata.deletedAt == null)
.toList();
}
@override
Future<void> replaceComposition(
WorkoutTemplate template,
DateTime deletedAt,
) async {
templates.removeWhere((item) => item.metadata.id == template.metadata.id);
templates.add(template);
}
@override
Future<void> save(WorkoutTemplate template) async {
saved.add(template);
templates.removeWhere((item) => item.metadata.id == template.metadata.id);
templates.add(template);
}
@override
Future<void> saveAll(List<WorkoutTemplate> templates) async {
for (final template in templates) {
await save(template);
}
}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session;
var saveCount = 0;
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<ActiveRestState?> findRestStateById(String id) async => null;
@override
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return const [];
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return const [];
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return const [];
}
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return const [];
}
@override
Future<void> save(ActiveWorkoutSession session) async {
saveCount += 1;
this.session = session;
}
@override
Future<void> saveRestState(ActiveRestState restState) async {}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {}
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {}
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
}
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 {}
}
final class _FakeExerciseRepository implements ExerciseRepository {
@override
Future<Exercise?> findById(String id) async => null;
@override
Future<bool> isReferencedByProgram(String id) async => false;
@override
Future<List<Exercise>> listActive() async => const [];
@override
Future<void> save(Exercise exercise) async {}
}
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> delete(String id, DateTime deletedAt) async {}
@override
Future<WorkoutHistory?> findById(String id) async => null;
@override
Future<List<WorkoutHistory>> listActive() async => const [];
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
int? minHeartRateBpm,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
double? totalDistanceMeters,
double? totalCaloriesKcal,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
}
ShareUseCases _shareUseCases({
required _FakeWorkoutTemplateRepository templateRepository,
required _FakeProgramRepository programRepository,
_FakeRemoteShareApi? remoteShareApi,
}) {
return ShareUseCases(
tokenStore: _FakeAuthTokenStore('token-1'),
remoteShareApi: remoteShareApi ?? _FakeRemoteShareApi(),
inboxRepository: _FakeShareInboxRepository(),
pendingActionRepository: _FakePendingShareActionRepository(),
localChanges: _FakeLocalSyncChangeRepository(),
programRepository: programRepository,
templateRepository: templateRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
}
final class _FakeAuthTokenStore implements AuthTokenStore {
_FakeAuthTokenStore([this.token]);
String? token;
@override
Future<void> clearToken() async {
token = null;
}
@override
Future<String?> readToken() async => token;
@override
Future<void> saveToken(String token, DateTime expiresAt) async {
this.token = token;
}
}
final class _FakeRemoteShareApi implements RemoteShareApi {
ShareResourceType? lastResourceType;
List<String> lastRecipientEmails = const [];
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
return RemoteSyncedItem(
resourceType: SyncResourceType.workoutTemplate,
clientId: 'template-remote',
serverId: 'template-server',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Séance partagée'},
);
}
@override
Future<void> declineShare(String shareId, String token) async {}
@override
Future<List<ShareInboxItem>> fetchInbox(String token) async => const [];
@override
Future<void> revokeShare(String shareId, String token) async {}
@override
Future<RemoteShareSendResult> sendShare({
required ShareResourceType resourceType,
required Map<String, Object?> payload,
required List<String> recipientEmails,
required String token,
}) async {
lastResourceType = resourceType;
lastRecipientEmails = recipientEmails;
return const RemoteShareSendResult(
shareId: 'share-1',
recipientUserIds: ['recipient-1'],
unresolvedEmails: [],
);
}
}
final class _FakeShareInboxRepository implements ShareInboxRepository {
@override
Future<ShareInboxItem?> findByShareId(String shareId) async => null;
@override
Future<List<ShareInboxItem>> listAll() async => const [];
@override
Future<void> markStatus(
String shareId,
ShareInboxStatus status,
DateTime respondedAt,
) async {}
@override
Future<void> upsert(ShareInboxItem item) async {}
@override
Future<void> upsertAll(List<ShareInboxItem> items) async {}
}
final class _FakePendingShareActionRepository
implements PendingShareActionRepository {
@override
Future<void> add(PendingShareAction action) async {}
@override
Future<List<PendingShareAction>> listPending() async => const [];
@override
Future<void> markFailed(String id, DateTime attemptedAt) async {}
@override
Future<void> markSucceeded(String id, DateTime attemptedAt) async {}
}
final class _FakeLocalSyncChangeRepository
implements LocalSyncChangeRepository {
@override
Future<bool> applyRemoteItem(RemoteSyncedItem item) async => true;
@override
Future<List<PendingSyncChange>> listPendingChanges() async => const [];
@override
Future<void> markChangesSynced(
List<String> changeLogIds,
DateTime syncedAt,
) async {}
}