feat(server): implemente synchronisation et serveur avec fixtures (#187)

- Implémente la couche de synchronisation avec le serveur
- Ajoute les fixtures versionnées pour les tests
- Met à jour Drift database et repositories pour le support sync
- Améliore les tests de synchronisation
- Corrige et améliore le watch companion pour la collecte de métriques

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 23:07:31 +02:00
parent d1edb2397e
commit fe3608cc4a
31 changed files with 5056 additions and 394 deletions

View File

@ -224,6 +224,131 @@ void main() {
expect(find.text('Dribble routine'), findsOneWidget);
});
testWidgets('filtre les exercices par types métier en OR', (tester) async {
final exerciseRepository = _FakeExerciseRepository()
..exercises.addAll([
Exercise(
metadata: _metadata('exercise-1'),
name: 'Tir hérité',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
category: ExerciseCategory.shoot,
),
Exercise(
metadata: _metadata('exercise-2'),
name: 'Dribble appuyé',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
businessTypes: const [BusinessExerciseType.dribble],
),
Exercise(
metadata: _metadata('exercise-3'),
name: 'Routine libre',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
),
]);
await tester.pumpWidget(
MaterialApp(
home: ExerciseLibraryScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository),
),
),
);
await tester.pump();
await tester.pump();
expect(find.widgetWithText(FilterChip, 'Tir'), findsOneWidget);
expect(find.widgetWithText(FilterChip, 'Dribble'), findsOneWidget);
expect(find.widgetWithText(FilterChip, 'Libre / autre'), findsOneWidget);
await tester.tap(find.widgetWithText(FilterChip, 'Tir'));
await tester.pump();
expect(find.text('Tir hérité'), findsOneWidget);
expect(find.text('Dribble appuyé'), findsNothing);
expect(find.text('Routine libre'), findsNothing);
await tester.tap(find.widgetWithText(FilterChip, 'Dribble'));
await tester.pump();
expect(find.text('Tir hérité'), findsOneWidget);
expect(find.text('Dribble appuyé'), findsOneWidget);
expect(find.text('Routine libre'), findsNothing);
await tester.tap(find.widgetWithText(FilterChip, 'Libre / autre'));
await tester.pump();
expect(find.text('Tir hérité'), findsOneWidget);
expect(find.text('Dribble appuyé'), findsOneWidget);
expect(find.text('Routine libre'), findsOneWidget);
await tester.tap(find.text('Effacer les filtres'));
await tester.pump();
expect(
tester
.widget<FilterChip>(find.widgetWithText(FilterChip, 'Tir'))
.selected,
isFalse,
);
expect(
tester
.widget<FilterChip>(find.widgetWithText(FilterChip, 'Dribble'))
.selected,
isFalse,
);
expect(
tester
.widget<FilterChip>(find.widgetWithText(FilterChip, 'Libre / autre'))
.selected,
isFalse,
);
});
testWidgets('masque le filtre type quand un seul type est disponible', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository()
..exercises.addAll([
Exercise(
metadata: _metadata('exercise-1'),
name: 'Tir proche',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
category: ExerciseCategory.shoot,
),
Exercise(
metadata: _metadata('exercise-2'),
name: 'Tir loin',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
category: ExerciseCategory.shoot,
),
]);
await tester.pumpWidget(
MaterialApp(
home: ExerciseLibraryScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository),
),
),
);
await tester.pump();
await tester.pump();
expect(find.text('Type'), findsNothing);
expect(find.widgetWithText(FilterChip, 'Tir'), findsNothing);
});
testWidgets('basculer en chrono intégré masque lunité et affiche le badge', (
tester,
) async {

View File

@ -79,6 +79,7 @@ void main() {
home: HistoryDetailScreen(
history: _history(
id: 'history-1',
withTelemetry: true,
minHeartRateBpm: 88,
averageHeartRateBpm: 126.4,
maxHeartRateBpm: 171,
@ -88,18 +89,28 @@ void main() {
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(),
telemetryUseCases: _telemetryUseCases(),
closeUseCase: _closeUseCase(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Stats montre'), findsOneWidget);
expect(find.text('Fréquence cardiaque'), findsOneWidget);
expect(find.text('Min'), findsAtLeastNWidgets(1));
expect(find.text('Moyenne'), findsOneWidget);
expect(find.text('Max'), findsAtLeastNWidgets(1));
expect(find.text('Distance'), findsOneWidget);
expect(find.text('Calories'), findsOneWidget);
expect(find.text('Distance'), findsAtLeastNWidgets(1));
expect(find.text('Calories'), findsAtLeastNWidgets(1));
expect(find.text('Étape'), findsOneWidget);
expect(find.text('Série'), findsAtLeastNWidgets(1));
expect(find.text('Exercice'), findsAtLeastNWidgets(1));
expect(find.text('Séance'), findsAtLeastNWidgets(1));
expect(find.text('FC'), findsOneWidget);
expect(find.text('Période'), findsOneWidget);
expect(find.text('30:00'), findsAtLeastNWidgets(1));
expect(find.text('Fréquence cardiaque · Séance · 03:00'), findsOneWidget);
expect(
find.byKey(const ValueKey('history-watch-stats-graph')),
findsOneWidget,
@ -269,6 +280,7 @@ WorkoutHistory _history({
DateTime? startedAt,
bool stopwatchScore = false,
bool withStepResults = false,
bool withTelemetry = false,
bool emptySnapshot = false,
int? minHeartRateBpm,
double? averageHeartRateBpm,
@ -318,6 +330,51 @@ WorkoutHistory _history({
if (stopwatchScore) 'targetScoreTimeMsSnapshot': 45000,
},
],
if (withTelemetry)
'telemetrySamples': [
{
'id': 'telemetry-1',
'sessionId': 'session-1',
'capturedAt': start.toUtc().toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'stepIndex': 0,
'heartRateBpm': 88,
'distanceMeters': 0,
'caloriesKcal': 0,
},
{
'id': 'telemetry-2',
'sessionId': 'session-1',
'capturedAt': start
.add(const Duration(minutes: 1, seconds: 30))
.toUtc()
.toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'stepIndex': 0,
'heartRateBpm': 126,
'distanceMeters': 600,
'caloriesKcal': 40,
},
{
'id': 'telemetry-3',
'sessionId': 'session-1',
'capturedAt': start
.add(const Duration(minutes: 3))
.toUtc()
.toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'stepIndex': 0,
'heartRateBpm': 171,
'distanceMeters': 1234,
'caloriesKcal': 83,
},
],
}),
stepResults: withStepResults
? [
@ -491,6 +548,16 @@ CloseWorkoutSessionUseCase _closeUseCase() {
);
}
WorkoutTelemetryUseCases _telemetryUseCases([
_FakeWorkoutTelemetryRepository? repository,
]) {
return WorkoutTelemetryUseCases(
repository: repository ?? _FakeWorkoutTelemetryRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17)),
ids: _FakeIds(),
);
}
final class _FakeClock implements Clock {
_FakeClock(this.value);
@ -724,3 +791,108 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
}
final class _FakeWorkoutTelemetryRepository
implements WorkoutTelemetryRepository {
final samples = <WorkoutTelemetrySample>[];
@override
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
samples.add(sample);
return true;
}
@override
Future<ActiveWorkoutTelemetryWindowState?> findWindowState(
String sessionId,
) async => null;
@override
Future<void> saveWindowState(ActiveWorkoutTelemetryWindowState state) async {}
@override
Future<void> deleteWindowState(String sessionId) async {}
@override
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
return samples
.where((sample) => sample.sessionId == sessionId)
.toList(growable: false);
}
@override
Future<List<WorkoutTelemetrySample>> listSamplesForScope({
required String sessionId,
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) async {
return samples
.where(
(sample) =>
sample.sessionId == sessionId &&
_fakeTelemetrySampleMatchesScope(
sample,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
),
)
.toList(growable: false);
}
@override
Future<void> replaceAggregatesForSession({
required String sessionId,
required List<WorkoutTelemetryAggregate> aggregates,
}) async {}
@override
Future<List<WorkoutTelemetryAggregate>> listAggregates(
String sessionId,
) async => const [];
@override
Future<WorkoutTelemetryAggregate?> findAggregate({
required String sessionId,
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) async => null;
}
bool _fakeTelemetrySampleMatchesScope(
WorkoutTelemetrySample sample, {
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) {
return switch (scope) {
WorkoutTelemetryAggregateScope.session => true,
WorkoutTelemetryAggregateScope.exercise =>
sample.programIndex == programIndex &&
sample.exerciseIndex == exerciseIndex,
WorkoutTelemetryAggregateScope.set =>
sample.programIndex == programIndex &&
sample.exerciseIndex == exerciseIndex &&
sample.setIndex == setIndex,
WorkoutTelemetryAggregateScope.step =>
sample.programIndex == programIndex &&
sample.exerciseIndex == exerciseIndex &&
sample.setIndex == setIndex &&
(passageIndex == null || sample.passageIndex == passageIndex) &&
sample.stepIndex == stepIndex,
};
}

View File

@ -932,9 +932,28 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
final class _FakeWorkoutTelemetryRepository
implements WorkoutTelemetryRepository {
final windowStates = <String, ActiveWorkoutTelemetryWindowState>{};
@override
Future<bool> saveSample(WorkoutTelemetrySample sample) async => true;
@override
Future<ActiveWorkoutTelemetryWindowState?> findWindowState(
String sessionId,
) async {
return windowStates[sessionId];
}
@override
Future<void> saveWindowState(ActiveWorkoutTelemetryWindowState state) async {
windowStates[state.sessionId] = state;
}
@override
Future<void> deleteWindowState(String sessionId) async {
windowStates.remove(sessionId);
}
@override
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
return const [];

View File

@ -237,6 +237,67 @@ void main() {
expect(find.text('186 kcal'), findsOneWidget);
});
testWidgets(
'affiche la distance indisponible quand la dernière mesure live la coupe',
(tester) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordTelemetrySample(
WatchSensorSample(
sessionId: 'session-1',
capturedAtEpochMs: now
.subtract(const Duration(seconds: 1))
.millisecondsSinceEpoch,
heartRateBpm: 124,
distanceMeters: 840,
caloriesKcal: 186,
),
)
..recordTelemetrySample(
WatchSensorSample(
sessionId: 'session-1',
capturedAtEpochMs: now.millisecondsSinceEpoch,
heartRateBpm: 126,
caloriesKcal: 188,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 126 bpm'), findsOneWidget);
expect(find.text('840 m'), findsNothing);
expect(find.text('Donnée indisponible'), findsOneWidget);
expect(find.text('188 kcal'), findsOneWidget);
},
);
testWidgets('affiche les états capteur explicites avant la première mesure', (
tester,
) async {