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

@ -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 {