feat(exercice): modèle domain + Drift pour exercices à étapes (ticket #56)
Étend le modèle Drift (tables.dart, app_database.dart/.g.dart, migration schemaVersion 7→8) et la couche application (entités, use cases, repositories) pour supporter des exercices composés de plusieurs étapes. flutter pub get OK, build_runner OK, flutter analyze propre (mêmes 8 infos préexistantes), 87/87 tests verts, build APK debug validé. Premier ticket du chantier "Exercice à plusieurs étapes" (#54). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -177,6 +177,113 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('Exercise use case rejects more than eight steps', () async {
|
||||
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
||||
|
||||
await expectLater(
|
||||
useCase.create(
|
||||
name: 'Complexe',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 1,
|
||||
steps: [
|
||||
for (var index = 0; index < 9; index++)
|
||||
_step(id: 'step-$index', position: index),
|
||||
],
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Exercise use case rejects non-contiguous step positions', () async {
|
||||
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
||||
|
||||
await expectLater(
|
||||
useCase.create(
|
||||
name: 'Complexe',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 1,
|
||||
steps: [
|
||||
_step(id: 'step-1', position: 1),
|
||||
_step(id: 'step-0', position: 0),
|
||||
],
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Exercise step validation rejects invalid basic fields', () {
|
||||
expect(() => _step(name: ''), throwsA(isA<DomainException>()));
|
||||
expect(() => _step(defaultTargetValue: 0), throwsA(isA<DomainException>()));
|
||||
});
|
||||
|
||||
test('Exercise step validation rejects disabled score values', () {
|
||||
expect(
|
||||
() => _step(hasScore: false, scoreLabel: 'Charge'),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
expect(
|
||||
() => _step(hasScore: false, defaultTargetScore: 0),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'Exercise step manual score validation accepts zero and rejects negative',
|
||||
() {
|
||||
final step = _step(
|
||||
hasScore: true,
|
||||
scoreLabel: 'Charge',
|
||||
scoreUnit: 'kg',
|
||||
defaultTargetScore: 0,
|
||||
);
|
||||
|
||||
expect(step.defaultTargetScore, 0);
|
||||
expect(
|
||||
() => _step(
|
||||
hasScore: true,
|
||||
scoreLabel: 'Charge',
|
||||
scoreUnit: 'kg',
|
||||
defaultTargetScore: -1,
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
expect(
|
||||
() => _step(hasScore: true, scoreLabel: 'Charge'),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('Exercise step stopwatch score validation rejects manual fields', () {
|
||||
final step = _step(
|
||||
hasScore: true,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
defaultTargetScoreTimeMs: 12000,
|
||||
);
|
||||
|
||||
expect(step.defaultTargetScoreTimeMs, 12000);
|
||||
expect(
|
||||
() => _step(
|
||||
hasScore: true,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
defaultTargetScoreTimeMs: 0,
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
expect(
|
||||
() => _step(
|
||||
hasScore: true,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
scoreLabel: 'Charge',
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Exercise image gallery rejects a sixth image', () async {
|
||||
final repository = _FakeExerciseRepository()
|
||||
..exercise = Exercise(
|
||||
@ -959,6 +1066,34 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
ExerciseStep _step({
|
||||
String id = 'step-1',
|
||||
int position = 0,
|
||||
String name = 'Phase',
|
||||
ExerciseStepType type = ExerciseStepType.time,
|
||||
int defaultTargetValue = 30,
|
||||
bool hasScore = false,
|
||||
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
||||
String? scoreLabel,
|
||||
String? scoreUnit,
|
||||
double? defaultTargetScore,
|
||||
int? defaultTargetScoreTimeMs,
|
||||
}) {
|
||||
return ExerciseStep(
|
||||
id: id,
|
||||
position: position,
|
||||
name: name,
|
||||
type: type,
|
||||
defaultTargetValue: defaultTargetValue,
|
||||
hasScore: hasScore,
|
||||
scoreInputMode: scoreInputMode,
|
||||
scoreLabel: scoreLabel,
|
||||
scoreUnit: scoreUnit,
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
EntityMetadata _metadata(String id) {
|
||||
return EntityMetadata(
|
||||
id: id,
|
||||
|
||||
@ -79,6 +79,158 @@ void main() {
|
||||
expect(changes.every((change) => change.entityType == 'Exercise'), isTrue);
|
||||
});
|
||||
|
||||
test('exercise repository round-trips configured steps', () async {
|
||||
final now = DateTime.utc(2026, 7, 17, 12);
|
||||
final exercise = Exercise(
|
||||
metadata: _metadata('exercise-steps-1', now),
|
||||
name: 'Burpee complexe',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 1,
|
||||
steps: [
|
||||
ExerciseStep(
|
||||
id: 'step-1',
|
||||
position: 0,
|
||||
name: 'Planche',
|
||||
type: ExerciseStepType.time,
|
||||
defaultTargetValue: 20,
|
||||
),
|
||||
ExerciseStep(
|
||||
id: 'step-2',
|
||||
position: 1,
|
||||
name: 'Sauts',
|
||||
type: ExerciseStepType.reps,
|
||||
defaultTargetValue: 8,
|
||||
hasScore: true,
|
||||
scoreLabel: 'Amplitude',
|
||||
scoreUnit: 'pts',
|
||||
defaultTargetScore: 0,
|
||||
),
|
||||
ExerciseStep(
|
||||
id: 'step-3',
|
||||
position: 2,
|
||||
name: 'Sprint final',
|
||||
type: ExerciseStepType.time,
|
||||
defaultTargetValue: 10,
|
||||
hasScore: true,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
defaultTargetScoreTimeMs: 12000,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await exerciseRepository.save(exercise);
|
||||
|
||||
final restored = await exerciseRepository.findById(exercise.metadata.id);
|
||||
|
||||
expect(restored, isNotNull);
|
||||
expect(restored!.steps, hasLength(3));
|
||||
expect(restored.steps.map((step) => step.name), [
|
||||
'Planche',
|
||||
'Sauts',
|
||||
'Sprint final',
|
||||
]);
|
||||
expect(restored.steps[1].defaultTargetScore, 0);
|
||||
expect(restored.steps[2].scoreInputMode, ScoreInputMode.stopwatch);
|
||||
expect(restored.steps[2].defaultTargetScoreTimeMs, 12000);
|
||||
});
|
||||
|
||||
test(
|
||||
'program exercise step snapshot is preserved when source exercise changes',
|
||||
() async {
|
||||
final now = DateTime.utc(2026, 7, 17, 12);
|
||||
final source = Exercise(
|
||||
metadata: _metadata('exercise-source-1', now),
|
||||
name: 'Complexe original',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 1,
|
||||
steps: [
|
||||
ExerciseStep(
|
||||
id: 'step-original-1',
|
||||
position: 0,
|
||||
name: 'Phase originale A',
|
||||
type: ExerciseStepType.time,
|
||||
defaultTargetValue: 30,
|
||||
),
|
||||
ExerciseStep(
|
||||
id: 'step-original-2',
|
||||
position: 1,
|
||||
name: 'Phase originale B',
|
||||
type: ExerciseStepType.reps,
|
||||
defaultTargetValue: 12,
|
||||
),
|
||||
],
|
||||
);
|
||||
await exerciseRepository.save(source);
|
||||
await programRepository.save(
|
||||
Program(
|
||||
metadata: _metadata('program-steps-1', now),
|
||||
name: 'Programme étapes',
|
||||
defaultRestSeconds: 60,
|
||||
),
|
||||
);
|
||||
final useCase = ProgramUseCases(
|
||||
programRepository: programRepository,
|
||||
exerciseRepository: exerciseRepository,
|
||||
templateRepository: templateRepository,
|
||||
clock: _FakeClock(now),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
final snapshot = await useCase.addExercise(
|
||||
programId: 'program-steps-1',
|
||||
exerciseId: source.metadata.id,
|
||||
position: 0,
|
||||
setsCount: 1,
|
||||
enabledMeasures: const {WorkoutMeasure.reps},
|
||||
);
|
||||
await exerciseRepository.save(
|
||||
source.copyWith(
|
||||
metadata: _metadata(
|
||||
source.metadata.id,
|
||||
now.add(const Duration(minutes: 1)),
|
||||
1,
|
||||
),
|
||||
steps: [
|
||||
ExerciseStep(
|
||||
id: 'step-updated-1',
|
||||
position: 0,
|
||||
name: 'Phase modifiée',
|
||||
type: ExerciseStepType.time,
|
||||
defaultTargetValue: 45,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final restoredProgram = await programRepository.findById(
|
||||
'program-steps-1',
|
||||
);
|
||||
final restoredSnapshot = restoredProgram!.exercises.single;
|
||||
final snapshotJson = snapshot.toSnapshotJson();
|
||||
|
||||
expect(restoredSnapshot.exerciseStepsSnapshot, hasLength(2));
|
||||
expect(
|
||||
restoredSnapshot.exerciseStepsSnapshot.first.name,
|
||||
'Phase originale A',
|
||||
);
|
||||
expect(
|
||||
restoredSnapshot.exerciseStepsSnapshot.last.type,
|
||||
ExerciseStepType.reps,
|
||||
);
|
||||
expect(
|
||||
(snapshotJson['exerciseStepsSnapshot'] as List).map(
|
||||
(step) => (step as Map)['name'],
|
||||
),
|
||||
['Phase originale A', 'Phase originale B'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'removing a program exercise writes a soft delete change log entry',
|
||||
() async {
|
||||
|
||||
Reference in New Issue
Block a user