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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -5,6 +5,7 @@ import 'package:gametime/application/application.dart';
|
||||
import 'package:gametime/domain/domain.dart';
|
||||
import 'package:gametime/infrastructure/infrastructure.dart'
|
||||
hide
|
||||
ActiveWorkoutTelemetryWindowState,
|
||||
WorkoutHistory,
|
||||
WorkoutHistorySetResult,
|
||||
WorkoutHistoryStepResult,
|
||||
@ -311,6 +312,25 @@ void main() {
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(telemetryRepository.samples, isEmpty);
|
||||
|
||||
native.emitSensorSample(
|
||||
WatchSensorSample(
|
||||
sampleId: 'sample-2',
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: _now
|
||||
.add(const Duration(seconds: 20))
|
||||
.millisecondsSinceEpoch,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
stepIndex: 0,
|
||||
heartRateBpm: 130,
|
||||
distanceMeters: 520,
|
||||
caloriesKcal: 45,
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(
|
||||
telemetryRepository.samples.single.id,
|
||||
@ -658,6 +678,7 @@ final class _FakeWorkoutTelemetryRepository
|
||||
implements WorkoutTelemetryRepository {
|
||||
final samples = <WorkoutTelemetrySample>[];
|
||||
final aggregates = <WorkoutTelemetryAggregate>[];
|
||||
final windowStates = <String, ActiveWorkoutTelemetryWindowState>{};
|
||||
|
||||
@override
|
||||
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
|
||||
@ -668,6 +689,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
|
||||
|
||||
@ -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 l’unité et affiche le badge', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 [];
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user