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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user