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

@ -148,7 +148,7 @@ void main() {
await columnNames('workout_telemetry_aggregates'),
contains('sample_count'),
);
expect(database.schemaVersion, 25);
expect(database.schemaVersion, 26);
});
test('exercise business types persist with category fallback', () async {
@ -499,7 +499,7 @@ CREATE TABLE pending_share_actions (
final inboxItems = await inboxRepository.listAll();
final pendingActions = await pendingRepository.listPending();
expect(version.data['user_version'], 24);
expect(version.data['user_version'], 26);
expect(inboxItems.map((item) => item.shareId), contains('share-program-1'));
expect(inboxItems.map((item) => item.shareId), contains('share-pack-1'));
expect(
@ -710,6 +710,23 @@ CREATE TABLE pending_share_actions (
maxHeartRateBpm: 150,
totalDistanceMeters: 42,
totalCaloriesKcal: 12,
historySnapshotJson: jsonEncode({
'name': 'sync-history-full',
'telemetrySamples': [
{
'id': 'telemetry:session-sync:0',
'sessionId': 'session-sync',
'capturedAt': now.toUtc().toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'stepIndex': 0,
'heartRateBpm': 120,
'distanceMeters': 42,
'caloriesKcal': 12,
},
],
}),
),
);
@ -726,6 +743,7 @@ CREATE TABLE pending_share_actions (
expect(payload['totalCaloriesKcal'], 12);
expect(payload['results'], hasLength(1));
expect(payload['stepResults'], hasLength(1));
expect(payload['telemetrySamples'], hasLength(1));
});
test('local sync pull restores exercise images and steps', () async {
@ -804,6 +822,20 @@ CREATE TABLE pending_share_actions (
'totalActiveMs': 300000,
'completed': true,
'historySnapshotJson': '{"name":"remote-history-full"}',
'telemetrySamples': [
{
'id': 'telemetry:remote-session:0',
'sessionId': 'remote-session',
'capturedAt': now.toUtc().toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'stepIndex': 0,
'heartRateBpm': 125,
'distanceMeters': 84,
'caloriesKcal': 24,
},
],
'minHeartRateBpm': 95,
'averageHeartRateBpm': 125,
'maxHeartRateBpm': 155,
@ -884,6 +916,16 @@ CREATE TABLE pending_share_actions (
expect(restored.totalCaloriesKcal, 24);
expect(restored.results.single.actualScoreTimeMs, 12000);
expect(restored.stepResults.single.actualReps, 10);
final telemetrySamples = await telemetryRepository.listSamples(
'remote-session',
);
final telemetryAggregate = await telemetryRepository.findAggregate(
sessionId: 'remote-session',
scope: WorkoutTelemetryAggregateScope.session,
);
expect(telemetrySamples, hasLength(1));
expect(telemetrySamples.single.heartRateBpm, 125);
expect(telemetryAggregate!.totalDistanceMeters, 84);
});
test('local sync pull defaults missing tags to empty lists', () async {
@ -1610,7 +1652,7 @@ CREATE TABLE pending_share_actions (
).run();
expect(result.status, StarterSeedStatus.inserted);
expect(await seedRepository.readAppliedStarterSeedVersion(), 1);
expect(await seedRepository.readAppliedStarterSeedVersion(), 2);
final exercises = await exerciseRepository.listActive();
final programs = await programRepository.listActive();
@ -1621,15 +1663,23 @@ CREATE TABLE pending_share_actions (
expect(exercises.every((exercise) => exercise.isExample), isTrue);
expect(programs.single.isExample, isTrue);
expect(templates.single.isExample, isTrue);
expect(exercises.map((exercise) => exercise.category).toSet(), {
ExerciseCategory.shoot,
ExerciseCategory.freeThrows,
ExerciseCategory.dribble,
ExerciseCategory.finishing,
ExerciseCategory.conditioning,
ExerciseCategory.defense,
ExerciseCategory.mobility,
});
expect(
exercises.every(
(exercise) => exercise.category == ExerciseCategory.uncategorized,
),
isTrue,
);
expect(
exercises.every((exercise) => exercise.businessTypes.isNotEmpty),
isTrue,
);
expect(exercises.every((exercise) => exercise.tags.isNotEmpty), isTrue);
expect(programs.single.tags, ['fondations', 'basket']);
expect(templates.single.tags, ['fondations', 'séance']);
expect(
exercises.map((exercise) => exercise.metadata.id),
everyElement(startsWith('starter-v2-')),
);
final secondRun = await SeedStarterContentUseCase(
seedStateRepository: seedRepository,
@ -1665,7 +1715,7 @@ CREATE TABLE pending_share_actions (
).run();
expect(result.status, StarterSeedStatus.skippedNotEmpty);
expect(await seedRepository.readAppliedStarterSeedVersion(), 1);
expect(await seedRepository.readAppliedStarterSeedVersion(), 2);
expect(await exerciseRepository.listActive(), hasLength(1));
expect(await programRepository.listActive(), isEmpty);
expect(await templateRepository.listActive(), isEmpty);
@ -2193,6 +2243,27 @@ CREATE TABLE pending_share_actions (
for (final roundTripCase in cases) {
test(roundTripCase.label, () async {
final referencedMediaIds = {
...roundTripCase.exercise.imageMediaIds,
...[
roundTripCase.exercise.iconMediaId,
roundTripCase.exercise.videoMediaId,
].nonNulls,
};
for (final mediaId in referencedMediaIds) {
await mediaAssetRepository.save(
MediaAsset(
metadata: _metadata(
mediaId,
roundTripCase.exercise.metadata.createdAt,
),
kind: mediaId.startsWith('video-')
? MediaKind.video
: MediaKind.image,
localUri: 'file:///$mediaId',
),
);
}
await exerciseRepository.save(roundTripCase.exercise);
final restored = await exerciseRepository.findById(
@ -3450,6 +3521,7 @@ WorkoutHistory _history({
int? maxHeartRateBpm,
double? totalDistanceMeters,
double? totalCaloriesKcal,
String? historySnapshotJson,
}) {
return WorkoutHistory(
metadata: _metadata(id, startedAt),
@ -3458,7 +3530,7 @@ WorkoutHistory _history({
endedAt: startedAt.add(const Duration(minutes: 5)),
totalActiveMs: totalActiveMs,
completed: completed,
historySnapshotJson: '{"name":"$id"}',
historySnapshotJson: historySnapshotJson ?? '{"name":"$id"}',
results: results ?? [result!],
stepResults: stepResults,
minHeartRateBpm: minHeartRateBpm,