feat(exercice): résolution effective et auto-advance des chronos d'étapes (ticket #76)

Ajoute la résolution effective de l'auto-enchaînement (application/use_cases.dart)
et son support dans l'adapter Drift (infrastructure/local/drift_repositories.dart).
flutter pub get OK, dart format appliqué, analyze propre (mêmes infos
préexistantes), tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 13:56:49 +02:00
parent bb90df6411
commit ea1e240c87
4 changed files with 596 additions and 169 deletions

View File

@ -1214,10 +1214,8 @@ final class ProgramUseCases {
exerciseImageMediaIdsSnapshot: input.exerciseImageMediaIdsSnapshot, exerciseImageMediaIdsSnapshot: input.exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot, exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: input.exerciseStepsSnapshot, exerciseStepsSnapshot: input.exerciseStepsSnapshot,
autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot: input.autoStartNextTimedStepSnapshot,
input.autoStartNextTimedStepSnapshot, autoStartNextTimedStepOverride: input.autoStartNextTimedStepOverride,
autoStartNextTimedStepOverride:
input.autoStartNextTimedStepOverride,
exerciseArchivedSnapshot: input.exerciseArchivedSnapshot, exerciseArchivedSnapshot: input.exerciseArchivedSnapshot,
availableTimeSnapshot: input.availableTimeSnapshot, availableTimeSnapshot: input.availableTimeSnapshot,
availableRepsSnapshot: input.availableRepsSnapshot, availableRepsSnapshot: input.availableRepsSnapshot,
@ -1478,8 +1476,7 @@ final class WorkoutTemplateUseCases {
targetRepsOverride: input.targetRepsOverride, targetRepsOverride: input.targetRepsOverride,
targetScoreOverride: input.targetScoreOverride, targetScoreOverride: input.targetScoreOverride,
targetScoreTimeMsOverride: input.targetScoreTimeMsOverride, targetScoreTimeMsOverride: input.targetScoreTimeMsOverride,
autoStartNextTimedStepOverride: autoStartNextTimedStepOverride: input.autoStartNextTimedStepOverride,
input.autoStartNextTimedStepOverride,
), ),
); );
} }
@ -2387,16 +2384,43 @@ final class ActiveExerciseStepUseCases {
setIndex: setIndex, setIndex: setIndex,
); );
final now = clock.now(); final now = clock.now();
final state = await _autoAdvanceElapsedTimers( final currentState = await _requiredStepProgressState(
context, sessionId: sessionId,
await _requiredStepProgressState( programIndex: programIndex,
sessionId: sessionId, exerciseIndex: exerciseIndex,
programIndex: programIndex, setIndex: setIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
),
now,
); );
final currentStep = _currentStep(context, currentState);
if (currentState.status == ActiveExerciseStepProgressStatus.runningTimer &&
currentStep.type == ExerciseStepType.time) {
final targetMs = currentStep.defaultTargetValue * 1000;
final elapsedMs = currentState.elapsedMillisecondsAt(now);
if (elapsedMs >= targetMs) {
final completed = _stepResult(
context: context,
state: currentState,
step: currentStep,
status: SetResultStatus.completed,
now: now,
actualTimeMs: targetMs,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
note: note,
);
await sessionRepository.saveExerciseStepResult(completed);
await sessionRepository.saveExerciseStepProgressState(
_advanceState(
context,
currentState,
now,
runningTimerOverflowMs: elapsedMs - targetMs,
completedStep: currentStep,
),
);
return completed;
}
}
final state = await _autoAdvanceElapsedTimers(context, currentState, now);
final step = _currentStep(context, state); final step = _currentStep(context, state);
if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) { if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
throw const DomainException('Exercise step sequence is complete.'); throw const DomainException('Exercise step sequence is complete.');
@ -2419,7 +2443,7 @@ final class ActiveExerciseStepUseCases {
); );
await sessionRepository.saveExerciseStepResult(completed); await sessionRepository.saveExerciseStepResult(completed);
await sessionRepository.saveExerciseStepProgressState( await sessionRepository.saveExerciseStepProgressState(
_advanceState(context, state, now), _advanceState(context, state, now, completedStep: step),
); );
return completed; return completed;
} }
@ -2453,7 +2477,7 @@ final class ActiveExerciseStepUseCases {
); );
await sessionRepository.saveExerciseStepResult(skipped); await sessionRepository.saveExerciseStepResult(skipped);
await sessionRepository.saveExerciseStepProgressState( await sessionRepository.saveExerciseStepProgressState(
_advanceState(context, state, now), _advanceState(context, state, now, completedStep: step),
); );
return skipped; return skipped;
} }
@ -2491,7 +2515,7 @@ final class ActiveExerciseStepUseCases {
); );
skipped.add(result); skipped.add(result);
await sessionRepository.saveExerciseStepResult(result); await sessionRepository.saveExerciseStepResult(result);
state = _advanceState(context, state, now); state = _advanceState(context, state, now, completedStep: step);
} }
await sessionRepository.saveExerciseStepProgressState(state); await sessionRepository.saveExerciseStepProgressState(state);
return skipped; return skipped;
@ -2528,7 +2552,7 @@ final class ActiveExerciseStepUseCases {
); );
skipped.add(result); skipped.add(result);
await sessionRepository.saveExerciseStepResult(result); await sessionRepository.saveExerciseStepResult(result);
state = _advanceState(context, state, now); state = _advanceState(context, state, now, completedStep: step);
} }
await sessionRepository.saveExerciseStepProgressState(state); await sessionRepository.saveExerciseStepProgressState(state);
return skipped; return skipped;
@ -2581,6 +2605,7 @@ final class ActiveExerciseStepUseCases {
expectedPassages: snapshot.repsEnabled expectedPassages: snapshot.repsEnabled
? (snapshot.targetReps ?? 1).clamp(1, 1 << 31) ? (snapshot.targetReps ?? 1).clamp(1, 1 << 31)
: 1, : 1,
autoStartNextTimedStep: snapshot.autoStartNextTimedStepEffective,
); );
} }
@ -2638,6 +2663,7 @@ final class ActiveExerciseStepUseCases {
current, current,
now, now,
runningTimerOverflowMs: overflowMs, runningTimerOverflowMs: overflowMs,
completedStep: step,
); );
await sessionRepository.saveExerciseStepProgressState(current); await sessionRepository.saveExerciseStepProgressState(current);
} }
@ -2649,6 +2675,7 @@ final class ActiveExerciseStepUseCases {
ActiveExerciseStepProgressState state, ActiveExerciseStepProgressState state,
DateTime now, { DateTime now, {
int runningTimerOverflowMs = 0, int runningTimerOverflowMs = 0,
ExerciseStep? completedStep,
}) { }) {
var nextPassage = state.currentPassageIndex; var nextPassage = state.currentPassageIndex;
var nextStep = state.currentStepIndex + 1; var nextStep = state.currentStepIndex + 1;
@ -2666,8 +2693,15 @@ final class ActiveExerciseStepUseCases {
); );
} }
final step = context.steps[nextStep]; final step = context.steps[nextStep];
final shouldStopBeforeNextTimedStep =
!context.autoStartNextTimedStep &&
completedStep?.type == ExerciseStepType.time &&
step.type == ExerciseStepType.time;
final resolvedOverflowMs = shouldStopBeforeNextTimedStep
? 0
: runningTimerOverflowMs;
final status = step.type == ExerciseStepType.time final status = step.type == ExerciseStepType.time
? runningTimerOverflowMs > 0 ? resolvedOverflowMs > 0
? ActiveExerciseStepProgressStatus.runningTimer ? ActiveExerciseStepProgressStatus.runningTimer
: ActiveExerciseStepProgressStatus.stoppedTimer : ActiveExerciseStepProgressStatus.stoppedTimer
: ActiveExerciseStepProgressStatus.waitingManual; : ActiveExerciseStepProgressStatus.waitingManual;
@ -2678,7 +2712,7 @@ final class ActiveExerciseStepUseCases {
currentStepSnapshotId: step.id, currentStepSnapshotId: step.id,
status: status, status: status,
startedAt: status == ActiveExerciseStepProgressStatus.runningTimer startedAt: status == ActiveExerciseStepProgressStatus.runningTimer
? now.subtract(Duration(milliseconds: runningTimerOverflowMs)) ? now.subtract(Duration(milliseconds: resolvedOverflowMs))
: null, : null,
accumulatedMs: 0, accumulatedMs: 0,
lastTransitionAt: now, lastTransitionAt: now,
@ -3320,6 +3354,15 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({
} }
final exercise = exercises[exerciseIndex] as Map<String, dynamic>; final exercise = exercises[exerciseIndex] as Map<String, dynamic>;
final id = exercise['id'] as String? ?? 'exercise-$exerciseIndex'; final id = exercise['id'] as String? ?? 'exercise-$exerciseIndex';
final autoStartNextTimedStepEffective =
_templateStepChainingOverride(
decoded,
workoutTemplateProgramId: programSnapshotId,
snapshotProgramExerciseId: id,
) ??
(exercise['autoStartNextTimedStepOverride'] as bool?) ??
(exercise['autoStartNextTimedStepSnapshot'] as bool?) ??
true;
return _ResolvedExerciseSnapshot( return _ResolvedExerciseSnapshot(
programSnapshotId: programSnapshotId, programSnapshotId: programSnapshotId,
exerciseSnapshotId: id, exerciseSnapshotId: id,
@ -3338,9 +3381,31 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
autoStartNextTimedStepEffective: autoStartNextTimedStepEffective,
); );
} }
bool? _templateStepChainingOverride(
Map<String, dynamic> resolvedTemplateSnapshot, {
required String workoutTemplateProgramId,
required String snapshotProgramExerciseId,
}) {
final overrides =
(resolvedTemplateSnapshot['overrides'] as List<dynamic>? ?? const []);
for (final raw in overrides) {
if (raw is! Map) {
continue;
}
final override = Map<String, dynamic>.from(raw);
if (override['workoutTemplateProgramId'] == workoutTemplateProgramId &&
override['snapshotProgramExerciseId'] == snapshotProgramExerciseId) {
final value = override['autoStartNextTimedStepOverride'];
return value is bool ? value : null;
}
}
return null;
}
List<_SetPositionSnapshot> _listSetSnapshots( List<_SetPositionSnapshot> _listSetSnapshots(
String resolvedTemplateSnapshotJson, String resolvedTemplateSnapshotJson,
) { ) {
@ -3422,6 +3487,7 @@ final class _StepSequenceContext {
required this.setIndex, required this.setIndex,
required this.steps, required this.steps,
required this.expectedPassages, required this.expectedPassages,
required this.autoStartNextTimedStep,
}); });
final String sessionId; final String sessionId;
@ -3432,6 +3498,7 @@ final class _StepSequenceContext {
final int setIndex; final int setIndex;
final List<ExerciseStep> steps; final List<ExerciseStep> steps;
final int expectedPassages; final int expectedPassages;
final bool autoStartNextTimedStep;
} }
List<WorkoutHistorySetResult> _historyResultsFromActiveResults({ List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
@ -3523,6 +3590,10 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
autoStartNextTimedStepEffective:
(exercise['autoStartNextTimedStepOverride'] as bool?) ??
(exercise['autoStartNextTimedStepSnapshot'] as bool?) ??
true,
); );
} }
} }
@ -3546,6 +3617,7 @@ final class _ResolvedExerciseSnapshot {
this.scoreLabelSnapshot, this.scoreLabelSnapshot,
this.scoreUnitSnapshot, this.scoreUnitSnapshot,
this.steps = const [], this.steps = const [],
this.autoStartNextTimedStepEffective = true,
}); });
final String programSnapshotId; final String programSnapshotId;
@ -3563,6 +3635,7 @@ final class _ResolvedExerciseSnapshot {
final String? scoreLabelSnapshot; final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot; final String? scoreUnitSnapshot;
final List<ExerciseStep> steps; final List<ExerciseStep> steps;
final bool autoStartNextTimedStepEffective;
} }
List<ExerciseStep> _exerciseStepsFromSnapshot(Object? value) { List<ExerciseStep> _exerciseStepsFromSnapshot(Object? value) {

View File

@ -3047,8 +3047,7 @@ domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
defaultTargetReps: payload['defaultTargetReps'] as int?, defaultTargetReps: payload['defaultTargetReps'] as int?,
defaultTargetScore: (payload['defaultTargetScore'] as num?)?.toDouble(), defaultTargetScore: (payload['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: payload['defaultTargetScoreTimeMs'] as int?, defaultTargetScoreTimeMs: payload['defaultTargetScoreTimeMs'] as int?,
autoStartNextTimedStep: autoStartNextTimedStep: payload['autoStartNextTimedStep'] as bool? ?? true,
payload['autoStartNextTimedStep'] as bool? ?? true,
steps: _stepsFromPayload(payload['steps']), steps: _stepsFromPayload(payload['steps']),
archivedAt: _dateTimeFromPayload(payload['archivedAt']), archivedAt: _dateTimeFromPayload(payload['archivedAt']),
); );

View File

@ -479,49 +479,52 @@ void main() {
); );
}); });
test('program exercise snapshots step chaining setting from exercise', () async { test(
final exerciseRepository = _FakeExerciseRepository() 'program exercise snapshots step chaining setting from exercise',
..exercise = Exercise( () async {
metadata: _metadata('exercise-1'), final exerciseRepository = _FakeExerciseRepository()
name: 'Circuit', ..exercise = Exercise(
hasTimeMeasure: false, metadata: _metadata('exercise-1'),
hasRepsMeasure: true, name: 'Circuit',
hasScoreMeasure: false, hasTimeMeasure: false,
defaultTargetReps: 10, hasRepsMeasure: true,
autoStartNextTimedStep: false, hasScoreMeasure: false,
defaultTargetReps: 10,
autoStartNextTimedStep: false,
);
final programRepository = _FakeProgramRepository()
..programs.add(
Program(
metadata: _metadata('program-1'),
name: 'Programme',
defaultRestSeconds: 60,
),
);
final useCase = ProgramUseCases(
programRepository: programRepository,
exerciseRepository: exerciseRepository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
); );
final programRepository = _FakeProgramRepository()
..programs.add( final snapshot = await useCase.addExercise(
Program( programId: 'program-1',
metadata: _metadata('program-1'), exerciseId: 'exercise-1',
name: 'Programme', position: 0,
defaultRestSeconds: 60, setsCount: 3,
), enabledMeasures: const {WorkoutMeasure.reps},
); );
final useCase = ProgramUseCases(
programRepository: programRepository,
exerciseRepository: exerciseRepository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final snapshot = await useCase.addExercise( expect(snapshot.autoStartNextTimedStepSnapshot, isFalse);
programId: 'program-1', expect(snapshot.autoStartNextTimedStepOverride, isNull);
exerciseId: 'exercise-1', expect(
position: 0, snapshot.toSnapshotJson()['autoStartNextTimedStepSnapshot'],
setsCount: 3, isFalse,
enabledMeasures: const {WorkoutMeasure.reps}, );
); },
);
expect(snapshot.autoStartNextTimedStepSnapshot, isFalse);
expect(snapshot.autoStartNextTimedStepOverride, isNull);
expect(
snapshot.toSnapshotJson()['autoStartNextTimedStepSnapshot'],
isFalse,
);
});
test('program saveConfigured preserves step chaining override', () async { test('program saveConfigured preserves step chaining override', () async {
final programRepository = _FakeProgramRepository(); final programRepository = _FakeProgramRepository();
@ -596,10 +599,7 @@ void main() {
], ],
); );
expect( expect(template.overrides.single.autoStartNextTimedStepOverride, isFalse);
template.overrides.single.autoStartNextTimedStepOverride,
isFalse,
);
}); });
test('delete exercise keeps existing program snapshots unchanged', () async { test('delete exercise keeps existing program snapshots unchanged', () async {
@ -1230,6 +1230,318 @@ void main() {
}, },
); );
test(
'step chaining resolution uses template override before program override',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
autoStartNextTimedStepSnapshot: true,
autoStartNextTimedStepOverride: true,
templateAutoStartNextTimedStepOverride: false,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(milliseconds: 2500));
final view = await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(view.state.status, ActiveExerciseStepProgressStatus.stoppedTimer);
expect(view.state.currentStepIndex, 1);
expect(view.state.accumulatedMs, 0);
expect(repository.stepResults, hasLength(1));
},
);
test(
'step chaining resolution uses program override before exercise snapshot',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
autoStartNextTimedStepSnapshot: false,
autoStartNextTimedStepOverride: true,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(milliseconds: 2500));
final view = await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(
view.state.status,
ActiveExerciseStepProgressStatus.sequenceComplete,
);
expect(repository.stepResults, hasLength(2));
},
);
test(
'step chaining defaults to true when snapshot fields are absent',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
autoStartNextTimedStepSnapshot: null,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(milliseconds: 2500));
final view = await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(
view.state.status,
ActiveExerciseStepProgressStatus.sequenceComplete,
);
expect(repository.stepResults, hasLength(2));
},
);
test(
'completeCurrentStep leaves next timed step stopped when chaining disabled',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
autoStartNextTimedStepSnapshot: false,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(milliseconds: 500));
await useCase.completeCurrentStep(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
final view = await useCase.readProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(view.state.status, ActiveExerciseStepProgressStatus.stoppedTimer);
expect(view.state.currentStepIndex, 1);
expect(view.state.startedAt, isNull);
expect(view.state.accumulatedMs, 0);
},
);
test(
'completeCurrentStep starts next timed step when chaining is enabled',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
autoStartNextTimedStepSnapshot: true,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(milliseconds: 1500));
await useCase.completeCurrentStep(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
final view = await useCase.readProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(view.state.status, ActiveExerciseStepProgressStatus.runningTimer);
expect(view.state.currentStepIndex, 1);
expect(view.state.elapsedMillisecondsAt(clock.now()), 500);
},
);
test('startTimer manually starts a stopped next timed step', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
autoStartNextTimedStepSnapshot: false,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(milliseconds: 1500));
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
final running = await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(running.status, ActiveExerciseStepProgressStatus.runningTimer);
expect(running.currentStepIndex, 1);
expect(running.startedAt, clock.now());
});
test('AuthUseCases register stores token and account session', () async { test('AuthUseCases register stores token and account session', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final tokenStore = _FakeAuthTokenStore(); final tokenStore = _FakeAuthTokenStore();
@ -2622,7 +2934,26 @@ ActiveWorkoutSession _sessionWithSnapshot({
int setsCount = 2, int setsCount = 2,
int targetReps = 10, int targetReps = 10,
List<ExerciseStep> exerciseSteps = const [], List<ExerciseStep> exerciseSteps = const [],
bool? autoStartNextTimedStepSnapshot = true,
bool? autoStartNextTimedStepOverride,
bool? templateAutoStartNextTimedStepOverride,
}) { }) {
final exerciseSnapshot = {
'id': 'exercise-snapshot-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': setsCount,
'timeEnabled': false,
'repsEnabled': true,
'scoreEnabled': false,
'targetReps': targetReps,
'exerciseStepsSnapshot': exerciseSteps
.map((step) => step.toSnapshotJson())
.toList(),
if (autoStartNextTimedStepSnapshot != null)
'autoStartNextTimedStepSnapshot': autoStartNextTimedStepSnapshot,
if (autoStartNextTimedStepOverride != null)
'autoStartNextTimedStepOverride': autoStartNextTimedStepOverride,
};
return ActiveWorkoutSession( return ActiveWorkoutSession(
metadata: _metadata('session-1'), metadata: _metadata('session-1'),
status: ActiveWorkoutStatus.running, status: ActiveWorkoutStatus.running,
@ -2638,23 +2969,38 @@ ActiveWorkoutSession _sessionWithSnapshot({
'id': 'program-snapshot-1', 'id': 'program-snapshot-1',
'programNameSnapshot': 'Programme', 'programNameSnapshot': 'Programme',
'programSnapshotJson': jsonEncode({ 'programSnapshotJson': jsonEncode({
'exercises': [ 'exercises': [exerciseSnapshot],
{
'id': 'exercise-snapshot-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': setsCount,
'timeEnabled': false,
'repsEnabled': true,
'scoreEnabled': false,
'targetReps': targetReps,
'exerciseStepsSnapshot': exerciseSteps
.map((step) => step.toSnapshotJson())
.toList(),
},
],
}), }),
}, },
], ],
'overrides': [
if (templateAutoStartNextTimedStepOverride != null)
{
'workoutTemplateProgramId': 'program-snapshot-1',
'snapshotProgramExerciseId': 'exercise-snapshot-1',
'autoStartNextTimedStepOverride':
templateAutoStartNextTimedStepOverride,
},
],
}), }),
); );
} }
List<ExerciseStep> _twoTimedSteps() {
return [
_step(
id: 'step-1',
position: 0,
name: 'Gauche',
type: ExerciseStepType.time,
defaultTargetValue: 1,
),
_step(
id: 'step-2',
position: 1,
name: 'Droite',
type: ExerciseStepType.time,
defaultTargetValue: 1,
),
];
}

