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