feat(core): consolide modele donnees stats par scope, types metier d'exercice et correctif contrainte active_exercise_step_progress_states

Couche domaine/application/infra locale partagee par #179 (stockage stats par etape/serie/exercice/seance), #182 (correctif contrainte UNIQUE active_exercise_step_progress_states) et #183 (modele/persistance types metier d'exercice).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:55:52 +02:00
parent 7b5801f959
commit fdaa518fa9
7 changed files with 1036 additions and 80 deletions

View File

@ -3324,22 +3324,6 @@ void main() {
),
);
final duplicate = await useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
28,
10,
1,
).millisecondsSinceEpoch,
heartRateBpm: 160,
distanceMeters: 300,
caloriesKcal: 10,
),
);
final second = await useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-2',
sessionId: 'session-1',
@ -3348,7 +3332,42 @@ void main() {
7,
28,
10,
2,
0,
10,
).millisecondsSinceEpoch,
heartRateBpm: 160,
distanceMeters: 530,
caloriesKcal: 45,
),
);
final olderInSameBucket = await useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-older',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
28,
10,
0,
5,
).millisecondsSinceEpoch,
heartRateBpm: 130,
distanceMeters: 510,
caloriesKcal: 43,
),
);
final second = await useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-3',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
28,
10,
0,
20,
).millisecondsSinceEpoch,
programIndex: 0,
exerciseIndex: 0,
@ -3367,17 +3386,19 @@ void main() {
WorkoutTelemetryAggregateScope.set,
WorkoutTelemetryAggregateScope.step,
]);
expect(duplicate, isEmpty);
expect(duplicate, isNotEmpty);
expect(olderInSameBucket, isEmpty);
expect(repository.samples, hasLength(2));
expect(repository.samples.first.heartRateBpm, 160);
final sessionAggregate = second.singleWhere(
(aggregate) =>
aggregate.scope == WorkoutTelemetryAggregateScope.session,
);
expect(sessionAggregate.sampleCount, 2);
expect(sessionAggregate.minHeartRateBpm, 120);
expect(sessionAggregate.averageHeartRateBpm, 135);
expect(sessionAggregate.maxHeartRateBpm, 150);
expect(sessionAggregate.minHeartRateBpm, 150);
expect(sessionAggregate.averageHeartRateBpm, 155);
expect(sessionAggregate.maxHeartRateBpm, 160);
expect(sessionAggregate.totalDistanceMeters, 620);
expect(sessionAggregate.totalCaloriesKcal, 48);
expect(
@ -3394,6 +3415,79 @@ void main() {
},
);
test(
'WorkoutTelemetryUseCases reads graph samples by scope with relative cumulative metrics',
() 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'),
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-1',
'sessionId': 'session-1',
'capturedAt': DateTime.utc(2026, 7, 28, 10).toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'stepIndex': 0,
'heartRateBpm': 120,
'distanceMeters': 500,
'caloriesKcal': 42,
},
{
'id': 'sample-2',
'sessionId': 'session-1',
'capturedAt': DateTime.utc(2026, 7, 28, 10, 3).toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'stepIndex': 0,
'heartRateBpm': 150,
'distanceMeters': 620,
'caloriesKcal': 48,
},
{
'id': 'sample-other',
'sessionId': 'session-1',
'capturedAt': DateTime.utc(2026, 7, 28, 10, 4).toIso8601String(),
'programIndex': 0,
'exerciseIndex': 1,
'heartRateBpm': 90,
},
],
}),
);
final series = await useCase.readGraphSeriesForHistory(
history: history,
scope: WorkoutTelemetryAggregateScope.step,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
);
expect(series.points, hasLength(2));
expect(series.minHeartRateBpm, 120);
expect(series.maxHeartRateBpm, 150);
expect(series.points.first.distanceMeters, 0);
expect(series.points.last.distanceMeters, 120);
expect(series.points.last.caloriesKcal, 6);
expect(series.points.last.startsAfterGap, isTrue);
},
);
test(
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
() async {
@ -5309,10 +5403,15 @@ final class _FakeWorkoutTelemetryRepository
@override
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
if (samples.any((existing) => existing.id == sample.id)) {
final index = samples.indexWhere((existing) => existing.id == sample.id);
if (index != -1 && !sample.capturedAt.isAfter(samples[index].capturedAt)) {
return false;
}
samples.add(sample);
if (index == -1) {
samples.add(sample);
} else {
samples[index] = sample;
}
samples.sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
return true;
}
@ -5324,6 +5423,33 @@ final class _FakeWorkoutTelemetryRepository
.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,
@ -5369,6 +5495,33 @@ final class _FakeWorkoutTelemetryRepository
}
}
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,
};
}
ActiveWorkoutSessionUseCases _activeUseCase(
_FakeActiveSessionRepository repository,
_FakeClock clock,