Réorganise l'écran d'exécution autour d'une carte "Exercice actif" sous le compteur SÉRIE X/Y (nom, médias, temps de série, action "Démarrer l'exercice"). Le timer de série devient un état persistant dédié (plus un DateTime UI volatile), pause-aware, et "Démarrer l'exercice" lance en une action tous les chronos qui doivent démarrer en début de série (série, première étape temps, score chrono si stopwatch). Implémentation restée non commitée depuis sa réalisation ; QA a validé les vérifications statiques exécutables en sandbox (dart analyze, git diff --check). flutter analyze / flutter test restent à relancer dans un environnement avec cache Flutter SDK accessible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
524 lines
15 KiB
Dart
524 lines
15 KiB
Dart
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 groupe les séances par période', (tester) async {
|
||
final groups = groupHistoriesByPeriod([
|
||
_history(id: 'today', startedAt: DateTime.utc(2026, 7, 17, 9)),
|
||
_history(id: 'week', startedAt: DateTime.utc(2026, 7, 15, 9)),
|
||
_history(id: 'old', startedAt: DateTime.utc(2026, 7, 1, 9)),
|
||
], DateTime.utc(2026, 7, 17, 12));
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: Scaffold(
|
||
body: Column(children: [for (final key in groups.keys) Text(key)]),
|
||
),
|
||
),
|
||
);
|
||
|
||
expect(find.text("Aujourd'hui"), findsOneWidget);
|
||
expect(find.text('Cette semaine'), findsOneWidget);
|
||
expect(find.text('Plus ancien'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('le détail affiche temps répétitions et score avec unité', (
|
||
tester,
|
||
) async {
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: HistoryDetailScreen(
|
||
history: _history(id: 'history-1'),
|
||
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
activeUseCases: _activeUseCases(),
|
||
closeUseCase: _closeUseCase(),
|
||
),
|
||
),
|
||
);
|
||
|
||
expect(find.text('Programme jambes'), findsOneWidget);
|
||
expect(find.text('Squat'), findsOneWidget);
|
||
expect(
|
||
find.text('Temps 00:45 · 10 répétitions · Score 80 kg'),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('le détail affiche le score chrono comme temps réalisé', (
|
||
tester,
|
||
) async {
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: HistoryDetailScreen(
|
||
history: _history(id: 'history-1', stopwatchScore: true),
|
||
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
activeUseCases: _activeUseCases(),
|
||
closeUseCase: _closeUseCase(),
|
||
),
|
||
),
|
||
);
|
||
|
||
expect(
|
||
find.text(
|
||
'Temps 00:45 · 10 répétitions · '
|
||
'Temps réalisé : 00:38.7 / objectif 00:45',
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
expect(find.textContaining('Score 38.7'), findsNothing);
|
||
});
|
||
|
||
testWidgets('le détail affiche les résultats d’étapes par passage', (
|
||
tester,
|
||
) async {
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: HistoryDetailScreen(
|
||
history: _history(id: 'history-1', withStepResults: true),
|
||
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
activeUseCases: _activeUseCases(),
|
||
closeUseCase: _closeUseCase(),
|
||
),
|
||
),
|
||
);
|
||
|
||
expect(find.text('Étapes réalisées'), findsOneWidget);
|
||
expect(find.text('Passage 1'), findsOneWidget);
|
||
expect(
|
||
find.text(
|
||
'Étape 1 · Dribble gauche · Temps 00:10 · Réussites : 4 paniers',
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
expect(find.text('Étape 2 · Dribble droite'), findsOneWidget);
|
||
expect(find.text('Passée'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('la suppression demande une confirmation', (tester) async {
|
||
final repository = _FakeWorkoutHistoryRepository();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: HistoryDetailScreen(
|
||
history: _history(id: 'history-1'),
|
||
historyUseCases: _historyUseCases(repository),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
activeUseCases: _activeUseCases(),
|
||
closeUseCase: _closeUseCase(),
|
||
),
|
||
),
|
||
);
|
||
|
||
await tester.tap(find.text("Supprimer de l'historique"));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(repository.deletedIds, isEmpty);
|
||
expect(find.text("Supprimer de l'historique ?"), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Annuler'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(repository.deletedIds, isEmpty);
|
||
|
||
await tester.tap(find.text("Supprimer de l'historique"));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Supprimer'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(repository.deletedIds, ['history-1']);
|
||
});
|
||
}
|
||
|
||
WorkoutHistory _history({
|
||
required String id,
|
||
DateTime? startedAt,
|
||
bool stopwatchScore = false,
|
||
bool withStepResults = false,
|
||
}) {
|
||
final start = startedAt ?? DateTime.utc(2026, 7, 17, 10);
|
||
return WorkoutHistory(
|
||
metadata: _metadata(id),
|
||
sourceWorkoutTemplateId: 'template-1',
|
||
sourceActiveWorkoutSessionId: 'session-1',
|
||
nameSnapshot: 'Séance jambes',
|
||
startedAt: start,
|
||
endedAt: start.add(const Duration(minutes: 30)),
|
||
totalActiveMs: 1800000,
|
||
completed: true,
|
||
historySnapshotJson: jsonEncode({
|
||
'sessionId': 'session-1',
|
||
'resolvedTemplateSnapshotJson': _resolvedSnapshot(
|
||
stopwatchScore: stopwatchScore,
|
||
withSteps: withStepResults,
|
||
),
|
||
'results': [
|
||
{
|
||
'programSnapshotId': 'program-snapshot-1',
|
||
'exerciseSnapshotId': 'exercise-snapshot-1',
|
||
'programIndex': 0,
|
||
'exerciseIndex': 0,
|
||
'setIndex': 0,
|
||
'actualTimeMs': 45000,
|
||
'actualReps': 10,
|
||
if (stopwatchScore) 'actualScoreTimeMs': 38700,
|
||
if (!stopwatchScore) 'actualScore': 80,
|
||
'scoreLabelSnapshot': 'Charge',
|
||
'scoreInputModeSnapshot': stopwatchScore
|
||
? ScoreInputMode.stopwatch.name
|
||
: ScoreInputMode.manual.name,
|
||
if (!stopwatchScore) 'scoreUnitSnapshot': 'kg',
|
||
if (stopwatchScore) 'targetScoreTimeMsSnapshot': 45000,
|
||
},
|
||
],
|
||
}),
|
||
stepResults: withStepResults
|
||
? [
|
||
WorkoutHistoryStepResult(
|
||
metadata: _metadata('step-result-1'),
|
||
workoutHistoryId: id,
|
||
programSnapshotId: 'program-snapshot-1',
|
||
exerciseSnapshotId: 'exercise-snapshot-1',
|
||
programIndex: 0,
|
||
exerciseIndex: 0,
|
||
setIndex: 0,
|
||
passageIndex: 0,
|
||
stepIndex: 0,
|
||
stepSnapshotId: 'step-1',
|
||
stepNameSnapshot: 'Dribble gauche',
|
||
stepTypeSnapshot: ExerciseStepType.time,
|
||
targetValueSnapshot: 10,
|
||
hasScoreSnapshot: true,
|
||
scoreInputModeSnapshot: ScoreInputMode.manual,
|
||
scoreLabelSnapshot: 'Réussites',
|
||
scoreUnitSnapshot: 'paniers',
|
||
targetScoreSnapshot: 3,
|
||
status: SetResultStatus.completed,
|
||
actualTimeMs: 10000,
|
||
actualScore: 4,
|
||
),
|
||
WorkoutHistoryStepResult(
|
||
metadata: _metadata('step-result-2'),
|
||
workoutHistoryId: id,
|
||
programSnapshotId: 'program-snapshot-1',
|
||
exerciseSnapshotId: 'exercise-snapshot-1',
|
||
programIndex: 0,
|
||
exerciseIndex: 0,
|
||
setIndex: 0,
|
||
passageIndex: 0,
|
||
stepIndex: 1,
|
||
stepSnapshotId: 'step-2',
|
||
stepNameSnapshot: 'Dribble droite',
|
||
stepTypeSnapshot: ExerciseStepType.reps,
|
||
targetValueSnapshot: 8,
|
||
hasScoreSnapshot: false,
|
||
status: SetResultStatus.skipped,
|
||
),
|
||
]
|
||
: const [],
|
||
);
|
||
}
|
||
|
||
String _resolvedSnapshot({
|
||
bool stopwatchScore = false,
|
||
bool withSteps = false,
|
||
}) {
|
||
return jsonEncode({
|
||
'name': 'Séance jambes',
|
||
'programs': [
|
||
{
|
||
'id': 'program-snapshot-1',
|
||
'programNameSnapshot': 'Programme jambes',
|
||
'programSnapshotJson': jsonEncode({
|
||
'exercises': [
|
||
{
|
||
'id': 'exercise-snapshot-1',
|
||
'exerciseNameSnapshot': 'Squat',
|
||
'setsCount': 1,
|
||
'timeEnabled': true,
|
||
'repsEnabled': true,
|
||
'scoreEnabled': true,
|
||
'targetTimeSeconds': 45,
|
||
'targetReps': 10,
|
||
if (stopwatchScore) 'targetScoreTimeMs': 45000,
|
||
if (!stopwatchScore) 'targetScore': 80,
|
||
'scoreInputModeSnapshot': stopwatchScore
|
||
? ScoreInputMode.stopwatch.name
|
||
: ScoreInputMode.manual.name,
|
||
'scoreLabelSnapshot': 'Charge',
|
||
if (!stopwatchScore) 'scoreUnitSnapshot': 'kg',
|
||
'restSecondsOverride': 0,
|
||
if (withSteps)
|
||
'exerciseStepsSnapshot': [
|
||
{
|
||
'id': 'step-1',
|
||
'position': 0,
|
||
'name': 'Dribble gauche',
|
||
'type': ExerciseStepType.time.name,
|
||
'defaultTargetValue': 10,
|
||
'hasScore': true,
|
||
'scoreInputMode': ScoreInputMode.manual.name,
|
||
'scoreLabel': 'Réussites',
|
||
'scoreUnit': 'paniers',
|
||
'defaultTargetScore': 3,
|
||
},
|
||
{
|
||
'id': 'step-2',
|
||
'position': 1,
|
||
'name': 'Dribble droite',
|
||
'type': ExerciseStepType.reps.name,
|
||
'defaultTargetValue': 8,
|
||
'hasScore': false,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
],
|
||
'overrides': const [],
|
||
});
|
||
}
|
||
|
||
EntityMetadata _metadata(String id) {
|
||
return EntityMetadata(
|
||
id: id,
|
||
createdAt: DateTime.utc(2026, 7, 17),
|
||
updatedAt: DateTime.utc(2026, 7, 17),
|
||
originDeviceId: 'device-1',
|
||
);
|
||
}
|
||
|
||
WorkoutHistoryUseCases _historyUseCases(
|
||
_FakeWorkoutHistoryRepository repository,
|
||
) {
|
||
return WorkoutHistoryUseCases(
|
||
repository: repository,
|
||
clock: _FakeClock(DateTime.utc(2026, 7, 17)),
|
||
);
|
||
}
|
||
|
||
WorkoutTemplateUseCases _workoutTemplateUseCases() {
|
||
return WorkoutTemplateUseCases(
|
||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||
programRepository: _FakeProgramRepository(),
|
||
clock: _FakeClock(DateTime.utc(2026, 7, 17)),
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
);
|
||
}
|
||
|
||
ActiveWorkoutSessionUseCases _activeUseCases() {
|
||
return ActiveWorkoutSessionUseCases(
|
||
sessionRepository: _FakeActiveSessionRepository(),
|
||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||
clock: _FakeClock(DateTime.utc(2026, 7, 17)),
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
);
|
||
}
|
||
|
||
CloseWorkoutSessionUseCase _closeUseCase() {
|
||
return CloseWorkoutSessionUseCase(
|
||
sessionRepository: _FakeActiveSessionRepository(),
|
||
historyRepository: _FakeWorkoutHistoryRepository(),
|
||
clock: _FakeClock(DateTime.utc(2026, 7, 17)),
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
);
|
||
}
|
||
|
||
final class _FakeClock implements Clock {
|
||
_FakeClock(this.value);
|
||
|
||
DateTime value;
|
||
|
||
@override
|
||
DateTime now() => value;
|
||
}
|
||
|
||
final class _FakeIds implements IdGenerator {
|
||
var _next = 0;
|
||
|
||
@override
|
||
String newId() {
|
||
_next += 1;
|
||
return 'id-$_next';
|
||
}
|
||
}
|
||
|
||
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||
final deletedIds = <String>[];
|
||
|
||
@override
|
||
Future<void> delete(String id, DateTime deletedAt) async {
|
||
deletedIds.add(id);
|
||
}
|
||
|
||
@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> saveSetResult(WorkoutHistorySetResult result) async {}
|
||
|
||
@override
|
||
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
||
}
|
||
|
||
final class _FakeWorkoutTemplateRepository
|
||
implements WorkoutTemplateRepository {
|
||
@override
|
||
Future<WorkoutTemplate?> findById(String id) async => null;
|
||
|
||
@override
|
||
Future<List<WorkoutTemplate>> listActive() async => const [];
|
||
|
||
@override
|
||
Future<void> replaceComposition(
|
||
WorkoutTemplate template,
|
||
DateTime deletedAt,
|
||
) async {}
|
||
|
||
@override
|
||
Future<void> save(WorkoutTemplate template) async {}
|
||
|
||
@override
|
||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||
|
||
@override
|
||
Future<void> saveProgram(WorkoutTemplateProgram program) 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 _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||
@override
|
||
Future<ActiveWorkoutSession?> findById(String id) async => null;
|
||
|
||
@override
|
||
Future<ActiveWorkoutSession?> findOpen() async => null;
|
||
|
||
@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 => null;
|
||
|
||
@override
|
||
Future<ActiveSetTimerState?> findSetTimerState({
|
||
required String sessionId,
|
||
required int programIndex,
|
||
required int exerciseIndex,
|
||
required int setIndex,
|
||
}) async => null;
|
||
|
||
@override
|
||
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
|
||
required String sessionId,
|
||
required int programIndex,
|
||
required int exerciseIndex,
|
||
required int setIndex,
|
||
}) async => null;
|
||
|
||
@override
|
||
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
||
return const [];
|
||
}
|
||
|
||
@override
|
||
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
|
||
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 {}
|
||
|
||
@override
|
||
Future<void> saveRestState(ActiveRestState restState) async {}
|
||
|
||
@override
|
||
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState 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> saveSetResult(ActiveSetResult result) async {}
|
||
}
|