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

@ -3287,6 +3287,50 @@ void main() {
},
);
test(
'ActiveWorkoutSensorUseCases marks distance unavailable when the latest sample omits it',
() async {
final useCase = ActiveWorkoutSensorUseCases(
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)),
);
final withDistance = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
).millisecondsSinceEpoch,
heartRateBpm: 120,
distanceMeters: 500,
),
);
final withoutDistance = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-2',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
1,
).millisecondsSinceEpoch,
heartRateBpm: 124,
),
);
expect(withDistance?.latestDistanceMeters, 500);
expect(withDistance?.latestDistanceAvailable, isTrue);
expect(withoutDistance?.latestDistanceMeters, 500);
expect(withoutDistance?.latestDistanceAvailable, isFalse);
await useCase.dispose();
},
);
test(
'WorkoutTelemetryUseCases persists samples and aggregates by scope',
() async {
@ -3335,6 +3379,10 @@ void main() {
0,
10,
).millisecondsSinceEpoch,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
heartRateBpm: 160,
distanceMeters: 530,
caloriesKcal: 45,
@ -3378,20 +3426,42 @@ void main() {
caloriesKcal: 48,
),
);
final third = await useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-4',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
28,
10,
0,
40,
).millisecondsSinceEpoch,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
heartRateBpm: 155,
distanceMeters: 650,
caloriesKcal: 51,
),
);
expect(ignored, isEmpty);
expect(first.map((aggregate) => aggregate.scope), [
expect(first, isEmpty);
expect(duplicate, isEmpty);
expect(olderInSameBucket, isEmpty);
expect(second.map((aggregate) => aggregate.scope), [
WorkoutTelemetryAggregateScope.session,
WorkoutTelemetryAggregateScope.exercise,
WorkoutTelemetryAggregateScope.set,
WorkoutTelemetryAggregateScope.step,
]);
expect(duplicate, isNotEmpty);
expect(olderInSameBucket, isEmpty);
expect(repository.samples, hasLength(2));
expect(repository.samples.first.heartRateBpm, 160);
final sessionAggregate = second.singleWhere(
final sessionAggregate = third.singleWhere(
(aggregate) =>
aggregate.scope == WorkoutTelemetryAggregateScope.session,
);
@ -3415,6 +3485,69 @@ void main() {
},
);
test(
'WorkoutTelemetryUseCases does not advance windows while paused',
() async {
final repository = _FakeWorkoutTelemetryRepository();
final sessionRepository = _FakeActiveSessionRepository();
final startedAt = DateTime.utc(2026, 7, 28, 10);
sessionRepository.session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
status: ActiveWorkoutStatus.running,
startedAt: startedAt,
lastPersistedAt: startedAt,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: '{"programs":[]}',
);
final useCase = WorkoutTelemetryUseCases(
repository: repository,
sessionRepository: sessionRepository,
clock: _FakeClock(startedAt),
ids: _FakeIds(),
);
await useCase.recordTelemetrySample(
WatchTelemetrySample(
sessionId: 'session-1',
capturedAtEpochMs: startedAt.millisecondsSinceEpoch,
heartRateBpm: 120,
),
);
sessionRepository.session = sessionRepository.session!.pause(
startedAt.add(const Duration(seconds: 10)),
);
await useCase.recordTelemetrySample(
WatchTelemetrySample(
sessionId: 'session-1',
capturedAtEpochMs: startedAt
.add(const Duration(minutes: 5))
.millisecondsSinceEpoch,
heartRateBpm: 150,
),
);
sessionRepository.session = sessionRepository.session!.resume(
startedAt.add(const Duration(minutes: 5)),
);
final aggregates = await useCase.recordTelemetrySample(
WatchTelemetrySample(
sessionId: 'session-1',
capturedAtEpochMs: startedAt
.add(const Duration(minutes: 5, seconds: 5))
.millisecondsSinceEpoch,
heartRateBpm: 130,
),
);
expect(aggregates, isNotEmpty);
expect(repository.samples, hasLength(1));
expect(repository.samples.single.heartRateBpm, 120);
expect(repository.samples.single.capturedAt, startedAt);
},
);
test(
'WorkoutTelemetryUseCases reads graph samples by scope with relative cumulative metrics',
() async {
@ -3488,6 +3621,133 @@ void main() {
},
);
test(
'WorkoutTelemetryUseCases lists selected instances and real scope markers',
() async {
final repository = _FakeWorkoutTelemetryRepository();
final useCase = WorkoutTelemetryUseCases(
repository: repository,
clock: _FakeClock(DateTime.utc(2026, 7, 28, 10)),
ids: _FakeIds(),
);
final history = WorkoutHistory(
metadata: _metadata('history-telemetry-markers'),
nameSnapshot: 'Séance',
startedAt: DateTime.utc(2026, 7, 28, 10),
endedAt: DateTime.utc(2026, 7, 28, 11),
totalActiveMs: 3600000,
completed: true,
historySnapshotJson: jsonEncode({
'telemetrySamples': [
{
'id': 'sample-set-1-a',
'sessionId': 'session-1',
'capturedAt': DateTime.utc(2026, 7, 28, 10).toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'stepIndex': 0,
'heartRateBpm': 120,
},
{
'id': 'sample-set-1-b',
'sessionId': 'session-1',
'capturedAt': DateTime.utc(
2026,
7,
28,
10,
0,
30,
).toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'stepIndex': 1,
'heartRateBpm': 130,
},
{
'id': 'sample-set-2-a',
'sessionId': 'session-1',
'capturedAt': DateTime.utc(2026, 7, 28, 10, 1).toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 1,
'stepIndex': 0,
'heartRateBpm': 140,
},
{
'id': 'sample-set-2-b',
'sessionId': 'session-1',
'capturedAt': DateTime.utc(
2026,
7,
28,
10,
1,
30,
).toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 1,
'stepIndex': 1,
'heartRateBpm': 150,
},
{
'id': 'sample-exercise-2',
'sessionId': 'session-1',
'capturedAt': DateTime.utc(2026, 7, 28, 10, 2).toIso8601String(),
'programIndex': 0,
'exerciseIndex': 1,
'setIndex': 0,
'stepIndex': 0,
'heartRateBpm': 110,
},
],
}),
);
final exerciseInstances = await useCase.listScopeInstancesForHistory(
history: history,
scope: WorkoutTelemetryAggregateScope.exercise,
);
final setInstances = await useCase.listScopeInstancesForHistory(
history: history,
scope: WorkoutTelemetryAggregateScope.set,
);
final markers = await useCase.readScopeMarkersForHistory(
history: history,
scope: WorkoutTelemetryAggregateScope.exercise,
programIndex: 0,
exerciseIndex: 0,
);
expect(exerciseInstances, hasLength(2));
expect(exerciseInstances.first.exerciseIndex, 0);
expect(exerciseInstances.last.exerciseIndex, 1);
expect(setInstances, hasLength(3));
expect(setInstances.take(2).map((instance) => instance.setIndex), [0, 1]);
expect(markers.map((marker) => marker.elapsedMs), [
0,
30000,
60000,
90000,
]);
expect(markers.map((marker) => marker.boundary), [
ScopeMarkerBoundary.start,
ScopeMarkerBoundary.end,
ScopeMarkerBoundary.start,
ScopeMarkerBoundary.end,
]);
expect(markers.map((marker) => marker.label), [
'Déb. série 1',
'Fin série 1',
'Déb. série 2',
'Fin série 2',
]);
},
);
test(
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
() async {
@ -5400,6 +5660,7 @@ final class _FakeWorkoutTelemetryRepository
implements WorkoutTelemetryRepository {
final samples = <WorkoutTelemetrySample>[];
final aggregates = <WorkoutTelemetryAggregate>[];
final windowStates = <String, ActiveWorkoutTelemetryWindowState>{};
@override
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
@ -5416,6 +5677,23 @@ final class _FakeWorkoutTelemetryRepository
return 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 samples