View File

@ -98,99 +98,108 @@ void main() {
expect(restored!.defaultTargetScore, 0); expect(restored!.defaultTargetScore, 0);
}); });
test('step chaining settings round-trip through drift repositories', () async { test(
final now = DateTime.utc(2026, 7, 17, 12); 'step chaining settings round-trip through drift repositories',
final exercise = Exercise( () async {
metadata: _metadata('exercise-chain-1', now), final now = DateTime.utc(2026, 7, 17, 12);
name: 'Circuit', final exercise = Exercise(
hasTimeMeasure: false, metadata: _metadata('exercise-chain-1', now),
hasRepsMeasure: true, name: 'Circuit',
hasScoreMeasure: false, hasTimeMeasure: false,
defaultTargetReps: 10, hasRepsMeasure: true,
autoStartNextTimedStep: false, hasScoreMeasure: false,
); defaultTargetReps: 10,
await exerciseRepository.save(exercise); autoStartNextTimedStep: false,
);
await exerciseRepository.save(exercise);
final restoredExercise = await exerciseRepository.findById( final restoredExercise = await exerciseRepository.findById(
exercise.metadata.id, exercise.metadata.id,
); );
expect(restoredExercise!.autoStartNextTimedStep, isFalse); expect(restoredExercise!.autoStartNextTimedStep, isFalse);
final programExercise = _programExercise( final programExercise = _programExercise(
'program-exercise-chain-1', 'program-exercise-chain-1',
now, now,
programId: 'program-chain-1', programId: 'program-chain-1',
position: 0, position: 0,
autoStartNextTimedStepSnapshot: false, autoStartNextTimedStepSnapshot: false,
autoStartNextTimedStepOverride: true, autoStartNextTimedStepOverride: true,
); );
await programRepository.save( await programRepository.save(
Program( Program(
metadata: _metadata('program-chain-1', now), metadata: _metadata('program-chain-1', now),
name: 'Programme', name: 'Programme',
defaultRestSeconds: 60, defaultRestSeconds: 60,
exercises: [programExercise], exercises: [programExercise],
), ),
); );
final restoredProgram = await programRepository.findById('program-chain-1'); final restoredProgram = await programRepository.findById(
expect( 'program-chain-1',
restoredProgram!.exercises.single.autoStartNextTimedStepSnapshot, );
isFalse, expect(
); restoredProgram!.exercises.single.autoStartNextTimedStepSnapshot,
expect( isFalse,
restoredProgram.exercises.single.autoStartNextTimedStepOverride, );
isTrue, expect(
); restoredProgram.exercises.single.autoStartNextTimedStepOverride,
isTrue,
);
final templateProgram = WorkoutTemplateProgram( final templateProgram = WorkoutTemplateProgram(
metadata: _metadata('template-program-chain-1', now), metadata: _metadata('template-program-chain-1', now),
workoutTemplateId: 'template-chain-1', workoutTemplateId: 'template-chain-1',
sourceProgramId: 'program-chain-1', sourceProgramId: 'program-chain-1',
position: 0, position: 0,
programNameSnapshot: 'Programme', programNameSnapshot: 'Programme',
defaultRestSecondsSnapshot: 60, defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({ programSnapshotJson: jsonEncode({
'exercises': [programExercise.toSnapshotJson()], 'exercises': [programExercise.toSnapshotJson()],
}), }),
); );
await templateRepository.save( await templateRepository.save(
WorkoutTemplate( WorkoutTemplate(
metadata: _metadata('template-chain-1', now), metadata: _metadata('template-chain-1', now),
name: 'Séance', name: 'Séance',
programs: [templateProgram], programs: [templateProgram],
overrides: [ overrides: [
WorkoutTemplateExerciseOverride( WorkoutTemplateExerciseOverride(
metadata: _metadata('override-chain-1', now), metadata: _metadata('override-chain-1', now),
workoutTemplateProgramId: templateProgram.metadata.id, workoutTemplateProgramId: templateProgram.metadata.id,
snapshotProgramExerciseId: programExercise.metadata.id, snapshotProgramExerciseId: programExercise.metadata.id,
), ),
WorkoutTemplateExerciseOverride( WorkoutTemplateExerciseOverride(
metadata: _metadata('override-chain-2', now), metadata: _metadata('override-chain-2', now),
workoutTemplateProgramId: templateProgram.metadata.id, workoutTemplateProgramId: templateProgram.metadata.id,
snapshotProgramExerciseId: 'program-exercise-chain-2', snapshotProgramExerciseId: 'program-exercise-chain-2',
autoStartNextTimedStepOverride: false, autoStartNextTimedStepOverride: false,
), ),
], ],
), ),
); );
final restoredTemplate = await templateRepository.findById( final restoredTemplate = await templateRepository.findById(
'template-chain-1', 'template-chain-1',
); );
expect( expect(
restoredTemplate!.overrides restoredTemplate!.overrides
.singleWhere((override) => override.metadata.id == 'override-chain-1') .singleWhere(
.autoStartNextTimedStepOverride, (override) => override.metadata.id == 'override-chain-1',
isNull, )
); .autoStartNextTimedStepOverride,
expect( isNull,
restoredTemplate.overrides );
.singleWhere((override) => override.metadata.id == 'override-chain-2') expect(
.autoStartNextTimedStepOverride, restoredTemplate.overrides
isFalse, .singleWhere(
); (override) => override.metadata.id == 'override-chain-2',
}); )
.autoStartNextTimedStepOverride,
isFalse,
);
},
);
test('exercise repository round-trips configured steps', () async { test('exercise repository round-trips configured steps', () async {
final now = DateTime.utc(2026, 7, 17, 12); final now = DateTime.utc(2026, 7, 17, 12);