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:
2026-07-19 19:32:24 +02:00
parent 73d01371d4
commit b1182db32e
8 changed files with 2958 additions and 6 deletions

View File

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

View File

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