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