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,

View File

@ -124,6 +124,7 @@ void main() {
}
expect(await columnNames('exercises'), contains('tags_json'));
expect(await columnNames('exercises'), contains('business_types_json'));
expect(await columnNames('programs'), contains('tags_json'));
expect(await columnNames('workout_templates'), contains('tags_json'));
expect(
@ -143,7 +144,55 @@ void main() {
await columnNames('workout_telemetry_aggregates'),
contains('sample_count'),
);
expect(database.schemaVersion, 24);
expect(database.schemaVersion, 25);
});
test('exercise business types persist with category fallback', () async {
final now = DateTime.utc(2026, 7, 30, 12);
final exercise = Exercise(
metadata: _metadata('exercise-business-types', now),
name: 'Dribble intense',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
category: ExerciseCategory.shoot,
businessTypes: const [
BusinessExerciseType.dribble,
BusinessExerciseType.highIntensity,
BusinessExerciseType.dribble,
],
);
await exerciseRepository.save(exercise);
final restoredExercise = await exerciseRepository.findById(
exercise.metadata.id,
);
expect(restoredExercise?.businessTypes, [
BusinessExerciseType.dribble,
BusinessExerciseType.highIntensity,
]);
expect(restoredExercise?.effectiveBusinessTypes, [
BusinessExerciseType.dribble,
BusinessExerciseType.highIntensity,
]);
final legacyExercise = Exercise(
metadata: _metadata('exercise-business-types-legacy', now),
name: 'Shoot',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
category: ExerciseCategory.shoot,
);
await exerciseRepository.save(legacyExercise);
final restoredLegacy = await exerciseRepository.findById(
legacyExercise.metadata.id,
);
expect(restoredLegacy?.businessTypes, isEmpty);
expect(restoredLegacy?.effectiveBusinessTypes, [
BusinessExerciseType.shoot,
]);
});
test(
@ -165,17 +214,56 @@ void main() {
id: 'sample-2',
sessionId: 'session-1',
capturedAt: DateTime.utc(2026, 7, 28, 10, 1),
programIndex: 0,
exerciseIndex: 1,
heartRateBpm: 150,
distanceMeters: 620,
caloriesKcal: 48,
);
final replacement = WorkoutTelemetrySample(
id: 'sample-1',
sessionId: 'session-1',
capturedAt: DateTime.utc(2026, 7, 28, 10, 0, 10),
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
heartRateBpm: 130,
distanceMeters: 530,
caloriesKcal: 45,
);
final olderReplacement = WorkoutTelemetrySample(
id: 'sample-1',
sessionId: 'session-1',
capturedAt: DateTime.utc(2026, 7, 28, 10, 0, 5),
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
heartRateBpm: 125,
distanceMeters: 510,
caloriesKcal: 43,
);
expect(await telemetryRepository.saveSample(first), isTrue);
expect(await telemetryRepository.saveSample(first), isFalse);
expect(await telemetryRepository.saveSample(replacement), isTrue);
expect(await telemetryRepository.saveSample(olderReplacement), isFalse);
expect(await telemetryRepository.saveSample(second), isTrue);
final samples = await telemetryRepository.listSamples('session-1');
expect(samples.map((sample) => sample.id), ['sample-1', 'sample-2']);
expect(samples.first.heartRateBpm, 130);
final stepSamples = await telemetryRepository.listSamplesForScope(
sessionId: 'session-1',
scope: WorkoutTelemetryAggregateScope.step,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
);
expect(stepSamples, hasLength(1));
expect(stepSamples.single.distanceMeters, 530);
await telemetryRepository.replaceAggregatesForSession(
sessionId: 'session-1',
@ -484,6 +572,41 @@ CREATE TABLE pending_share_actions (
expect(payloadsById['template-sync-tags']!['tags'], ['routine']);
});
test('local sync payload includes exercise business types', () async {
final now = DateTime.utc(2026, 7, 30, 13);
final exercise = Exercise(
metadata: _metadata('exercise-sync-business-types', now),
name: 'Drive',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
);
await exerciseRepository.save(exercise);
final updatedExercise = exercise.copyWith(
metadata: _metadata(
'exercise-sync-business-types',
now.add(const Duration(seconds: 1)),
1,
),
businessTypes: const [
BusinessExerciseType.dribble,
BusinessExerciseType.finishing,
],
);
await exerciseRepository.save(updatedExercise);
final changes = await syncChangeRepository.listPendingChanges();
final payloadsById = {
for (final change in changes) change.item.clientId: change.item.payload,
};
expect(payloadsById['exercise-sync-business-types']!['businessTypes'], [
'dribble',
'finishing',
]);
});
test('local sync payload includes full workout history aggregate', () async {
final now = DateTime.utc(2026, 7, 22, 10, 45);
await historyRepository.save(
@ -2249,6 +2372,82 @@ CREATE TABLE pending_share_actions (
},
);
test(
'active exercise step progress keeps stored identity when upserted by position',
() async {
final now = DateTime.utc(2026, 7, 17, 12);
await activeRepository.save(
ActiveWorkoutSession(
metadata: _metadata('session-step-progress-upsert', now),
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
),
);
await activeRepository.saveExerciseStepProgressState(
ActiveExerciseStepProgressState(
metadata: _metadata('step-progress-original', now),
activeWorkoutSessionId: 'session-step-progress-upsert',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
currentPassageIndex: 0,
currentStepIndex: 0,
currentStepSnapshotId: 'step-snapshot-1',
status: ActiveExerciseStepProgressStatus.runningTimer,
startedAt: now,
accumulatedMs: 0,
lastTransitionAt: now,
),
);
final updatedAt = now.add(const Duration(minutes: 1));
await activeRepository.saveExerciseStepProgressState(
ActiveExerciseStepProgressState(
metadata: _metadata('step-progress-new-id', updatedAt, 1),
activeWorkoutSessionId: 'session-step-progress-upsert',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
currentPassageIndex: 0,
currentStepIndex: 1,
currentStepSnapshotId: 'step-snapshot-2',
status: ActiveExerciseStepProgressStatus.stoppedTimer,
accumulatedMs: 30000,
lastTransitionAt: updatedAt,
),
);
final rows = await database
.select(database.activeExerciseStepProgressStates)
.get();
final row =
await (database.select(database.activeExerciseStepProgressStates)
..where((table) => table.id.equals('step-progress-original')))
.getSingle();
final changes =
await (database.select(database.changeLogEntries)..where(
(table) =>
table.entityId.equals('step-progress-original') &
table.operation.equals('update'),
))
.get();
expect(rows, hasLength(1));
expect(row.createdAt.toUtc(), now);
expect(row.currentStepIndex, 1);
expect(row.currentStepSnapshotId, 'step-snapshot-2');
expect(row.status, 'stoppedTimer');
expect(row.accumulatedMs, 30000);
expect(changes.map((change) => change.localRevision), [1]);
},
);
test(
'closing a session stores autonomous history rows with set snapshots',
() async {