import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; import 'package:gametime/domain/domain.dart'; import 'package:watch_bridge_contract/watch_bridge_contract.dart'; void main() { test('Exercise requires at least one active measure', () { expect( () => Exercise( metadata: _metadata('exercise-1'), name: 'Push up', hasTimeMeasure: false, hasRepsMeasure: false, hasScoreMeasure: false, ), throwsA(isA()), ); }); test('Exercise rejects manual score defaults on stopwatch score mode', () { expect( () => Exercise( metadata: _metadata('exercise-1'), name: 'Sprint', hasTimeMeasure: false, hasRepsMeasure: false, hasScoreMeasure: true, scoreInputMode: ScoreInputMode.stopwatch, defaultTargetScore: 10, ), throwsA(isA()), ); expect( () => Exercise( metadata: _metadata('exercise-2'), name: 'Sprint', hasTimeMeasure: false, hasRepsMeasure: false, hasScoreMeasure: true, scoreInputMode: ScoreInputMode.stopwatch, defaultTargetScore: 10, defaultTargetScoreTimeMs: 12000, ), throwsA(isA()), ); }); test('Program exercise allows reps or time with stopwatch score', () { final repsAndScore = ProgramExercise( metadata: _metadata('program-exercise-1'), programId: 'program-1', position: 0, exerciseNameSnapshot: 'Sprint reps', availableTimeSnapshot: false, availableRepsSnapshot: true, availableScoreSnapshot: true, scoreInputModeSnapshot: ScoreInputMode.stopwatch, setsCount: 3, timeEnabled: false, repsEnabled: true, scoreEnabled: true, targetReps: 10, targetScoreTimeMs: 12000, ); final timeAndScore = ProgramExercise( metadata: _metadata('program-exercise-2'), programId: 'program-1', position: 1, exerciseNameSnapshot: 'Sprint time', availableTimeSnapshot: true, availableRepsSnapshot: false, availableScoreSnapshot: true, scoreInputModeSnapshot: ScoreInputMode.stopwatch, setsCount: 3, timeEnabled: true, repsEnabled: false, scoreEnabled: true, targetTimeSeconds: 30, targetScoreTimeMs: 12000, ); expect(repsAndScore.scoreInputModeSnapshot, ScoreInputMode.stopwatch); expect(timeAndScore.scoreInputModeSnapshot, ScoreInputMode.stopwatch); }); test('Program exercise rejects manual score target on stopwatch score', () { expect( () => ProgramExercise( metadata: _metadata('program-exercise-1'), programId: 'program-1', position: 0, exerciseNameSnapshot: 'Sprint score', availableTimeSnapshot: false, availableRepsSnapshot: true, availableScoreSnapshot: true, scoreInputModeSnapshot: ScoreInputMode.stopwatch, setsCount: 3, timeEnabled: false, repsEnabled: true, scoreEnabled: true, targetReps: 10, targetScore: 10, targetScoreTimeMs: 12000, ), throwsA(isA()), ); }); test( 'Exercise use case rejects active measures without default targets', () async { final useCase = _exerciseUseCase(_FakeExerciseRepository()); await expectLater( useCase.create( name: 'Squat', hasTimeMeasure: true, hasRepsMeasure: false, hasScoreMeasure: false, ), throwsA(isA()), ); await expectLater( useCase.create( name: 'Pompes', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, ), throwsA(isA()), ); await expectLater( useCase.create( name: 'Charge', hasTimeMeasure: false, hasRepsMeasure: false, hasScoreMeasure: true, scoreLabel: 'Charge', scoreUnit: 'kg', ), throwsA(isA()), ); }, ); test('Exercise use case rejects invalid default targets', () async { final useCase = _exerciseUseCase(_FakeExerciseRepository()); await expectLater( useCase.create( name: 'Squat', hasTimeMeasure: true, hasRepsMeasure: false, hasScoreMeasure: false, defaultTargetTimeSeconds: 0, ), throwsA(isA()), ); await expectLater( useCase.create( name: 'Pompes', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: -1, ), throwsA(isA()), ); await expectLater( useCase.create( name: 'Charge', hasTimeMeasure: false, hasRepsMeasure: false, hasScoreMeasure: true, scoreLabel: 'Charge', scoreUnit: 'kg', defaultTargetScore: -1, ), throwsA(isA()), ); }); test( 'Exercise use case accepts a zero manual score default target', () async { final repository = _FakeExerciseRepository(); final useCase = _exerciseUseCase(repository); final exercise = await useCase.create( name: 'Charge', hasTimeMeasure: false, hasRepsMeasure: false, hasScoreMeasure: true, scoreLabel: 'Charge', scoreUnit: 'kg', defaultTargetScore: 0, ); expect(exercise.defaultTargetScore, 0); expect(repository.saved.single.defaultTargetScore, 0); }, ); test( 'Exercise use case accepts stopwatch score without default target', () async { final repository = _FakeExerciseRepository(); final useCase = _exerciseUseCase(repository); final exercise = await useCase.create( name: 'Sprint', hasTimeMeasure: false, hasRepsMeasure: false, hasScoreMeasure: true, scoreInputMode: ScoreInputMode.stopwatch, scoreLabel: 'Temps réalisé', ); expect(exercise.defaultTargetScoreTimeMs, isNull); expect(repository.saved.single.metadata.id, exercise.metadata.id); }, ); test( 'Exercise use case rejects non-positive stopwatch score default target', () async { final useCase = _exerciseUseCase(_FakeExerciseRepository()); await expectLater( useCase.create( name: 'Sprint', hasTimeMeasure: false, hasRepsMeasure: false, hasScoreMeasure: true, scoreInputMode: ScoreInputMode.stopwatch, scoreLabel: 'Temps réalisé', defaultTargetScoreTimeMs: 0, ), throwsA(isA()), ); }, ); test('Exercise update validates default targets too', () async { final repository = _FakeExerciseRepository() ..exercise = Exercise( metadata: _metadata('exercise-1'), name: 'Squat', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, ); final useCase = _exerciseUseCase(repository); await expectLater( useCase.update( id: 'exercise-1', name: 'Squat', hasTimeMeasure: true, hasRepsMeasure: false, hasScoreMeasure: false, ), throwsA(isA()), ); }); 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()), ); }); 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()), ); }); test('Exercise step validation rejects invalid basic fields', () { expect(() => _step(name: ''), throwsA(isA())); expect(() => _step(defaultTargetValue: 0), throwsA(isA())); }); test('Exercise step validation rejects disabled score values', () { expect( () => _step(hasScore: false, scoreLabel: 'Charge'), throwsA(isA()), ); expect( () => _step(hasScore: false, defaultTargetScore: 0), throwsA(isA()), ); }); 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()), ); expect( () => _step(hasScore: true, scoreLabel: 'Charge'), throwsA(isA()), ); }, ); 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()), ); expect( () => _step( hasScore: true, scoreInputMode: ScoreInputMode.stopwatch, scoreLabel: 'Charge', ), throwsA(isA()), ); }); test('linked step score requires manual series score', () async { final linkedStep = _step( hasScore: true, scoreLabel: 'Paniers', scoreUnit: 'pts', linkedToSeriesScore: true, ); final useCase = _exerciseUseCase(_FakeExerciseRepository()); await expectLater( useCase.create( name: 'Tirs', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 1, steps: [linkedStep], ), throwsA(isA()), ); await expectLater( useCase.create( name: 'Tirs chrono', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: true, scoreInputMode: ScoreInputMode.stopwatch, defaultTargetReps: 1, steps: [linkedStep], ), throwsA(isA()), ); }); test('Exercise image gallery rejects a sixth image', () async { final repository = _FakeExerciseRepository() ..exercise = Exercise( metadata: _metadata('exercise-1'), name: 'Squat', imageMediaIds: const [ 'media-1', 'media-2', 'media-3', 'media-4', 'media-5', ], hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, ); final useCase = _exerciseUseCase(repository); await expectLater( useCase.addImage(exerciseId: 'exercise-1', mediaAssetId: 'media-6'), throwsA(isA()), ); }); test('Exercise image gallery reorders images', () async { final repository = _FakeExerciseRepository() ..exercise = Exercise( metadata: _metadata('exercise-1'), name: 'Squat', imageMediaIds: const ['media-1', 'media-2', 'media-3'], hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, ); final useCase = _exerciseUseCase(repository); final updated = await useCase.reorderImages( exerciseId: 'exercise-1', orderedMediaIds: const ['media-3', 'media-1', 'media-2'], ); expect(updated.imageMediaIds, ['media-3', 'media-1', 'media-2']); expect(repository.saved.single.imageMediaIds, updated.imageMediaIds); }); test('Exercise image gallery removes an image', () async { final repository = _FakeExerciseRepository() ..exercise = Exercise( metadata: _metadata('exercise-1'), name: 'Squat', imageMediaIds: const ['media-1', 'media-2', 'media-3'], hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, ); final useCase = _exerciseUseCase(repository); final updated = await useCase.removeImage( exerciseId: 'exercise-1', mediaAssetId: 'media-2', ); expect(updated.imageMediaIds, ['media-1', 'media-3']); expect(updated.imageMediaId, 'media-1'); }); test('Exercise icon can be set to an image from the gallery', () async { final repository = _FakeExerciseRepository() ..exercise = Exercise( metadata: _metadata('exercise-1'), name: 'Squat', imageMediaIds: const ['media-1', 'media-2'], hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, ); final useCase = _exerciseUseCase(repository); final updated = await useCase.setIcon( exerciseId: 'exercise-1', mediaAssetId: 'media-2', ); expect(updated.iconMediaId, 'media-2'); expect(repository.saved.single.iconMediaId, 'media-2'); }); test('Exercise icon rejects images outside the gallery', () async { final repository = _FakeExerciseRepository() ..exercise = Exercise( metadata: _metadata('exercise-1'), name: 'Squat', imageMediaIds: const ['media-1'], hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, ); final useCase = _exerciseUseCase(repository); await expectLater( useCase.setIcon(exerciseId: 'exercise-1', mediaAssetId: 'media-2'), throwsA(isA()), ); expect(repository.saved, isEmpty); }); test('Exercise icon is reset when its image is removed', () async { final repository = _FakeExerciseRepository() ..exercise = Exercise( metadata: _metadata('exercise-1'), name: 'Squat', imageMediaIds: const ['media-1', 'media-2'], iconMediaId: 'media-2', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, ); final useCase = _exerciseUseCase(repository); final updated = await useCase.removeImage( exerciseId: 'exercise-1', mediaAssetId: 'media-2', ); expect(updated.imageMediaIds, ['media-1']); expect(updated.iconMediaId, isNull); expect(repository.saved.single.iconMediaId, isNull); }); test('program exercise snapshot keeps the full image gallery', () async { final exerciseRepository = _FakeExerciseRepository() ..exercise = Exercise( metadata: _metadata('exercise-1'), name: 'Squat', imageMediaIds: const ['media-1', 'media-2', 'media-3'], videoMediaId: 'video-1', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, ); final programRepository = _FakeProgramRepository() ..programs.add( Program( metadata: _metadata('program-1'), name: 'Jambes', 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 snapshot = await useCase.addExercise( programId: 'program-1', exerciseId: 'exercise-1', position: 0, setsCount: 3, enabledMeasures: const {WorkoutMeasure.reps}, ); expect(snapshot.exerciseImageMediaIdSnapshot, 'media-1'); expect(snapshot.exerciseImageMediaIdsSnapshot, [ 'media-1', 'media-2', 'media-3', ]); expect(snapshot.exerciseVideoMediaIdSnapshot, 'video-1'); expect(snapshot.toSnapshotJson()['imageMediaIdsSnapshot'], [ 'media-1', 'media-2', 'media-3', ]); expect( programRepository .programs .single .exercises .single .exerciseImageMediaIdsSnapshot, snapshot.exerciseImageMediaIdsSnapshot, ); }); test( 'program exercise snapshots step chaining setting from exercise', () async { final exerciseRepository = _FakeExerciseRepository() ..exercise = Exercise( metadata: _metadata('exercise-1'), name: 'Circuit', hasTimeMeasure: false, hasRepsMeasure: true, 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 snapshot = await useCase.addExercise( programId: 'program-1', exerciseId: 'exercise-1', position: 0, setsCount: 3, 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 { final programRepository = _FakeProgramRepository(); final useCase = ProgramUseCases( programRepository: programRepository, exerciseRepository: _FakeExerciseRepository(), templateRepository: _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); final program = await useCase.saveConfigured( name: 'Programme', defaultRestSeconds: 60, exercises: const [ ProgramExerciseConfig( exerciseNameSnapshot: 'Circuit', availableTimeSnapshot: false, availableRepsSnapshot: true, availableScoreSnapshot: false, setsCount: 3, enabledMeasures: {WorkoutMeasure.reps}, targetReps: 10, autoStartNextTimedStepSnapshot: true, autoStartNextTimedStepOverride: false, ), ], ); expect(program.exercises.single.autoStartNextTimedStepSnapshot, isTrue); expect(program.exercises.single.autoStartNextTimedStepOverride, isFalse); }); test('program saveConfigured rejects empty exercise list', () async { final useCase = ProgramUseCases( programRepository: _FakeProgramRepository(), exerciseRepository: _FakeExerciseRepository(), templateRepository: _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); await expectLater( useCase.saveConfigured( name: 'Programme', defaultRestSeconds: 60, exercises: const [], ), throwsA(isA()), ); }); test('workout template override preserves step chaining override', () async { final templateRepository = _FakeWorkoutTemplateRepository(); final useCase = WorkoutTemplateUseCases( templateRepository: templateRepository, programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); final template = await useCase.saveConfigured( name: 'Séance', programs: [ WorkoutTemplateProgramConfig( clientKey: 'program-1', programNameSnapshot: 'Programme', defaultRestSecondsSnapshot: 60, programSnapshotJson: jsonEncode({ 'exercises': [ { 'id': 'exercise-snapshot-1', 'exerciseNameSnapshot': 'Circuit', 'setsCount': 3, 'timeEnabled': false, 'repsEnabled': true, 'scoreEnabled': false, }, ], }), ), ], overrides: const [ WorkoutTemplateExerciseOverrideConfig( workoutTemplateProgramClientKey: 'program-1', snapshotProgramExerciseId: 'exercise-snapshot-1', autoStartNextTimedStepOverride: false, ), ], ); expect(template.overrides.single.autoStartNextTimedStepOverride, isFalse); }); test('workout template saveConfigured rejects empty program list', () async { final useCase = WorkoutTemplateUseCases( templateRepository: _FakeWorkoutTemplateRepository(), programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); await expectLater( useCase.saveConfigured( name: 'Séance', programs: const [], overrides: const [], ), throwsA(isA()), ); }); test( 'workout template saveConfigured rejects program snapshot without sets', () async { final useCase = WorkoutTemplateUseCases( templateRepository: _FakeWorkoutTemplateRepository(), programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); await expectLater( useCase.saveConfigured( name: 'Séance', programs: [ WorkoutTemplateProgramConfig( clientKey: 'program-1', programNameSnapshot: 'Programme', defaultRestSecondsSnapshot: 60, programSnapshotJson: jsonEncode({'exercises': const []}), ), ], overrides: const [], ), throwsA(isA()), ); }, ); test('delete exercise keeps existing program snapshots unchanged', () async { final exerciseRepository = _FakeExerciseRepository() ..exercise = Exercise( metadata: _metadata('exercise-1'), name: 'Squat', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, ); final programRepository = _FakeProgramRepository() ..programs.add( Program( metadata: _metadata('program-1'), name: 'Jambes', defaultRestSeconds: 60, exercises: [ _programExercise( id: 'program-exercise-1', programId: 'program-1', sourceExerciseId: 'exercise-1', position: 0, ), ], ), ); final useCase = _exerciseUseCase( exerciseRepository, programRepository: programRepository, ); final deleted = await useCase.delete('exercise-1'); expect(deleted.metadata.deletedAt, isNotNull); expect(exerciseRepository.listActive(), completion(isEmpty)); expect(programRepository.programs.single.exercises, hasLength(1)); expect( programRepository.programs.single.exercises.single.sourceExerciseId, 'exercise-1', ); expect( programRepository.programs.single.exercises.single.exerciseNameSnapshot, 'Exercise program-exercise-1', ); expect(programRepository.programs.single.exercises.single.position, 0); }); test( 'delete program removes it from referencing workout templates', () async { final programRepository = _FakeProgramRepository() ..programs.add( Program( metadata: _metadata('program-1'), name: 'Jambes', defaultRestSeconds: 60, ), ); final templateRepository = _FakeWorkoutTemplateRepository() ..templates.add( WorkoutTemplate( metadata: _metadata('template-1'), name: 'Séance', programs: [ _templateProgram( id: 'template-program-1', workoutTemplateId: 'template-1', sourceProgramId: 'program-1', position: 0, ), _templateProgram( id: 'template-program-2', workoutTemplateId: 'template-1', sourceProgramId: 'program-2', position: 1, ), ], overrides: [ WorkoutTemplateExerciseOverride( metadata: _metadata('override-1'), workoutTemplateProgramId: 'template-program-1', snapshotProgramExerciseId: 'exercise-snapshot-1', targetRepsOverride: 8, ), ], ), ); final useCase = ProgramUseCases( programRepository: programRepository, exerciseRepository: _FakeExerciseRepository(), templateRepository: templateRepository, clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); final deleted = await useCase.delete('program-1'); expect(deleted.metadata.deletedAt, isNotNull); expect(programRepository.listActive(), completion(isEmpty)); final template = templateRepository.templates.single; expect(template.programs, hasLength(1)); expect(template.programs.single.sourceProgramId, 'program-2'); expect(template.programs.single.position, 0); expect(template.overrides, isEmpty); }, ); test('delete workout template hides it from active list', () async { final templateRepository = _FakeWorkoutTemplateRepository() ..templates.add( WorkoutTemplate(metadata: _metadata('template-1'), name: 'Séance'), ); final useCase = WorkoutTemplateUseCases( templateRepository: templateRepository, programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); final deleted = await useCase.delete('template-1'); expect(deleted.metadata.deletedAt, isNotNull); expect(templateRepository.listActive(), completion(isEmpty)); }); test('history stays unchanged after deleting its source template', () async { final templateRepository = _FakeWorkoutTemplateRepository() ..templates.add( WorkoutTemplate(metadata: _metadata('template-1'), name: 'Séance'), ); final history = WorkoutHistory( metadata: _metadata('history-1'), sourceWorkoutTemplateId: 'template-1', nameSnapshot: 'Séance historique', startedAt: DateTime.utc(2026, 7, 17, 10), endedAt: DateTime.utc(2026, 7, 17, 11), totalActiveMs: 3600000, completed: true, historySnapshotJson: '{"name":"Séance historique"}', ); final useCase = WorkoutTemplateUseCases( templateRepository: templateRepository, programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); await useCase.delete('template-1'); expect(history.sourceWorkoutTemplateId, 'template-1'); expect(history.nameSnapshot, 'Séance historique'); expect(history.historySnapshotJson, '{"name":"Séance historique"}'); }); test('Workout template use case rejects active measure overrides', () async { final useCase = WorkoutTemplateUseCases( templateRepository: _FakeWorkoutTemplateRepository(), programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); await expectLater( useCase.overrideExerciseTargets( workoutTemplateProgramId: 'template-program-1', snapshotProgramExerciseId: 'exercise-snapshot-1', attemptedMeasureOverride: {WorkoutMeasure.reps}, ), throwsA(isA()), ); }); test( 'Workout template use case rejects target overrides for inactive measures', () async { final useCase = WorkoutTemplateUseCases( templateRepository: _FakeWorkoutTemplateRepository(), programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); await expectLater( useCase.saveConfigured( name: 'Séance tir', programs: [ WorkoutTemplateProgramConfig( clientKey: 'program-1', programNameSnapshot: 'Programme tir', defaultRestSecondsSnapshot: 30, programSnapshotJson: jsonEncode({ 'exercises': [ { 'id': 'exercise-snapshot-1', 'exerciseNameSnapshot': 'Lancers francs', 'setsCount': 3, 'timeEnabled': true, 'repsEnabled': false, 'scoreEnabled': false, 'targetTimeSeconds': 60, 'targetReps': null, 'targetScore': null, }, ], }), ), ], overrides: const [ WorkoutTemplateExerciseOverrideConfig( workoutTemplateProgramClientKey: 'program-1', snapshotProgramExerciseId: 'exercise-snapshot-1', targetRepsOverride: 10, ), ], ), throwsA(isA()), ); }, ); test('Active session elapsed time is based on persisted timestamps', () { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = ActiveWorkoutSession( metadata: _metadata('session-1'), status: ActiveWorkoutStatus.running, startedAt: clock.now(), lastPersistedAt: clock.now(), elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: '{}', ); clock.value = clock.value.add(const Duration(seconds: 30)); final paused = session.pause(clock.now()); expect(paused.elapsedActiveMs, 30000); clock.value = clock.value.add(const Duration(minutes: 5)); final resumed = paused.resume(clock.now()); expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 30000); clock.value = clock.value.add(const Duration(seconds: 15)); expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000); }); test( 'startFromTemplate rejects template without playable exercise', () async { final templateRepository = _FakeWorkoutTemplateRepository() ..templates.add( WorkoutTemplate( metadata: _metadata('template-1'), name: 'Séance vide', ), ); final repository = _FakeActiveSessionRepository(); final useCase = ActiveWorkoutSessionUseCases( sessionRepository: repository, templateRepository: templateRepository, clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); await expectLater( useCase.startFromTemplate('template-1'), throwsA(isA()), ); expect(repository.session, isNull); }, ); test('startFromTemplate stamps the source template start time', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final templateRepository = _FakeWorkoutTemplateRepository() ..templates.add(_playableTemplate(id: 'template-1', name: 'A')); final repository = _FakeActiveSessionRepository(); final useCase = ActiveWorkoutSessionUseCases( sessionRepository: repository, templateRepository: templateRepository, clock: clock, ids: _FakeIds(), originDeviceId: 'device-1', ); final session = await useCase.startFromTemplate('template-1'); expect(session.sourceWorkoutTemplateId, 'template-1'); expect(repository.session, session); expect(templateRepository.templates.single.lastStartedAt, clock.now()); }); test( 'startFromLastTemplate starts most recently used playable template', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final templateRepository = _FakeWorkoutTemplateRepository() ..templates.addAll([ _playableTemplate( id: 'older', name: 'B', lastStartedAt: DateTime.utc(2026, 7, 10, 12), ), _playableTemplate( id: 'latest', name: 'A', lastStartedAt: DateTime.utc(2026, 7, 12, 12), ), WorkoutTemplate(metadata: _metadata('empty'), name: 'Empty'), ]); final repository = _FakeActiveSessionRepository(); final useCase = ActiveWorkoutSessionUseCases( sessionRepository: repository, templateRepository: templateRepository, clock: clock, ids: _FakeIds(), originDeviceId: 'device-1', ); final session = await useCase.startFromLastTemplate(); expect(session.sourceWorkoutTemplateId, 'latest'); expect(repository.session, session); }, ); test('resume abandons invalid session snapshot before throwing', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = ActiveWorkoutSession( metadata: _metadata('session-1'), status: ActiveWorkoutStatus.paused, startedAt: clock.now(), pausedAt: clock.now(), lastPersistedAt: clock.now(), elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}), ); final repository = _FakeActiveSessionRepository()..session = session; final useCase = _activeUseCase(repository, clock); await expectLater( useCase.resume(session.metadata.id), throwsA(isA()), ); expect(repository.session?.status, ActiveWorkoutStatus.abandoned); expect(repository.session?.endedAt, clock.now()); }); test('findOpen silently abandons invalid legacy session', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = ActiveWorkoutSession( metadata: _metadata('session-1'), status: ActiveWorkoutStatus.running, startedAt: clock.now(), lastPersistedAt: clock.now(), elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}), ); final repository = _FakeActiveSessionRepository()..session = session; final useCase = _activeUseCase(repository, clock); final open = await useCase.findOpen(); expect(open, isNull); expect(repository.session?.status, ActiveWorkoutStatus.abandoned); expect(repository.session?.endedAt, clock.now()); }); test('findOpen returns playable open session unchanged', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, ); final repository = _FakeActiveSessionRepository()..session = session; final useCase = _activeUseCase(repository, clock); final open = await useCase.findOpen(); expect(open, session); expect(repository.session?.status, ActiveWorkoutStatus.running); expect(repository.session?.endedAt, isNull); }); test('adjustRestSeconds persists adjusted rest duration', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final repository = _FakeActiveSessionRepository() ..restStates['rest-1'] = ActiveRestState( metadata: _metadata('rest-1'), activeWorkoutSessionId: 'session-1', afterProgramIndex: 0, afterExerciseIndex: 0, afterSetIndex: 0, plannedRestSeconds: 60, adjustedRestSeconds: 60, startedAt: clock.now(), ); final useCase = _activeUseCase(repository, clock); final adjusted = await useCase.adjustRestSeconds( restStateId: 'rest-1', deltaSeconds: -75, ); expect(adjusted.adjustedRestSeconds, 0); expect(repository.restStates['rest-1']!.adjustedRestSeconds, 0); }); test('skipRest persists skippedAt timestamp', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final repository = _FakeActiveSessionRepository() ..restStates['rest-1'] = ActiveRestState( metadata: _metadata('rest-1'), activeWorkoutSessionId: 'session-1', afterProgramIndex: 0, afterExerciseIndex: 0, afterSetIndex: 0, plannedRestSeconds: 60, adjustedRestSeconds: 60, startedAt: clock.now(), ); clock.value = clock.value.add(const Duration(seconds: 10)); final useCase = _activeUseCase(repository, clock); final skipped = await useCase.skipRest(restStateId: 'rest-1'); expect(skipped.skippedAt, clock.now()); expect(repository.restStates['rest-1']!.skippedAt, clock.now()); }); test('findActiveRest returns latest non-ended non-skipped rest', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final repository = _FakeActiveSessionRepository() ..restStates['ended'] = ActiveRestState( metadata: _metadata('ended'), activeWorkoutSessionId: 'session-1', afterProgramIndex: 0, afterExerciseIndex: 0, afterSetIndex: 0, plannedRestSeconds: 60, adjustedRestSeconds: 60, startedAt: clock.now(), endedAt: clock.now().add(const Duration(seconds: 60)), ) ..restStates['older-active'] = ActiveRestState( metadata: _metadata('older-active'), activeWorkoutSessionId: 'session-1', afterProgramIndex: 0, afterExerciseIndex: 1, afterSetIndex: 0, plannedRestSeconds: 60, adjustedRestSeconds: 60, startedAt: clock.now().add(const Duration(seconds: 30)), ) ..restStates['latest-active'] = ActiveRestState( metadata: _metadata('latest-active'), activeWorkoutSessionId: 'session-1', afterProgramIndex: 0, afterExerciseIndex: 2, afterSetIndex: 0, plannedRestSeconds: 60, adjustedRestSeconds: 60, startedAt: clock.now().add(const Duration(seconds: 90)), ) ..restStates['skipped'] = ActiveRestState( metadata: _metadata('skipped'), activeWorkoutSessionId: 'session-1', afterProgramIndex: 0, afterExerciseIndex: 3, afterSetIndex: 0, plannedRestSeconds: 60, adjustedRestSeconds: 60, startedAt: clock.now().add(const Duration(seconds: 120)), skippedAt: clock.now().add(const Duration(seconds: 125)), ); final useCase = _activeUseCase(repository, clock); final active = await useCase.findActiveRest(sessionId: 'session-1'); expect(active?.metadata.id, 'latest-active'); }); test( 'startSetExecution starts set timer score stopwatch and first timed step', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, timeEnabled: true, repsEnabled: false, scoreEnabled: true, scoreInputMode: ScoreInputMode.stopwatch, exerciseSteps: [_step(type: ExerciseStepType.time)], ); final repository = _FakeActiveSessionRepository()..session = session; final useCase = _activeUseCase(repository, clock); final started = await useCase.startSetExecution( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); expect(started.setTimer?.status, ActiveSetTimerStatus.running); expect( started.scoreStopwatch?.status, ActiveScoreStopwatchStatus.running, ); expect( started.stepProgress?.status, ActiveExerciseStepProgressStatus.runningTimer, ); expect(repository.setTimerStates.length, 1); expect(repository.scoreStopwatchStates.length, 1); expect(repository.stepProgressStates.length, 1); }, ); test('startSetExecution is idempotent for already running timers', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, timeEnabled: true, repsEnabled: false, scoreEnabled: true, scoreInputMode: ScoreInputMode.stopwatch, exerciseSteps: [_step(type: ExerciseStepType.time)], ); final repository = _FakeActiveSessionRepository()..session = session; final useCase = _activeUseCase(repository, clock); final first = await useCase.startSetExecution( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); clock.value = clock.value.add(const Duration(seconds: 3)); final second = await useCase.startSetExecution( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); expect(second.setTimer?.metadata.id, first.setTimer?.metadata.id); expect(second.setTimer?.startedAt, first.setTimer?.startedAt); expect(repository.setTimerStates.length, 1); expect(repository.scoreStopwatchStates.length, 1); expect(repository.stepProgressStates.length, 1); }); test( 'startSetExecution does not start a next timed step left stopped', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, timeEnabled: true, repsEnabled: false, exerciseSteps: _twoTimedSteps(), ); final repository = _FakeActiveSessionRepository() ..session = session ..stepProgressStates['step-state'] = ActiveExerciseStepProgressState( metadata: _metadata('step-state'), activeWorkoutSessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, currentPassageIndex: 0, currentStepIndex: 1, currentStepSnapshotId: 'step-2', status: ActiveExerciseStepProgressStatus.stoppedTimer, accumulatedMs: 0, lastTransitionAt: clock.now(), ); final useCase = _activeUseCase(repository, clock); final started = await useCase.startSetExecution( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); expect( started.stepProgress?.status, ActiveExerciseStepProgressStatus.stoppedTimer, ); expect(started.stepProgress?.currentStepIndex, 1); expect(started.stepProgress?.startedAt, isNull); }, ); test('pause and resume restore running persistent timers', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, timeEnabled: true, repsEnabled: false, scoreEnabled: true, scoreInputMode: ScoreInputMode.stopwatch, exerciseSteps: [_step(type: ExerciseStepType.time)], ); final repository = _FakeActiveSessionRepository()..session = session; final useCase = _activeUseCase(repository, clock); await useCase.startSetExecution( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); await useCase.startRestAfterSet( sessionId: session.metadata.id, afterProgramIndex: 0, afterExerciseIndex: 0, afterSetIndex: 0, plannedRestSeconds: 60, ); clock.value = clock.value.add(const Duration(seconds: 5)); await useCase.pause(session.metadata.id); expect( repository.setTimerStates.values.single.status, ActiveSetTimerStatus.paused, ); expect(repository.setTimerStates.values.single.accumulatedMs, 5000); expect( repository.scoreStopwatchStates.values.single.status, ActiveScoreStopwatchStatus.paused, ); expect( repository.stepProgressStates.values.single.status, ActiveExerciseStepProgressStatus.pausedTimer, ); final pausedRest = repository.restStates.values.single; expect(pausedRest.pausedAt, clock.now()); expect(pausedRest.remainingMillisecondsAt(clock.now()), 55000); clock.value = clock.value.add(const Duration(minutes: 2)); await useCase.resume(session.metadata.id); expect( repository.setTimerStates.values.single.status, ActiveSetTimerStatus.running, ); expect( repository.scoreStopwatchStates.values.single.status, ActiveScoreStopwatchStatus.running, ); expect( repository.stepProgressStates.values.single.status, ActiveExerciseStepProgressStatus.runningTimer, ); final resumedRest = repository.restStates.values.single; expect(resumedRest.pausedAt, isNull); expect(resumedRest.accumulatedPausedMs, 120000); expect(resumedRest.remainingMillisecondsAt(clock.now()), 55000); }); test('upsertSetResultAtPosition does not move session cursor', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 1, ); final repository = _FakeActiveSessionRepository()..session = session; final useCase = _activeUseCase(repository, clock); await useCase.upsertSetResultAtPosition( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, status: SetResultStatus.completed, actualReps: 12, ); expect(repository.session?.currentProgramIndex, 0); expect(repository.session?.currentExerciseIndex, 0); expect(repository.session?.currentSetIndex, 1); expect(repository.results.single.actualReps, 12); }); test( 'upsertSetResultAtPosition preserves existing id and createdAt', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 1, ); final existingCreatedAt = DateTime.utc(2026, 7, 17, 10); final existing = ActiveSetResult( metadata: EntityMetadata( id: 'result-1', createdAt: existingCreatedAt, updatedAt: existingCreatedAt, originDeviceId: 'device-1', ), activeWorkoutSessionId: session.metadata.id, programSnapshotId: 'program-snapshot-1', exerciseSnapshotId: 'exercise-snapshot-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, actualReps: 8, ); final repository = _FakeActiveSessionRepository() ..session = session ..results.add(existing); final useCase = _activeUseCase(repository, clock); final updated = await useCase.upsertSetResultAtPosition( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, status: SetResultStatus.completed, actualReps: 12, ); expect(updated.metadata.id, 'result-1'); expect(updated.metadata.createdAt, existingCreatedAt); expect(updated.actualReps, 12); }, ); test( 'listSetResults returns pending, skipped and completed statuses', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 2, setsCount: 3, ); final repository = _FakeActiveSessionRepository() ..session = session ..results.addAll([ ActiveSetResult( metadata: _metadata('completed'), activeWorkoutSessionId: session.metadata.id, programSnapshotId: 'program-snapshot-1', exerciseSnapshotId: 'exercise-snapshot-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, actualReps: 10, status: SetResultStatus.completed, ), ActiveSetResult( metadata: _metadata('skipped'), activeWorkoutSessionId: session.metadata.id, programSnapshotId: 'program-snapshot-1', exerciseSnapshotId: 'exercise-snapshot-1', programIndex: 0, exerciseIndex: 0, setIndex: 1, status: SetResultStatus.skipped, ), ]); final useCase = _activeUseCase(repository, clock); final states = await useCase.listSetResults(session.metadata.id); expect(states.map((state) => state.status), [ SetPositionStatus.completed, SetPositionStatus.skipped, SetPositionStatus.pending, ]); }, ); test( 'score stopwatch start stop resume accumulates elapsed duration', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, ); final repository = _FakeActiveSessionRepository()..session = session; final useCase = _activeUseCase(repository, clock); await useCase.startScoreStopwatch( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); clock.value = clock.value.add(const Duration(seconds: 5)); final stopped = await useCase.stopScoreStopwatch( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); clock.value = clock.value.add(const Duration(seconds: 10)); await useCase.resumeScoreStopwatch( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); clock.value = clock.value.add(const Duration(seconds: 3)); final stoppedAgain = await useCase.stopScoreStopwatch( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); expect(stopped.accumulatedMs, 5000); expect(stoppedAgain.accumulatedMs, 8000); }, ); test('score stopwatch state survives use case reconstruction', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, ); final repository = _FakeActiveSessionRepository()..session = session; await _activeUseCase(repository, clock).startScoreStopwatch( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); clock.value = clock.value.add(const Duration(seconds: 4)); final reconstructed = _activeUseCase(repository, clock); final stopped = await reconstructed.stopScoreStopwatch( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); expect(stopped.accumulatedMs, 4000); }); test( 'exercise step progress auto-advances consecutive elapsed timers after kill', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, setsCount: 1, targetReps: 2, exerciseSteps: [ _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, ), ], ); 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: 4500)); final reconstructed = ActiveExerciseStepUseCases( sessionRepository: repository, clock: clock, ids: _FakeIds(), originDeviceId: 'device-1', ); final view = await reconstructed.startOrResumeProgress( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); expect( view.state.status, ActiveExerciseStepProgressStatus.sequenceComplete, ); expect(repository.stepResults, hasLength(4)); expect(repository.stepResults.map((result) => result.passageIndex), [ 0, 0, 1, 1, ]); expect(repository.stepResults.map((result) => result.stepIndex), [ 0, 1, 0, 1, ]); expect(repository.stepResults.map((result) => result.actualTimeMs), [ 1000, 1000, 1000, 1000, ]); }, ); test('linked step score controls current series manual score', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, setsCount: 1, targetReps: 1, scoreEnabled: true, exerciseSteps: [ _step( id: 'step-1', position: 0, name: 'Tirs main droite', type: ExerciseStepType.reps, defaultTargetValue: 10, hasScore: true, scoreLabel: 'Paniers', scoreUnit: 'pts', linkedToSeriesScore: true, ), ], ); final repository = _FakeActiveSessionRepository()..session = session; final activeUseCase = _activeUseCase(repository, clock); final stepUseCase = ActiveExerciseStepUseCases( sessionRepository: repository, clock: clock, ids: _FakeIds(), originDeviceId: 'device-1', activeSessionUseCases: activeUseCase, ); await stepUseCase.startOrResumeProgress( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); await stepUseCase.incrementCurrentStepScore( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); await stepUseCase.incrementCurrentStepScore( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); await stepUseCase.decrementCurrentStepScore( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); expect(repository.manualScoreStates.values.single.value, 1); final result = await stepUseCase.completeCurrentStep( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, actualScore: 3, ); expect(result.actualScore, 3); expect(repository.manualScoreStates.values.single.value, 4); }); 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 disabled stops at first elapsed timed boundary after kill', () 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(), _step( id: 'step-3', position: 2, name: 'Centre', type: ExerciseStepType.time, defaultTargetValue: 1, ), ], ); final repository = _FakeActiveSessionRepository()..session = session; await ActiveExerciseStepUseCases( sessionRepository: repository, clock: clock, ids: _FakeIds(), originDeviceId: 'device-1', ).startOrResumeProgress( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); await ActiveExerciseStepUseCases( sessionRepository: repository, clock: clock, ids: _FakeIds(), originDeviceId: 'device-1', ).startTimer( sessionId: session.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, ); clock.value = clock.value.add(const Duration(milliseconds: 4500)); final reconstructed = ActiveExerciseStepUseCases( sessionRepository: repository, clock: clock, ids: _FakeIds(), originDeviceId: 'device-1', ); final view = await reconstructed.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.startedAt, isNull); expect(view.state.accumulatedMs, 0); expect(repository.stepResults, hasLength(1)); expect(repository.stepResults.single.stepIndex, 0); }, ); 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 { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final tokenStore = _FakeAuthTokenStore(); final accountRepository = _FakeOnlineAccountRepository(); final remoteAuthApi = _FakeRemoteAuthApi(); final useCase = _authUseCase( tokenStore: tokenStore, accountRepository: accountRepository, remoteAuthApi: remoteAuthApi, clock: clock, ); final session = await useCase.register( email: 'USER@example.com', password: 'password123', displayName: 'User', ); expect(session.serverUserId, 'server-user-1'); expect(session.email, 'user@example.com'); expect(session.displayName, 'User'); expect(session.isLoggedIn, isTrue); expect(tokenStore.token, 'token-1'); expect(accountRepository.session, session); }); test('AuthUseCases register propagates email already used error', () async { final remoteAuthApi = _FakeRemoteAuthApi() ..registerException = const RemoteAuthException( RemoteAuthFailure.emailAlreadyUsed, ); await expectLater( _authUseCase( remoteAuthApi: remoteAuthApi, ).register(email: 'user@example.com', password: 'password123'), throwsA( isA().having( (error) => error.failure, 'failure', RemoteAuthFailure.emailAlreadyUsed, ), ), ); }); test('AuthUseCases login stores token and account session', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final tokenStore = _FakeAuthTokenStore(); final accountRepository = _FakeOnlineAccountRepository(); final remoteAuthApi = _FakeRemoteAuthApi(); final useCase = _authUseCase( tokenStore: tokenStore, accountRepository: accountRepository, remoteAuthApi: remoteAuthApi, clock: clock, ); final session = await useCase.login( email: 'user@example.com', password: 'password123', ); expect(session.serverUserId, 'server-user-1'); expect(session.isLoggedIn, isTrue); expect(tokenStore.token, 'token-1'); expect(tokenStore.expiresAt, DateTime.utc(2026, 8, 16, 12)); expect(accountRepository.session?.lastAuthenticatedAt, clock.now()); }); test('AuthUseCases login propagates invalid credentials', () async { final remoteAuthApi = _FakeRemoteAuthApi() ..loginException = const RemoteAuthException( RemoteAuthFailure.invalidCredentials, ); await expectLater( _authUseCase( remoteAuthApi: remoteAuthApi, ).login(email: 'user@example.com', password: 'wrong-password'), throwsA( isA().having( (error) => error.failure, 'failure', RemoteAuthFailure.invalidCredentials, ), ), ); }); test('AuthUseCases logout clears local token even if remote fails', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final tokenStore = _FakeAuthTokenStore() ..token = 'token-1' ..expiresAt = DateTime.utc(2026, 8, 16, 12); final accountRepository = _FakeOnlineAccountRepository() ..session = UserAccountSession( id: 'session-1', serverUserId: 'server-user-1', email: 'user@example.com', isLoggedIn: true, createdAt: DateTime.utc(2026, 7, 17, 11), updatedAt: DateTime.utc(2026, 7, 17, 11), ); final remoteAuthApi = _FakeRemoteAuthApi() ..logoutException = const RemoteAuthException(RemoteAuthFailure.network); await _authUseCase( tokenStore: tokenStore, accountRepository: accountRepository, remoteAuthApi: remoteAuthApi, clock: clock, ).logout(); expect(tokenStore.token, isNull); expect(accountRepository.session?.isLoggedIn, isFalse); expect(remoteAuthApi.logoutCalls, 1); }); test('AuthUseCases currentSession reads local cache only', () async { final tokenStore = _FakeAuthTokenStore()..token = 'token-1'; final accountRepository = _FakeOnlineAccountRepository() ..session = UserAccountSession( id: 'session-1', serverUserId: 'server-user-1', email: 'user@example.com', isLoggedIn: true, createdAt: DateTime.utc(2026, 7, 17, 11), updatedAt: DateTime.utc(2026, 7, 17, 11), ); final remoteAuthApi = _FakeRemoteAuthApi(); final session = await _authUseCase( tokenStore: tokenStore, accountRepository: accountRepository, remoteAuthApi: remoteAuthApi, ).currentSession(); expect(session?.email, 'user@example.com'); expect(remoteAuthApi.registerCalls, 0); expect(remoteAuthApi.loginCalls, 0); expect(remoteAuthApi.logoutCalls, 0); }); test('SyncUseCases skips silently without token', () async { final remoteSyncApi = _FakeRemoteSyncApi(); final metadataRepository = _FakeSyncMetadataRepository(); final localChanges = _FakeLocalSyncChangeRepository(); final summary = await _syncUseCase( tokenStore: _FakeAuthTokenStore(), remoteSyncApi: remoteSyncApi, metadataRepository: metadataRepository, localChanges: localChanges, ).synchronize(manual: true); expect(summary.skipped, isTrue); expect(remoteSyncApi.pushCalls, 0); expect(remoteSyncApi.pullCalls, 0); expect(metadataRepository.saved, isEmpty); }); test('SyncUseCases successful push marks change log rows synced', () async { final tokenStore = _FakeAuthTokenStore()..token = 'token-1'; final localChanges = _FakeLocalSyncChangeRepository() ..pendingChanges.add(_pendingExerciseChange()); final remoteSyncApi = _FakeRemoteSyncApi() ..pushResult = RemoteSyncPushResult( serverCursor: 'cursor-1', results: [ RemoteSyncPushItemResult( resourceType: SyncResourceType.exercise, clientId: 'exercise-1', serverId: 'server-exercise-1', status: RemoteSyncPushStatus.accepted, serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1), ), ], ); final mappingRepository = _FakeRemoteResourceMappingRepository(); final summary = await _syncUseCase( tokenStore: tokenStore, remoteSyncApi: remoteSyncApi, mappingRepository: mappingRepository, localChanges: localChanges, ).synchronize(manual: true); expect(summary.pushedChanges, 1); expect(localChanges.syncedChangeLogIds, ['change-1']); expect(mappingRepository.saved.single.serverId, 'server-exercise-1'); }); test('SyncUseCases pull applies a newer remote item', () async { final tokenStore = _FakeAuthTokenStore()..token = 'token-1'; final localChanges = _FakeLocalSyncChangeRepository() ..localUpdatedAt = DateTime.utc(2026, 7, 17, 11); final remoteSyncApi = _FakeRemoteSyncApi() ..pullResult = RemoteSyncPullResult( serverCursor: 'cursor-2', items: [ _remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 12)), ], ); final summary = await _syncUseCase( tokenStore: tokenStore, remoteSyncApi: remoteSyncApi, localChanges: localChanges, ).synchronize(manual: true); expect(summary.pulledChanges, 1); expect(localChanges.appliedItems.single.clientId, 'exercise-1'); }); test('SyncUseCases pull ignores an older remote item', () async { final tokenStore = _FakeAuthTokenStore()..token = 'token-1'; final localChanges = _FakeLocalSyncChangeRepository() ..localUpdatedAt = DateTime.utc(2026, 7, 17, 12); final remoteSyncApi = _FakeRemoteSyncApi() ..pullResult = RemoteSyncPullResult( serverCursor: 'cursor-2', items: [ _remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 11)), ], ); final summary = await _syncUseCase( tokenStore: tokenStore, remoteSyncApi: remoteSyncApi, localChanges: localChanges, ).synchronize(manual: true); expect(summary.pulledChanges, 0); expect(localChanges.appliedItems, isEmpty); }); test('SyncUseCases stores failure status without throwing', () async { final tokenStore = _FakeAuthTokenStore()..token = 'token-1'; final metadataRepository = _FakeSyncMetadataRepository(); final remoteSyncApi = _FakeRemoteSyncApi() ..exception = const RemoteAuthException(RemoteAuthFailure.network); final summary = await _syncUseCase( tokenStore: tokenStore, remoteSyncApi: remoteSyncApi, metadataRepository: metadataRepository, ).synchronize(manual: true); expect(summary.failed, isTrue); expect(metadataRepository.metadata.status, OnlineSyncStatus.failure); expect(metadataRepository.metadata.lastFailureAt, isNotNull); }); test( 'ShareUseCases sendShare returns neutral result without token', () async { final remoteShareApi = _FakeRemoteShareApi(); final pendingRepository = _FakePendingShareActionRepository(); final programRepository = _FakeProgramRepository() ..programs.add(_shareableProgram()); final result = await _shareUseCase( tokenStore: _FakeAuthTokenStore(), remoteShareApi: remoteShareApi, pendingActionRepository: pendingRepository, programRepository: programRepository, ).sendShare( resourceType: ShareResourceType.program, localResourceId: 'program-1', recipientEmails: const ['friend@example.com'], ); expect(result.status, ShareSendStatus.notConnected); expect(remoteShareApi.sendCalls, 0); expect(pendingRepository.actions, isEmpty); }, ); test('ShareUseCases sendShare queues action when network fails', () async { final remoteShareApi = _FakeRemoteShareApi() ..exception = const RemoteAuthException(RemoteAuthFailure.network); final pendingRepository = _FakePendingShareActionRepository(); final programRepository = _FakeProgramRepository() ..programs.add(_shareableProgram()); final result = await _shareUseCase( remoteShareApi: remoteShareApi, pendingActionRepository: pendingRepository, programRepository: programRepository, ).sendShare( resourceType: ShareResourceType.program, localResourceId: 'program-1', recipientEmails: const ['friend@example.com'], ); expect(result.status, ShareSendStatus.queued); expect( pendingRepository.actions.single.actionType, PendingShareActionType.send, ); expect( pendingRepository.actions.single.resourceType, ShareResourceType.program, ); expect(pendingRepository.actions.single.payloadJson, contains('Programme')); }); test('ShareUseCases acceptShare imports returned resource locally', () async { final remoteShareApi = _FakeRemoteShareApi() ..acceptResult = _remoteSharedProgramItem(); final localChanges = _FakeLocalSyncChangeRepository(); final inboxRepository = _FakeShareInboxRepository() ..items.add(_shareInboxItem(status: ShareInboxStatus.pending)); await _shareUseCase( remoteShareApi: remoteShareApi, localChanges: localChanges, inboxRepository: inboxRepository, ).acceptShare('share-1'); expect(localChanges.appliedItems.single.clientId, 'shared-program-1'); expect(inboxRepository.items.single.status, ShareInboxStatus.accepted); }); test( 'ShareUseCases acceptShare sans token importe une copie locale indépendante', () async { final remoteShareApi = _FakeRemoteShareApi(); final pendingRepository = _FakePendingShareActionRepository(); final programRepository = _FakeProgramRepository(); final inboxRepository = _FakeShareInboxRepository() ..items.add( _shareInboxItem( status: ShareInboxStatus.pending, payloadJson: jsonEncode(_sharedProgramPayload()), ), ); await _shareUseCase( tokenStore: _FakeAuthTokenStore(), remoteShareApi: remoteShareApi, inboxRepository: inboxRepository, pendingActionRepository: pendingRepository, programRepository: programRepository, ).acceptShare('share-1'); expect(remoteShareApi.acceptCalls, 0); expect( pendingRepository.actions.single.actionType, PendingShareActionType.accept, ); expect(inboxRepository.items.single.status, ShareInboxStatus.accepted); final imported = programRepository.programs.single; expect(imported.metadata.id, isNot('program-from-sender')); expect(imported.metadata.originDeviceId, 'device-1'); expect(imported.name, 'Programme partagé'); expect( imported.exercises.single.metadata.id, isNot('exercise-from-sender'), ); expect(imported.exercises.single.sourceExerciseId, isNull); expect( imported.exercises.single.exerciseStepsSnapshot.single.name, 'Sprint', ); }, ); test('ShareUseCases refreshInbox updates the local cache', () async { final remoteShareApi = _FakeRemoteShareApi() ..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)]; final inboxRepository = _FakeShareInboxRepository(); final items = await _shareUseCase( remoteShareApi: remoteShareApi, inboxRepository: inboxRepository, ).refreshInbox(); expect(items.single.shareId, 'share-1'); expect(inboxRepository.items.single.senderUserId, 'sender-1'); }); test('ShareUseCases decline and revoke update local statuses', () async { final inboxRepository = _FakeShareInboxRepository() ..items.add(_shareInboxItem(status: ShareInboxStatus.pending)) ..items.add( _shareInboxItem(shareId: 'share-2', status: ShareInboxStatus.pending), ); final useCase = _shareUseCase(inboxRepository: inboxRepository); await useCase.declineShare('share-1'); await useCase.revokeShare('share-2'); expect( inboxRepository.items .singleWhere((item) => item.shareId == 'share-1') .status, ShareInboxStatus.declined, ); expect( inboxRepository.items .singleWhere((item) => item.shareId == 'share-2') .status, ShareInboxStatus.revoked, ); }); test('score result enforces manual xor stopwatch values', () { expect( () => ActiveSetResult( metadata: _metadata('result-1'), activeWorkoutSessionId: 'session-1', programSnapshotId: 'program-1', exerciseSnapshotId: 'exercise-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, actualScore: 12, actualScoreTimeMs: 12000, ), throwsA(isA()), ); expect( () => ActiveSetResult( metadata: _metadata('result-2'), activeWorkoutSessionId: 'session-1', programSnapshotId: 'program-1', exerciseSnapshotId: 'exercise-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, actualScore: 12, scoreInputModeSnapshot: ScoreInputMode.stopwatch, ), throwsA(isA()), ); expect( () => ActiveSetResult( metadata: _metadata('result-3'), activeWorkoutSessionId: 'session-1', programSnapshotId: 'program-1', exerciseSnapshotId: 'exercise-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, actualScoreTimeMs: 12000, scoreInputModeSnapshot: ScoreInputMode.manual, ), throwsA(isA()), ); }); test( 'recordCurrentSetResult uses live manual score unless overridden', () async { final repository = _FakeActiveSessionRepository() ..session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, scoreEnabled: true, ); final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12)); final useCase = _activeUseCase(repository, clock); await useCase.incrementManualScore( sessionId: 'session-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, ); await useCase.incrementManualScore( sessionId: 'session-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, ); final fromLiveState = await useCase.recordCurrentSetResult( sessionId: 'session-1', programSnapshotId: 'program-snapshot-1', exerciseSnapshotId: 'exercise-snapshot-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, scoreInputModeSnapshot: ScoreInputMode.manual, scoreLabelSnapshot: 'Points', scoreUnitSnapshot: 'pts', ); expect(fromLiveState.actualScore, 2); expect(repository.manualScoreStates, isEmpty); await useCase.incrementManualScore( sessionId: 'session-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, ); final overridden = await useCase.recordCurrentSetResult( sessionId: 'session-1', programSnapshotId: 'program-snapshot-1', exerciseSnapshotId: 'exercise-snapshot-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, actualScore: 5, scoreInputModeSnapshot: ScoreInputMode.manual, scoreLabelSnapshot: 'Points', scoreUnitSnapshot: 'pts', ); expect(overridden.actualScore, 5); }, ); test('recordCurrentSetResult upserts by logical set key', () async { final repository = _FakeActiveSessionRepository() ..session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, scoreEnabled: true, ); final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12)); final useCase = _activeUseCase(repository, clock); final first = await useCase.recordCurrentSetResult( sessionId: 'session-1', programSnapshotId: 'program-snapshot-1', exerciseSnapshotId: 'exercise-snapshot-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, actualScore: 2, scoreInputModeSnapshot: ScoreInputMode.manual, scoreLabelSnapshot: 'Points', scoreUnitSnapshot: 'pts', ); clock.value = DateTime.utc(2026, 7, 25, 12, 1); final second = await useCase.recordCurrentSetResult( sessionId: 'session-1', programSnapshotId: 'program-snapshot-1', exerciseSnapshotId: 'exercise-snapshot-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, actualScore: 5, scoreInputModeSnapshot: ScoreInputMode.manual, scoreLabelSnapshot: 'Points', scoreUnitSnapshot: 'pts', ); expect(second.metadata.id, first.metadata.id); expect(second.metadata.localRevision, first.metadata.localRevision + 1); expect(repository.results, hasLength(1)); expect(repository.results.single.actualScore, 5); }); test( 'setManualScore pushes phone corrections into live manual score state', () async { final repository = _FakeActiveSessionRepository() ..session = _sessionWithSnapshot( currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, scoreEnabled: true, ); final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12)); final useCase = _activeUseCase(repository, clock); final corrected = await useCase.setManualScore( sessionId: 'session-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, value: 7.5, ); expect(corrected.state.value, 7.5); expect(corrected.changed, isTrue); final incremented = await useCase.incrementManualScore( sessionId: 'session-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, ); expect(incremented.state.value, 8.5); final clamped = await useCase.setManualScore( sessionId: 'session-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, value: -4, ); expect(clamped.state.value, 0); final decremented = await useCase.decrementManualScore( sessionId: 'session-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, ); expect(decremented.state.value, 0); expect(decremented.changed, isFalse); }, ); test('WorkoutHistoryUseCases applies heart rate summary once', () async { final repository = _FakeWorkoutHistoryRepository() ..histories.add( WorkoutHistory( metadata: _metadata('history-1'), sourceActiveWorkoutSessionId: 'session-1', nameSnapshot: 'Seance', startedAt: DateTime.utc(2026, 7, 25, 11), endedAt: DateTime.utc(2026, 7, 25, 12), totalActiveMs: 3600000, completed: true, historySnapshotJson: '{"programs":[]}', ), ); final useCase = WorkoutHistoryUseCases( repository: repository, clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)), ); await useCase.updateHeartRateSummary( const WatchSensorSummary( sessionId: 'session-1', sampleCount: 12, averageHeartRateBpm: 126.5, maxHeartRateBpm: 171, ), ); await useCase.updateHeartRateSummary( const WatchSensorSummary( sessionId: 'session-1', sampleCount: 14, averageHeartRateBpm: 130, maxHeartRateBpm: 180, ), ); expect(repository.histories.single.averageHeartRateBpm, 126.5); expect(repository.histories.single.maxHeartRateBpm, 171); }); test( 'ActiveWorkoutSensorUseCases tracks live heart rate and calories', () async { final useCase = ActiveWorkoutSensorUseCases( clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)), ); final updates = []; final subscription = useCase.updates.listen(updates.add); expect( useCase.recordHeartRateSample( const WatchSensorSample( sessionId: '', recordedAtEpochMs: 0, heartRateBpm: 120, ), ), isNull, ); useCase.recordHeartRateSample( WatchSensorSample( sessionId: 'session-1', recordedAtEpochMs: DateTime.utc( 2026, 7, 25, 12, ).millisecondsSinceEpoch, heartRateBpm: 120, ), ); final state = useCase.recordHeartRateSample( WatchSensorSample( sessionId: 'session-1', recordedAtEpochMs: DateTime.utc( 2026, 7, 25, 12, 30, ).millisecondsSinceEpoch, heartRateBpm: 150, ), ); await Future.delayed(Duration.zero); expect(state?.latestHeartRateBpm, 150); expect(state?.sampleCount, 2); expect(state?.averageHeartRateBpm, 135); expect(state?.maxHeartRateBpm, 150); expect(state?.estimatedCaloriesKcal, 187.5); expect(updates, hasLength(2)); useCase.clear('session-1'); expect(useCase.current('session-1'), isNull); await subscription.cancel(); await useCase.dispose(); }, ); test( 'ActiveWorkoutSensorUseCases tracks telemetry and ignores duplicates', () async { final useCase = ActiveWorkoutSensorUseCases( clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)), ); final first = useCase.recordTelemetrySample( WatchTelemetrySample( sampleId: 'sample-1', sessionId: 'session-1', capturedAtEpochMs: DateTime.utc( 2026, 7, 25, 12, ).millisecondsSinceEpoch, heartRateBpm: 120, distanceMeters: 500, caloriesKcal: 42, ), ); final duplicate = useCase.recordTelemetrySample( WatchTelemetrySample( sampleId: 'sample-1', sessionId: 'session-1', capturedAtEpochMs: DateTime.utc( 2026, 7, 25, 12, 1, ).millisecondsSinceEpoch, heartRateBpm: 160, distanceMeters: 300, caloriesKcal: 10, ), ); final distanceOnly = useCase.recordTelemetrySample( WatchTelemetrySample( sampleId: 'sample-2', sessionId: 'session-1', capturedAtEpochMs: DateTime.utc( 2026, 7, 25, 12, 2, ).millisecondsSinceEpoch, distanceMeters: 620, caloriesKcal: 48, ), ); expect(first?.latestHeartRateBpm, 120); expect(duplicate, isNull); expect(distanceOnly?.sampleCount, 2); expect(distanceOnly?.latestHeartRateBpm, 120); expect(distanceOnly?.minHeartRateBpm, 120); expect(distanceOnly?.averageHeartRateBpm, 120); expect(distanceOnly?.maxHeartRateBpm, 120); expect(distanceOnly?.latestDistanceMeters, 620); expect(distanceOnly?.latestCaloriesKcal, 48); await useCase.dispose(); }, ); test( 'WorkoutHistoryUseCases ignores insufficient heart rate summary', () async { final repository = _FakeWorkoutHistoryRepository() ..histories.add( WorkoutHistory( metadata: _metadata('history-1'), sourceActiveWorkoutSessionId: 'session-1', nameSnapshot: 'Seance', startedAt: DateTime.utc(2026, 7, 25, 11), endedAt: DateTime.utc(2026, 7, 25, 12), totalActiveMs: 3600000, completed: true, historySnapshotJson: '{"programs":[]}', ), ); final useCase = WorkoutHistoryUseCases( repository: repository, clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)), ); await useCase.updateHeartRateSummary( const WatchSensorSummary( sessionId: 'session-1', sampleCount: 2, averageHeartRateBpm: 126.5, maxHeartRateBpm: 171, ), ); expect(repository.histories.single.averageHeartRateBpm, isNull); expect(repository.histories.single.maxHeartRateBpm, isNull); }, ); test('Tags are normalized and validated on taggable entities', () { final exercise = Exercise( metadata: _metadata('exercise-tags'), name: 'Shoot', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, tags: const [' Match Prep ', 'INTENSE'], ); expect(exercise.tags, ['match prep', 'intense']); expect( () => Program( metadata: _metadata('program-tags'), name: 'Program', defaultRestSeconds: 30, tags: const ['match', 'MATCH'], ), throwsA(isA()), ); expect( () => WorkoutTemplate( metadata: _metadata('template-tags'), name: 'Template', tags: List.generate(9, (index) => 'tag$index'), ), throwsA(isA()), ); expect( () => Exercise( metadata: _metadata('exercise-long-tag'), name: 'Shoot', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, tags: const ['tag beaucoup trop long pour le mvp'], ), throwsA(isA()), ); }); test('Tag helpers filter by all selected tags and sort suggestions', () { final programs = [ Program( metadata: _metadata('program-1'), name: 'A', defaultRestSeconds: 30, tags: const ['match', 'intense'], ), Program( metadata: _metadata('program-2'), name: 'B', defaultRestSeconds: 30, tags: const ['match', 'extérieur'], ), Program( metadata: _metadata('program-3'), name: 'C', defaultRestSeconds: 30, tags: const ['intense'], ), ]; final filtered = filterByRequiredTags(programs, { ' MATCH ', 'intense', }, (program) => program.tags); final suggestions = tagSuggestionsFor(programs, (program) => program.tags); expect(filtered.map((program) => program.metadata.id), ['program-1']); expect(suggestions.map((usage) => '${usage.tag}:${usage.count}'), [ 'intense:2', 'match:2', 'extérieur:1', ]); }); test('Local backup codec writes v1 envelope and preview counts', () { final codec = LocalBackupCodec(); final snapshot = _backupSnapshot( exportedAt: DateTime.utc(2026, 7, 22, 12), exercises: [ _backupResource( id: 'exercise-1', updatedAt: DateTime.utc(2026, 7, 22, 10), payload: { 'id': 'exercise-1', 'metadata': _backupMetadata( 'exercise-1', DateTime.utc(2026, 7, 22, 10), ), 'name': 'Shoot', }, ), ], mediaFiles: const [ EmbeddedBackupMediaFile( mediaAssetId: 'media-1', role: 'original', fileName: 'media-1.png', mimeType: 'image/png', sizeBytes: 4, sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', base64: 'dGVzdA==', ), ], ); final decodedJson = jsonDecode(utf8.decode(codec.encode(snapshot))) as Map; final preview = codec.preview(codec.encode(snapshot)); expect(decodedJson['kind'], 'gametime.localBackup'); expect(decodedJson['formatVersion'], 1); expect(preview.counts.exercises, 1); expect(preview.counts.embeddedMediaFiles, 1); expect(preview.hasEmbeddedMedia, isTrue); expect(preview.missingMediaCount, 0); }); test('Local backup codec rejects invalid and future files', () { final codec = LocalBackupCodec(); expect( () => codec.decode(Uint8List.fromList(utf8.encode('not json'))), throwsA( isA().having( (error) => error.error, 'error', LocalBackupValidationError.corrupted, ), ), ); final future = utf8.encode( jsonEncode({ 'kind': 'gametime.localBackup', 'formatVersion': 2, 'minSupportedFormatVersion': 2, 'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(), 'data': { 'mediaAssets': const [], 'exercises': const [], 'programs': const [], 'workoutTemplates': const [], 'workoutHistories': const [], }, 'mediaFiles': const [], }), ); expect( () => codec.decode(Uint8List.fromList(future)), throwsA( isA().having( (error) => error.error, 'error', LocalBackupValidationError.newerVersion, ), ), ); }); test('Data export use case embeds media and names gametime file', () async { final repository = _FakeLocalDataBackupRepository( snapshot: _backupSnapshot( exportedAt: DateTime.utc(2026, 7, 22), mediaAssets: [ _backupResource( id: 'media-1', updatedAt: DateTime.utc(2026, 7, 22, 10), payload: { 'id': 'media-1', 'metadata': _backupMetadata( 'media-1', DateTime.utc(2026, 7, 22, 10), ), 'localUri': 'file:///tmp/media-1.png', }, ), ], ), ); final mediaStore = _FakeLocalBackupMediaStore( files: const [ EmbeddedBackupMediaFile( mediaAssetId: 'media-1', role: 'original', fileName: 'media-1.png', mimeType: 'image/png', sizeBytes: 4, sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', base64: 'dGVzdA==', ), ], ); final useCase = DataExportUseCase( repository: repository, mediaStore: mediaStore, clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), ); final document = await useCase.exportAll(); final preview = const LocalBackupCodec().preview(document.bytes); expect(document.fileName, 'gametime-sauvegarde-2026-07-22.gametime'); expect(mediaStore.requestedMediaIds, ['media-1']); expect(preview.counts.mediaAssets, 1); expect(preview.counts.embeddedMediaFiles, 1); }); test('Data import use case blocks when active workout is open', () async { final repository = _FakeLocalDataBackupRepository(hasOpenSession: true); final useCase = DataImportUseCase( repository: repository, mediaStore: _FakeLocalBackupMediaStore(), clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), ); final bytes = const LocalBackupCodec().encode(_backupSnapshot()); await expectLater( useCase.importFrom(bytes, mode: LocalBackupImportMode.merge), throwsA( isA().having( (error) => error.error, 'error', LocalBackupValidationError.activeWorkoutInProgress, ), ), ); expect(repository.appliedModes, isEmpty); }); test('Data import use case restores media and applies selected mode', () async { final repository = _FakeLocalDataBackupRepository(); final mediaStore = _FakeLocalBackupMediaStore( restored: const { 'media-1': RestoredMediaFile( mediaAssetId: 'media-1', localUri: 'file:///restored/media-1.png', sizeBytes: 4, ), }, ); final useCase = DataImportUseCase( repository: repository, mediaStore: mediaStore, clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), ); final bytes = const LocalBackupCodec().encode( _backupSnapshot( mediaAssets: [ _backupResource( id: 'media-1', updatedAt: DateTime.utc(2026, 7, 22, 10), payload: { 'id': 'media-1', 'metadata': _backupMetadata( 'media-1', DateTime.utc(2026, 7, 22, 10), ), 'localUri': 'file:///old/media-1.png', }, ), ], mediaFiles: const [ EmbeddedBackupMediaFile( mediaAssetId: 'media-1', role: 'original', fileName: 'media-1.png', mimeType: 'image/png', sizeBytes: 4, sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', base64: 'dGVzdA==', ), ], ), ); final result = await useCase.importFrom( bytes, mode: LocalBackupImportMode.replaceAll, ); expect(result.insertedCount, 0); expect(repository.appliedModes, [LocalBackupImportMode.replaceAll]); expect( repository.appliedSnapshots.single.mediaAssets.single.payload['localUri'], 'file:///restored/media-1.png', ); }); test('Local backup codec rejects wrong kind and incompatible format', () { final codec = LocalBackupCodec(); final wrongKind = utf8.encode( jsonEncode({ 'kind': 'other.app.backup', 'formatVersion': 1, 'minSupportedFormatVersion': 1, 'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(), 'data': { 'mediaAssets': const [], 'exercises': const [], 'programs': const [], 'workoutTemplates': const [], 'workoutHistories': const [], }, 'mediaFiles': const [], }), ); expect( () => codec.decode(Uint8List.fromList(wrongKind)), throwsA( isA().having( (error) => error.error, 'error', LocalBackupValidationError.invalidFile, ), ), ); final missingVersion = utf8.encode( jsonEncode({ 'kind': 'gametime.localBackup', 'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(), 'data': { 'mediaAssets': const [], 'exercises': const [], 'programs': const [], 'workoutTemplates': const [], 'workoutHistories': const [], }, 'mediaFiles': const [], }), ); expect( () => codec.decode(Uint8List.fromList(missingVersion)), throwsA( isA().having( (error) => error.error, 'error', LocalBackupValidationError.incompatibleFormat, ), ), ); }); test('Local backup codec rejects malformed data collections', () { final codec = LocalBackupCodec(); final malformedData = utf8.encode( jsonEncode({ 'kind': 'gametime.localBackup', 'formatVersion': 1, 'minSupportedFormatVersion': 1, 'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(), 'data': 'not-a-map', 'mediaFiles': const [], }), ); expect( () => codec.decode(Uint8List.fromList(malformedData)), throwsA( isA().having( (error) => error.error, 'error', LocalBackupValidationError.corrupted, ), ), ); }); test( 'Data import use case wraps unexpected repository failure as importFailedNoMutation', () async { final repository = _ThrowingLocalDataBackupRepository(); final useCase = DataImportUseCase( repository: repository, mediaStore: _FakeLocalBackupMediaStore(), clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), ); final bytes = const LocalBackupCodec().encode(_backupSnapshot()); await expectLater( useCase.importFrom(bytes, mode: LocalBackupImportMode.merge), throwsA( isA().having( (error) => error.error, 'error', LocalBackupValidationError.importFailedNoMutation, ), ), ); }, ); test( 'Data import use case counts media assets not successfully restored', () async { final repository = _FakeLocalDataBackupRepository(); final mediaStore = _FakeLocalBackupMediaStore( restored: const { 'media-restored': RestoredMediaFile( mediaAssetId: 'media-restored', localUri: 'file:///restored/media-restored.png', sizeBytes: 4, ), }, ); final useCase = DataImportUseCase( repository: repository, mediaStore: mediaStore, clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), ); final bytes = const LocalBackupCodec().encode( _backupSnapshot( mediaAssets: [ _backupResource( id: 'media-restored', updatedAt: DateTime.utc(2026, 7, 22, 10), payload: { 'id': 'media-restored', 'metadata': _backupMetadata( 'media-restored', DateTime.utc(2026, 7, 22, 10), ), 'localUri': 'file:///old/media-restored.png', }, ), _backupResource( id: 'media-corrupted', updatedAt: DateTime.utc(2026, 7, 22, 10), payload: { 'id': 'media-corrupted', 'metadata': _backupMetadata( 'media-corrupted', DateTime.utc(2026, 7, 22, 10), ), 'localUri': 'file:///old/media-corrupted.png', }, ), ], mediaFiles: const [ EmbeddedBackupMediaFile( mediaAssetId: 'media-restored', role: 'original', fileName: 'media-restored.png', mimeType: 'image/png', sizeBytes: 4, sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', base64: 'dGVzdA==', ), EmbeddedBackupMediaFile( mediaAssetId: 'media-corrupted', role: 'original', fileName: 'media-corrupted.png', mimeType: 'image/png', sizeBytes: 4, sha256: 'checksum-mismatch', base64: 'dGVzdA==', ), ], ), ); final result = await useCase.importFrom( bytes, mode: LocalBackupImportMode.merge, ); expect(result.missingMediaCount, 1); }, ); test('Program use case duplicates deeply with copy name conflicts', () async { final programRepository = _FakeProgramRepository(); final templateRepository = _FakeWorkoutTemplateRepository(); final source = Program( metadata: _metadata('program-source'), name: 'Programme tirs', defaultRestSeconds: 45, isExample: true, tags: const ['match', 'extérieur'], exercises: [ _programExercise( id: 'program-exercise-source', programId: 'program-source', sourceExerciseId: 'exercise-source', position: 0, ), ], ); programRepository.programs.addAll([ source, Program( metadata: _metadata('program-copy-1'), name: 'Copie de Programme tirs', defaultRestSeconds: 45, ), ]); final useCase = ProgramUseCases( programRepository: programRepository, exerciseRepository: _FakeExerciseRepository(), templateRepository: templateRepository, clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); final copy = await useCase.duplicate('program-source'); expect(copy.metadata.id, isNot('program-source')); expect(copy.name, 'Copie 2 de Programme tirs'); expect(copy.isExample, isFalse); expect(copy.tags, source.tags); expect(copy.exercises.single.metadata.id, isNot('program-exercise-source')); expect(copy.exercises.single.programId, copy.metadata.id); expect(copy.exercises.single.sourceExerciseId, 'exercise-source'); expect(copy.exercises.single.targetReps, 10); expect( programRepository.programs .singleWhere((program) => program.metadata.id == 'program-source') .exercises .single .metadata .id, 'program-exercise-source', ); }); test( 'Workout template use case duplicates deeply and remaps overrides', () async { final templateRepository = _FakeWorkoutTemplateRepository(); final source = WorkoutTemplate( metadata: _metadata('template-source'), name: 'Prépa match', lastStartedAt: DateTime.utc(2026, 7, 20, 12), isExample: true, tags: const ['match', 'intense'], programs: [ _templateProgram( id: 'template-program-source', workoutTemplateId: 'template-source', sourceProgramId: 'program-source', position: 0, ), ], overrides: [ WorkoutTemplateExerciseOverride( metadata: _metadata('override-source'), workoutTemplateProgramId: 'template-program-source', snapshotProgramExerciseId: 'program-exercise-source', setsCountOverride: 4, targetRepsOverride: 12, autoStartNextTimedStepOverride: false, ), ], ); templateRepository.templates.addAll([ source, WorkoutTemplate( metadata: _metadata('template-copy-1'), name: 'Copie de Prépa match', ), ]); final useCase = WorkoutTemplateUseCases( templateRepository: templateRepository, programRepository: _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); final copy = await useCase.duplicate('template-source'); expect(copy.metadata.id, isNot('template-source')); expect(copy.name, 'Copie 2 de Prépa match'); expect(copy.lastStartedAt, isNull); expect(copy.isExample, isFalse); expect(copy.tags, source.tags); expect( copy.programs.single.metadata.id, isNot('template-program-source'), ); expect(copy.programs.single.workoutTemplateId, copy.metadata.id); expect(copy.overrides.single.metadata.id, isNot('override-source')); expect( copy.overrides.single.workoutTemplateProgramId, copy.programs.single.metadata.id, ); expect( templateRepository.templates .singleWhere( (template) => template.metadata.id == 'template-source', ) .programs .single .metadata .id, 'template-program-source', ); expect( copy.overrides.single.snapshotProgramExerciseId, 'program-exercise-source', ); expect(copy.overrides.single.setsCountOverride, 4); expect(copy.overrides.single.targetRepsOverride, 12); expect(copy.overrides.single.autoStartNextTimedStepOverride, isFalse); }, ); test('Progression stats use case resolves four week overview', () async { final repository = _FakeProgressionStatsRepository(); final now = DateTime.utc(2026, 7, 22, 12); repository.globalStats = ProgressionGlobalStatsData( completedSessionCount: 2, totalActiveMs: 180000, activeWeekStarts: [DateTime(2026, 7, 13), DateTime(2026, 7, 20)], hasAnyCompletedHistory: true, ); repository.exerciseOptions = [ ProgressionExerciseOptionData( exerciseKey: 'exercise-1', nameSnapshot: 'Shoot', isArchived: false, lastPerformedAt: now, ), ]; repository.measureOptions['exercise-1'] = const [ ProgressionMeasureOptionData( measure: ProgressionMeasure.manualScore, label: 'Score', scoreLabel: 'Réussites', scoreUnit: 'paniers', lowerIsBetter: false, ), ]; final overview = await ProgressionStatsUseCase( repository: repository, clock: _FakeClock(now), ).getOverview(ProgressionPeriod.fourWeeks); expect(repository.lastGlobalRange!.startedAt, DateTime(2026, 6, 29)); expect(repository.lastGlobalRange!.endedAt, now); expect(overview.totalWeekCount, 4); expect(overview.activeWeekCount, 2); expect(overview.rangeLabelKind, ProgressionRangeLabelKind.sinceDate); expect( overview.exerciseOptions.single.measures.single.scoreUnit, 'paniers', ); }); test('Progression stats use case resolves all-time empty overview', () async { final repository = _FakeProgressionStatsRepository(); repository.globalStats = const ProgressionGlobalStatsData( completedSessionCount: 0, totalActiveMs: 0, activeWeekStarts: [], hasAnyCompletedHistory: false, ); final overview = await ProgressionStatsUseCase( repository: repository, clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), ).getOverview(ProgressionPeriod.all); expect(repository.lastGlobalRange!.startedAt, isNull); expect(overview.totalWeekCount, isNull); expect(overview.rangeLabelKind, ProgressionRangeLabelKind.none); expect(overview.hasAnyCompletedHistory, isFalse); }); test( 'Progression stats use case maps series summaries and empty period', () async { final repository = _FakeProgressionStatsRepository(); final now = DateTime.utc(2026, 7, 22, 12); repository.series = ProgressionExerciseSeriesData( exerciseKey: 'exercise-1', exerciseNameSnapshot: 'Shoot', measure: const ProgressionMeasureOptionData( measure: ProgressionMeasure.reps, label: 'Répétitions', lowerIsBetter: false, ), points: [ ProgressionPointData( workoutHistoryId: 'history-1', workoutNameSnapshot: 'Séance 1', startedAt: now.subtract(const Duration(days: 1)), value: 12, rawValue: 12, ), ProgressionPointData( workoutHistoryId: 'history-2', workoutNameSnapshot: 'Séance 2', startedAt: now, value: 15, rawValue: 15, ), ], hasAnyAllTimeData: true, hasAnyCompletedExerciseResult: true, ); final series = await ProgressionStatsUseCase( repository: repository, clock: _FakeClock(now), ).getExerciseSeries( period: ProgressionPeriod.fourWeeks, exerciseKey: 'exercise-1', measure: ProgressionMeasure.reps, ); expect(series.state, ProgressionSeriesState.ready); expect(series.summary.kind, ProgressionSeriesSummaryKind.totals); expect(series.summary.recentTotal, 27); expect(series.summary.lastSessionTotal, 15); repository.series = const ProgressionExerciseSeriesData( exerciseKey: 'exercise-1', exerciseNameSnapshot: 'Shoot', measure: ProgressionMeasureOptionData( measure: ProgressionMeasure.manualScore, label: 'Score', lowerIsBetter: false, ), points: [], hasAnyAllTimeData: true, hasAnyCompletedExerciseResult: true, ); final empty = await ProgressionStatsUseCase( repository: repository, clock: _FakeClock(now), ).getExerciseSeries( period: ProgressionPeriod.fourWeeks, exerciseKey: 'exercise-1', measure: ProgressionMeasure.manualScore, ); expect(empty.state, ProgressionSeriesState.emptyButHasAllTimeData); expect(empty.summary.kind, ProgressionSeriesSummaryKind.none); repository.series = const ProgressionExerciseSeriesData( exerciseKey: 'exercise-1', exerciseNameSnapshot: 'Shoot', measure: ProgressionMeasureOptionData( measure: ProgressionMeasure.manualScore, label: 'Score', lowerIsBetter: false, ), points: [], hasAnyAllTimeData: false, hasAnyCompletedExerciseResult: true, ); final noGraphable = await ProgressionStatsUseCase( repository: repository, clock: _FakeClock(now), ).getExerciseSeries( period: ProgressionPeriod.all, exerciseKey: 'exercise-1', measure: ProgressionMeasure.manualScore, ); expect(noGraphable.state, ProgressionSeriesState.noGraphableMeasure); }, ); } 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, bool linkedToSeriesScore = false, }) { return ExerciseStep( id: id, position: position, name: name, type: type, defaultTargetValue: defaultTargetValue, hasScore: hasScore, scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, linkedToSeriesScore: linkedToSeriesScore, ); } EntityMetadata _metadata(String id) { return EntityMetadata( id: id, createdAt: DateTime.utc(2026, 7, 17), updatedAt: DateTime.utc(2026, 7, 17), originDeviceId: 'device-1', ); } final class _FakeClock implements Clock { _FakeClock(this.value); DateTime value; @override DateTime now() => value; } final class _FakeIds implements IdGenerator { var _next = 0; @override String newId() { _next += 1; return 'id-$_next'; } } final class _FakeProgressionStatsRepository implements ProgressionStatsRepository { ProgressionDateRange? lastGlobalRange; ProgressionDateRange? lastSeriesRange; ProgressionGlobalStatsData globalStats = const ProgressionGlobalStatsData( completedSessionCount: 0, totalActiveMs: 0, activeWeekStarts: [], hasAnyCompletedHistory: false, ); List exerciseOptions = const []; final measureOptions = >{}; ProgressionExerciseSeriesData series = const ProgressionExerciseSeriesData( exerciseKey: 'exercise-1', exerciseNameSnapshot: 'Exercise', measure: ProgressionMeasureOptionData( measure: ProgressionMeasure.manualScore, label: 'Score', lowerIsBetter: false, ), points: [], hasAnyAllTimeData: false, hasAnyCompletedExerciseResult: false, ); @override Future readGlobalStats( ProgressionDateRange range, ) async { lastGlobalRange = range; return globalStats; } @override Future> listExerciseOptions( ProgressionDateRange range, ) async { return exerciseOptions; } @override Future> listMeasureOptions({ required ProgressionDateRange range, required String exerciseKey, }) async { return measureOptions[exerciseKey] ?? const []; } @override Future readExerciseSeries({ required ProgressionDateRange range, required String exerciseKey, required ProgressionMeasure measure, }) async { lastSeriesRange = range; return series; } } AuthUseCases _authUseCase({ _FakeAuthTokenStore? tokenStore, _FakeOnlineAccountRepository? accountRepository, _FakeRemoteAuthApi? remoteAuthApi, _FakeClock? clock, }) { return AuthUseCases( tokenStore: tokenStore ?? _FakeAuthTokenStore(), accountRepository: accountRepository ?? _FakeOnlineAccountRepository(), remoteAuthApi: remoteAuthApi ?? _FakeRemoteAuthApi(), clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), ); } final class _FakeAuthTokenStore implements AuthTokenStore { String? token; DateTime? expiresAt; @override Future clearToken() async { token = null; expiresAt = null; } @override Future readToken() async => token; @override Future saveToken(String token, DateTime expiresAt) async { this.token = token; this.expiresAt = expiresAt; } } final class _FakeOnlineAccountRepository implements OnlineAccountRepository { UserAccountSession? session; var clearCalls = 0; @override Future clearSession() async { clearCalls += 1; session = null; } @override Future currentSession() async => session; @override Future saveSession(UserAccountSession session) async { this.session = session; } } final class _FakeRemoteAuthApi implements RemoteAuthApi { RemoteAuthException? registerException; RemoteAuthException? loginException; RemoteAuthException? logoutException; var registerCalls = 0; var loginCalls = 0; var logoutCalls = 0; @override Future register({ required String email, required String password, String? displayName, }) async { registerCalls += 1; final exception = registerException; if (exception != null) { throw exception; } return _authResult(email); } @override Future login({ required String email, required String password, }) async { loginCalls += 1; final exception = loginException; if (exception != null) { throw exception; } return _authResult(email); } @override Future logout(String token) async { logoutCalls += 1; final exception = logoutException; if (exception != null) { throw exception; } } RemoteAuthResult _authResult(String email) { return RemoteAuthResult( userId: 'server-user-1', email: email.trim().toLowerCase(), token: 'token-1', expiresAt: DateTime.utc(2026, 8, 16, 12), ); } } SyncUseCases _syncUseCase({ _FakeAuthTokenStore? tokenStore, _FakeRemoteSyncApi? remoteSyncApi, _FakeSyncMetadataRepository? metadataRepository, _FakeRemoteResourceMappingRepository? mappingRepository, _FakeLocalSyncChangeRepository? localChanges, _FakeClock? clock, }) { return SyncUseCases( tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'), remoteSyncApi: remoteSyncApi ?? _FakeRemoteSyncApi(), metadataRepository: metadataRepository ?? _FakeSyncMetadataRepository(), mappingRepository: mappingRepository ?? _FakeRemoteResourceMappingRepository(), localChanges: localChanges ?? _FakeLocalSyncChangeRepository(), clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)), deviceId: 'device-1', ); } final class _FakeRemoteSyncApi implements RemoteSyncApi { RemoteSyncPushResult pushResult = const RemoteSyncPushResult( serverCursor: null, results: [], ); RemoteSyncPullResult pullResult = const RemoteSyncPullResult( serverCursor: null, items: [], ); Exception? exception; var pushCalls = 0; var pullCalls = 0; @override Future push({ required String deviceId, required List items, required String token, }) async { pushCalls += 1; final error = exception; if (error != null) { throw error; } return pushResult; } @override Future pull({ required String? since, required String token, }) async { pullCalls += 1; final error = exception; if (error != null) { throw error; } return pullResult; } } final class _FakeSyncMetadataRepository implements SyncMetadataRepository { SyncMetadataSnapshot metadata = const SyncMetadataSnapshot(); final saved = []; @override Future read() async => metadata; @override Future save(SyncMetadataSnapshot metadata) async { this.metadata = metadata; saved.add(metadata); } } final class _FakeRemoteResourceMappingRepository implements RemoteResourceMappingRepository { final saved = []; @override Future find({ required SyncResourceType resourceType, required String clientId, }) async { return saved .where( (mapping) => mapping.resourceType == resourceType && mapping.clientId == clientId, ) .firstOrNull; } @override Future save(RemoteResourceMapping mapping) async { saved.removeWhere( (existing) => existing.resourceType == mapping.resourceType && existing.clientId == mapping.clientId, ); saved.add(mapping); } } final class _FakeLocalSyncChangeRepository implements LocalSyncChangeRepository { final pendingChanges = []; final syncedChangeLogIds = []; final appliedItems = []; DateTime? localUpdatedAt; @override Future> listPendingChanges() async { return pendingChanges; } @override Future markChangesSynced( List changeLogIds, DateTime syncedAt, ) async { syncedChangeLogIds.addAll(changeLogIds); } @override Future applyRemoteItem(RemoteSyncedItem item) async { final updatedAt = localUpdatedAt; if (updatedAt != null && !item.clientUpdatedAt.isAfter(updatedAt)) { return false; } appliedItems.add(item); localUpdatedAt = item.clientUpdatedAt; return true; } } PendingSyncChange _pendingExerciseChange() { return PendingSyncChange( changeLogIds: const ['change-1'], item: RemoteSyncPushItem( resourceType: SyncResourceType.exercise, clientId: 'exercise-1', schemaVersion: 1, clientUpdatedAt: DateTime.utc(2026, 7, 17, 12), deletedAt: null, payload: const {'id': 'exercise-1', 'name': 'Squat'}, ), ); } RemoteSyncedItem _remoteExerciseItem({required DateTime clientUpdatedAt}) { return RemoteSyncedItem( resourceType: SyncResourceType.exercise, clientId: 'exercise-1', serverId: 'server-exercise-1', schemaVersion: 1, clientUpdatedAt: clientUpdatedAt, serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1), deletedAt: null, payload: const {'id': 'exercise-1', 'name': 'Squat'}, ); } ShareUseCases _shareUseCase({ _FakeAuthTokenStore? tokenStore, _FakeRemoteShareApi? remoteShareApi, _FakeShareInboxRepository? inboxRepository, _FakePendingShareActionRepository? pendingActionRepository, _FakeLocalSyncChangeRepository? localChanges, _FakeProgramRepository? programRepository, _FakeWorkoutTemplateRepository? templateRepository, }) { return ShareUseCases( tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'), remoteShareApi: remoteShareApi ?? _FakeRemoteShareApi(), inboxRepository: inboxRepository ?? _FakeShareInboxRepository(), pendingActionRepository: pendingActionRepository ?? _FakePendingShareActionRepository(), localChanges: localChanges ?? _FakeLocalSyncChangeRepository(), programRepository: programRepository ?? _FakeProgramRepository(), templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); } final class _FakeRemoteShareApi implements RemoteShareApi { Exception? exception; RemoteSyncedItem? acceptResult; List inboxItems = const []; var sendCalls = 0; var acceptCalls = 0; var declineCalls = 0; var revokeCalls = 0; @override Future acceptShare(String shareId, String token) async { acceptCalls += 1; final error = exception; if (error != null) { throw error; } return acceptResult ?? _remoteSharedProgramItem(); } @override Future declineShare(String shareId, String token) async { declineCalls += 1; final error = exception; if (error != null) { throw error; } } @override Future> fetchInbox(String token) async { final error = exception; if (error != null) { throw error; } return inboxItems; } @override Future revokeShare(String shareId, String token) async { revokeCalls += 1; final error = exception; if (error != null) { throw error; } } @override Future sendShare({ required ShareResourceType resourceType, required Map payload, required List recipientEmails, required String token, }) async { sendCalls += 1; final error = exception; if (error != null) { throw error; } return const RemoteShareSendResult( shareId: 'share-1', recipientUserIds: ['server-user-2'], unresolvedEmails: [], ); } } final class _FakeShareInboxRepository implements ShareInboxRepository { final items = []; @override Future findByShareId(String shareId) async { return items.where((item) => item.shareId == shareId).firstOrNull; } @override Future> listAll() async => List.of(items); @override Future markStatus( String shareId, ShareInboxStatus status, DateTime respondedAt, ) async { final index = items.indexWhere((item) => item.shareId == shareId); if (index == -1) { return; } items[index] = items[index].copyWith( status: status, respondedAt: respondedAt, ); } @override Future upsert(ShareInboxItem item) async { items.removeWhere((existing) => existing.shareId == item.shareId); items.add(item); } @override Future upsertAll(List items) async { for (final item in items) { await upsert(item); } } } final class _FakePendingShareActionRepository implements PendingShareActionRepository { final actions = []; @override Future add(PendingShareAction action) async { actions.add(action); } @override Future> listPending() async { return actions .where((action) => action.status != PendingShareActionStatus.succeeded) .toList(); } @override Future markFailed(String id, DateTime attemptedAt) async { _mark(id, attemptedAt, PendingShareActionStatus.failed); } @override Future markSucceeded(String id, DateTime attemptedAt) async { _mark(id, attemptedAt, PendingShareActionStatus.succeeded); } void _mark(String id, DateTime attemptedAt, PendingShareActionStatus status) { final index = actions.indexWhere((action) => action.id == id); if (index == -1) { return; } final action = actions[index]; actions[index] = action.copyWith( lastAttemptAt: attemptedAt, attemptCount: action.attemptCount + 1, status: status, ); } } Program _shareableProgram() { return Program( metadata: _metadata('program-1'), name: 'Programme', defaultRestSeconds: 60, ); } ShareInboxItem _shareInboxItem({ String shareId = 'share-1', required ShareInboxStatus status, String payloadJson = '{"name":"Programme partagé"}', }) { return ShareInboxItem( shareId: shareId, senderUserId: 'sender-1', resourceType: ShareResourceType.program, payloadJson: payloadJson, status: status, createdAt: DateTime.utc(2026, 7, 17, 12), ); } Map _sharedProgramPayload() { return { 'id': 'program-from-sender', 'name': 'Programme partagé', 'defaultRestSeconds': 45, 'exercises': [ { 'id': 'exercise-from-sender', 'sourceExerciseId': 'source-exercise-from-sender', 'position': 0, 'exerciseNameSnapshot': 'Sprint', 'exerciseStepsSnapshot': [ { 'id': 'step-from-sender', 'position': 0, 'name': 'Sprint', 'type': 'time', 'defaultTargetValue': 20, 'hasScore': false, 'scoreInputMode': 'manual', 'scoreLabel': null, 'scoreUnit': null, 'defaultTargetScore': null, 'defaultTargetScoreTimeMs': null, }, ], 'availableTimeSnapshot': true, 'availableRepsSnapshot': false, 'availableScoreSnapshot': false, 'scoreInputModeSnapshot': 'manual', 'setsCount': 2, 'timeEnabled': true, 'repsEnabled': false, 'scoreEnabled': false, 'targetTimeSeconds': 20, }, ], }; } RemoteSyncedItem _remoteSharedProgramItem() { return RemoteSyncedItem( resourceType: SyncResourceType.program, clientId: 'shared-program-1', serverId: 'server-program-1', schemaVersion: 1, clientUpdatedAt: DateTime.utc(2026, 7, 17, 12), serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1), deletedAt: null, payload: const {'id': 'shared-program-1', 'name': 'Programme partagé'}, ); } ExerciseUseCases _exerciseUseCase( _FakeExerciseRepository repository, { _FakeProgramRepository? programRepository, }) { return ExerciseUseCases( repository: repository, programRepository: programRepository ?? _FakeProgramRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), originDeviceId: 'device-1', ); } ProgramExercise _programExercise({ required String id, required String programId, required String sourceExerciseId, required int position, }) { return ProgramExercise( metadata: _metadata(id), programId: programId, sourceExerciseId: sourceExerciseId, position: position, exerciseNameSnapshot: 'Exercise $id', availableTimeSnapshot: false, availableRepsSnapshot: true, availableScoreSnapshot: false, setsCount: 3, timeEnabled: false, repsEnabled: true, scoreEnabled: false, targetReps: 10, ); } WorkoutTemplateProgram _templateProgram({ required String id, required String workoutTemplateId, required String sourceProgramId, required int position, }) { return WorkoutTemplateProgram( metadata: _metadata(id), workoutTemplateId: workoutTemplateId, sourceProgramId: sourceProgramId, position: position, programNameSnapshot: 'Program $id', defaultRestSecondsSnapshot: 60, programSnapshotJson: '{"exercises":[]}', ); } WorkoutTemplate _playableTemplate({ required String id, required String name, DateTime? lastStartedAt, }) { return WorkoutTemplate( metadata: _metadata(id), name: name, lastStartedAt: lastStartedAt, programs: [ WorkoutTemplateProgram( metadata: _metadata('$id-program'), workoutTemplateId: id, sourceProgramId: null, position: 0, programNameSnapshot: 'Programme', defaultRestSecondsSnapshot: 60, programSnapshotJson: jsonEncode({ 'exercises': [ { 'id': '$id-exercise', 'exerciseNameSnapshot': 'Squat', 'setsCount': 2, 'timeEnabled': false, 'repsEnabled': true, 'scoreEnabled': false, 'targetReps': 10, 'exerciseStepsSnapshot': const [], 'autoStartNextTimedStepSnapshot': false, }, ], }), ), ], ); } final class _FakeExerciseRepository implements ExerciseRepository { Exercise? exercise; final saved = []; @override Future findById(String id) async { return exercise?.metadata.id == id ? exercise : null; } @override Future isReferencedByProgram(String id) async => false; @override Future> listActive() async => exercise == null || exercise!.metadata.deletedAt != null ? const [] : [exercise!]; @override Future save(Exercise exercise) async { this.exercise = exercise; saved.add(exercise); } } LocalDataExportSnapshot _backupSnapshot({ DateTime? exportedAt, List mediaAssets = const [], List exercises = const [], List programs = const [], List workoutTemplates = const [], List workoutHistories = const [], List mediaFiles = const [], }) { return LocalDataExportSnapshot( exportedAt: exportedAt ?? DateTime.utc(2026, 7, 22, 12), appSchemaVersion: 19, originDeviceId: 'device-1', mediaAssets: mediaAssets, exercises: exercises, programs: programs, workoutTemplates: workoutTemplates, workoutHistories: workoutHistories, mediaFiles: mediaFiles, ); } LocalBackupResource _backupResource({ required String id, required DateTime updatedAt, required Map payload, }) { return LocalBackupResource(id: id, updatedAt: updatedAt, payload: payload); } Map _backupMetadata(String id, DateTime updatedAt) => { 'id': id, 'createdAt': updatedAt.toUtc().toIso8601String(), 'updatedAt': updatedAt.toUtc().toIso8601String(), 'deletedAt': null, 'schemaVersion': 1, 'syncState': 'synced', 'localRevision': 0, 'originDeviceId': 'device-1', }; final class _FakeLocalDataBackupRepository implements LocalDataBackupRepository { _FakeLocalDataBackupRepository({ LocalDataExportSnapshot? snapshot, this.hasOpenSession = false, }) : snapshot = snapshot ?? _backupSnapshot(); final LocalDataExportSnapshot snapshot; final bool hasOpenSession; final appliedModes = []; final appliedSnapshots = []; @override Future applyImportSnapshot({ required LocalDataExportSnapshot snapshot, required LocalBackupImportMode mode, required DateTime importedAt, }) async { appliedModes.add(mode); appliedSnapshots.add(snapshot); return const LocalBackupImportResult( insertedCount: 0, updatedCount: 0, ignoredOlderCount: 0, deletedByReplaceCount: 0, missingMediaCount: 0, ); } @override Future hasAnyUserData() async => false; @override Future hasOpenActiveWorkoutSession() async => hasOpenSession; @override Future readExportSnapshot( DateTime exportedAt, ) async { return snapshot.copyWith(mediaFiles: snapshot.mediaFiles); } } final class _ThrowingLocalDataBackupRepository implements LocalDataBackupRepository { @override Future applyImportSnapshot({ required LocalDataExportSnapshot snapshot, required LocalBackupImportMode mode, required DateTime importedAt, }) async { throw Exception('transaction failed'); } @override Future hasAnyUserData() async => false; @override Future hasOpenActiveWorkoutSession() async => false; @override Future readExportSnapshot( DateTime exportedAt, ) async { return _backupSnapshot(); } } final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore { _FakeLocalBackupMediaStore({this.files = const [], this.restored = const {}}); final List files; final Map restored; final requestedMediaIds = []; @override Future> readEmbeddableFiles( List mediaAssets, ) async { requestedMediaIds.addAll(mediaAssets.map((asset) => asset.id)); return files; } @override Future> restoreEmbeddedFiles( List files, ) async { return restored; } } final class _FakeWorkoutTemplateRepository implements WorkoutTemplateRepository { final templates = []; @override Future findById(String id) async { for (final template in templates) { if (template.metadata.id == id && template.metadata.deletedAt == null) { return template; } } return null; } @override Future> listActive() async { return templates .where((template) => template.metadata.deletedAt == null) .toList(); } @override Future replaceComposition( WorkoutTemplate template, DateTime deletedAt, ) async { await save(template); } @override Future save(WorkoutTemplate template) async { templates.removeWhere((saved) => saved.metadata.id == template.metadata.id); templates.add(template); } @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @override Future saveProgram(WorkoutTemplateProgram program) async {} } final class _FakeProgramRepository implements ProgramRepository { final programs = []; @override Future findById(String id) async { for (final program in programs) { if (program.metadata.id == id && program.metadata.deletedAt == null) { return program; } } return null; } @override Future> listActive() async { return programs .where((program) => program.metadata.deletedAt == null) .toList(); } @override Future replaceExercises(Program program, DateTime deletedAt) async { await save(program); } @override Future save(Program program) async { programs.removeWhere((saved) => saved.metadata.id == program.metadata.id); programs.add(program); } @override Future saveExercise(ProgramExercise exercise) async { final index = programs.indexWhere( (program) => program.metadata.id == exercise.programId, ); if (index == -1) { return; } final program = programs[index]; programs[index] = program.copyWith( exercises: [ ...program.exercises.where( (saved) => saved.metadata.id != exercise.metadata.id, ), exercise, ], ); } } final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { final histories = []; @override Future findById(String id) async { return histories.where((history) => history.metadata.id == id).firstOrNull; } @override Future> listActive() async { return histories .where((history) => history.metadata.deletedAt == null) .toList(); } @override Future save(WorkoutHistory history) async { histories.removeWhere( (existing) => existing.metadata.id == history.metadata.id, ); histories.add(history); } @override Future patchHeartRateSummary({ required String historyId, required double averageHeartRateBpm, required int maxHeartRateBpm, required DateTime patchedAt, }) async { final index = histories.indexWhere( (history) => history.metadata.id == historyId && history.metadata.deletedAt == null && history.averageHeartRateBpm == null && history.maxHeartRateBpm == null, ); if (index == -1) { return; } final history = histories[index]; histories[index] = history.copyWith( metadata: history.metadata.touch(patchedAt), averageHeartRateBpm: averageHeartRateBpm, maxHeartRateBpm: maxHeartRateBpm, ); } @override Future saveSetResult(WorkoutHistorySetResult result) async {} @override Future saveStepResult(WorkoutHistoryStepResult result) async {} @override Future delete(String id, DateTime deletedAt) async { final index = histories.indexWhere((history) => history.metadata.id == id); if (index == -1) { return; } histories[index] = histories[index].copyWith( metadata: histories[index].metadata.markDeleted(deletedAt), ); } } ActiveWorkoutSessionUseCases _activeUseCase( _FakeActiveSessionRepository repository, _FakeClock clock, ) { return ActiveWorkoutSessionUseCases( sessionRepository: repository, templateRepository: _FakeWorkoutTemplateRepository(), clock: clock, ids: _FakeIds(), originDeviceId: 'device-1', ); } final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; final results = []; final restStates = {}; final setTimerStates = {}; final scoreStopwatchStates = {}; final manualScoreStates = {}; final stepProgressStates = {}; final stepResults = []; @override Future findById(String id) async { return session?.metadata.id == id ? session : null; } @override Future findOpen() async => session; @override Future findRestStateById(String id) async => restStates[id]; @override Future findScoreStopwatchState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { return scoreStopwatchStates.values .where( (state) => state.activeWorkoutSessionId == sessionId && state.programIndex == programIndex && state.exerciseIndex == exerciseIndex && state.setIndex == setIndex && state.metadata.deletedAt == null, ) .firstOrNull; } @override Future findManualScoreState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { return manualScoreStates.values .where( (state) => state.activeWorkoutSessionId == sessionId && state.programIndex == programIndex && state.exerciseIndex == exerciseIndex && state.setIndex == setIndex && state.metadata.deletedAt == null, ) .firstOrNull; } @override Future findSetTimerState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { return setTimerStates.values .where( (state) => state.activeWorkoutSessionId == sessionId && state.programIndex == programIndex && state.exerciseIndex == exerciseIndex && state.setIndex == setIndex && state.metadata.deletedAt == null, ) .firstOrNull; } @override Future findExerciseStepProgressState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { return stepProgressStates.values .where( (state) => state.activeWorkoutSessionId == sessionId && state.programIndex == programIndex && state.exerciseIndex == exerciseIndex && state.setIndex == setIndex && state.metadata.deletedAt == null, ) .firstOrNull; } @override Future> listRestStates(String sessionId) async { return restStates.values .where((rest) => rest.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listScoreStopwatchStates( String sessionId, ) async { return scoreStopwatchStates.values .where( (state) => state.activeWorkoutSessionId == sessionId && state.metadata.deletedAt == null, ) .toList(); } @override Future> listManualScoreStates( String sessionId, ) async { return manualScoreStates.values .where( (state) => state.activeWorkoutSessionId == sessionId && state.metadata.deletedAt == null, ) .toList(); } @override Future> listSetResults(String sessionId) async { return results .where((result) => result.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listSetTimerStates(String sessionId) async { return setTimerStates.values .where( (state) => state.activeWorkoutSessionId == sessionId && state.metadata.deletedAt == null, ) .toList(); } @override Future> listExerciseStepProgressStates( String sessionId, ) async { return stepProgressStates.values .where((state) => state.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listExerciseStepResults( String sessionId, ) async { return stepResults .where((result) => result.activeWorkoutSessionId == sessionId) .toList(); } @override Future save(ActiveWorkoutSession session) async { this.session = session; } @override Future saveRestState(ActiveRestState restState) async { restStates[restState.metadata.id] = restState; } @override Future saveSetTimerState(ActiveSetTimerState state) async { setTimerStates[state.metadata.id] = state; } @override Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async { scoreStopwatchStates[state.metadata.id] = state; } @override Future saveManualScoreState(ActiveManualScoreState state) async { manualScoreStates[state.metadata.id] = state; } @override Future saveExerciseStepProgressState( ActiveExerciseStepProgressState state, ) async { stepProgressStates[state.metadata.id] = state; } @override Future saveExerciseStepResult(ActiveExerciseStepResult result) async { stepResults.removeWhere( (existing) => existing.activeWorkoutSessionId == result.activeWorkoutSessionId && existing.programIndex == result.programIndex && existing.exerciseIndex == result.exerciseIndex && existing.setIndex == result.setIndex && existing.passageIndex == result.passageIndex && existing.stepIndex == result.stepIndex, ); stepResults.add(result); } @override Future deleteScoreStopwatchState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, required DateTime deletedAt, }) async { final state = await findScoreStopwatchState( sessionId: sessionId, programIndex: programIndex, exerciseIndex: exerciseIndex, setIndex: setIndex, ); if (state == null) { return; } scoreStopwatchStates.remove(state.metadata.id); } @override Future deleteManualScoreState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, required DateTime deletedAt, }) async { final state = await findManualScoreState( sessionId: sessionId, programIndex: programIndex, exerciseIndex: exerciseIndex, setIndex: setIndex, ); if (state == null) { return; } manualScoreStates.remove(state.metadata.id); } @override Future saveSetResult(ActiveSetResult result) async { results.removeWhere( (existing) => existing.activeWorkoutSessionId == result.activeWorkoutSessionId && existing.programIndex == result.programIndex && existing.exerciseIndex == result.exerciseIndex && existing.setIndex == result.setIndex, ); results.add(result); } } ActiveWorkoutSession _sessionWithSnapshot({ required int currentProgramIndex, required int currentExerciseIndex, required int currentSetIndex, int setsCount = 2, int targetReps = 10, bool timeEnabled = false, bool repsEnabled = true, bool scoreEnabled = false, int? targetTimeSeconds, ScoreInputMode scoreInputMode = ScoreInputMode.manual, int? targetScoreTimeMs, List exerciseSteps = const [], bool? autoStartNextTimedStepSnapshot = true, bool? autoStartNextTimedStepOverride, bool? templateAutoStartNextTimedStepOverride, }) { final exerciseSnapshot = { 'id': 'exercise-snapshot-1', 'exerciseNameSnapshot': 'Squat', 'setsCount': setsCount, 'timeEnabled': timeEnabled, 'repsEnabled': repsEnabled, 'scoreEnabled': scoreEnabled, 'targetTimeSeconds': targetTimeSeconds, 'targetReps': repsEnabled ? targetReps : null, 'targetScoreTimeMs': targetScoreTimeMs, 'scoreInputModeSnapshot': scoreInputMode.name, 'exerciseStepsSnapshot': exerciseSteps .map((step) => step.toSnapshotJson()) .toList(), 'autoStartNextTimedStepSnapshot': ?autoStartNextTimedStepSnapshot, 'autoStartNextTimedStepOverride': ?autoStartNextTimedStepOverride, }; return ActiveWorkoutSession( metadata: _metadata('session-1'), status: ActiveWorkoutStatus.running, startedAt: DateTime.utc(2026, 7, 17, 12), lastPersistedAt: DateTime.utc(2026, 7, 17, 12), elapsedActiveMs: 0, currentProgramIndex: currentProgramIndex, currentExerciseIndex: currentExerciseIndex, currentSetIndex: currentSetIndex, resolvedTemplateSnapshotJson: jsonEncode({ 'programs': [ { 'id': 'program-snapshot-1', 'programNameSnapshot': 'Programme', 'programSnapshotJson': jsonEncode({ 'exercises': [exerciseSnapshot], }), }, ], 'overrides': [ if (templateAutoStartNextTimedStepOverride != null) { 'workoutTemplateProgramId': 'program-snapshot-1', 'snapshotProgramExerciseId': 'exercise-snapshot-1', 'autoStartNextTimedStepOverride': templateAutoStartNextTimedStepOverride, }, ], }), ); } List _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, ), ]; }