Files
GameTime/test/application/use_cases_test.dart
Blomios 2e40694de4 fix(watch): corrige applicationId et nettoie la config de build montre
Répare la régression de packaging introduite par le reset/remerge de
develop : l'APK montre était packagée avec com.gametime.app au lieu de
com.gametime.watch (namespace correct mais applicationId hérité du
téléphone), ce qui cassait la détection du bridge. Aligne applicationId
sur com.gametime.watch, nettoie la config de signing partagée sur les
deux modules android/app et watch_app/android/app, et ajuste
use_cases + watch_session_screen en conséquence.

Validé : APK montre release package = com.gametime.watch, même SHA-256
de certificat sur les deux APKs (70cc69e8...45c7815), tests use_cases et
watch_session_screen OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:18:48 +02:00

5256 lines
156 KiB
Dart

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<DomainException>()),
);
});
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<DomainException>()),
);
expect(
() => Exercise(
metadata: _metadata('exercise-2'),
name: 'Sprint',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetScore: 10,
defaultTargetScoreTimeMs: 12000,
),
throwsA(isA<DomainException>()),
);
});
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<DomainException>()),
);
});
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<DomainException>()),
);
await expectLater(
useCase.create(
name: 'Pompes',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
),
throwsA(isA<DomainException>()),
);
await expectLater(
useCase.create(
name: 'Charge',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreLabel: 'Charge',
scoreUnit: 'kg',
),
throwsA(isA<DomainException>()),
);
},
);
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<DomainException>()),
);
await expectLater(
useCase.create(
name: 'Pompes',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: -1,
),
throwsA(isA<DomainException>()),
);
await expectLater(
useCase.create(
name: 'Charge',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreLabel: 'Charge',
scoreUnit: 'kg',
defaultTargetScore: -1,
),
throwsA(isA<DomainException>()),
);
});
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<DomainException>()),
);
},
);
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<DomainException>()),
);
});
test('Exercise use case rejects more than eight steps', () async {
final useCase = _exerciseUseCase(_FakeExerciseRepository());
await expectLater(
useCase.create(
name: 'Complexe',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 1,
steps: [
for (var index = 0; index < 9; index++)
_step(id: 'step-$index', position: index),
],
),
throwsA(isA<DomainException>()),
);
});
test('Exercise use case rejects non-contiguous step positions', () async {
final useCase = _exerciseUseCase(_FakeExerciseRepository());
await expectLater(
useCase.create(
name: 'Complexe',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 1,
steps: [
_step(id: 'step-1', position: 1),
_step(id: 'step-0', position: 0),
],
),
throwsA(isA<DomainException>()),
);
});
test('Exercise step validation rejects invalid basic fields', () {
expect(() => _step(name: ''), throwsA(isA<DomainException>()));
expect(() => _step(defaultTargetValue: 0), throwsA(isA<DomainException>()));
});
test('Exercise step validation rejects disabled score values', () {
expect(
() => _step(hasScore: false, scoreLabel: 'Charge'),
throwsA(isA<DomainException>()),
);
expect(
() => _step(hasScore: false, defaultTargetScore: 0),
throwsA(isA<DomainException>()),
);
});
test(
'Exercise step manual score validation accepts zero and rejects negative',
() {
final step = _step(
hasScore: true,
scoreLabel: 'Charge',
scoreUnit: 'kg',
defaultTargetScore: 0,
);
expect(step.defaultTargetScore, 0);
expect(
() => _step(
hasScore: true,
scoreLabel: 'Charge',
scoreUnit: 'kg',
defaultTargetScore: -1,
),
throwsA(isA<DomainException>()),
);
expect(
() => _step(hasScore: true, scoreLabel: 'Charge'),
throwsA(isA<DomainException>()),
);
},
);
test('Exercise step stopwatch score validation rejects manual fields', () {
final step = _step(
hasScore: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetScoreTimeMs: 12000,
);
expect(step.defaultTargetScoreTimeMs, 12000);
expect(
() => _step(
hasScore: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetScoreTimeMs: 0,
),
throwsA(isA<DomainException>()),
);
expect(
() => _step(
hasScore: true,
scoreInputMode: ScoreInputMode.stopwatch,
scoreLabel: 'Charge',
),
throwsA(isA<DomainException>()),
);
});
test('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<DomainException>()),
);
await expectLater(
useCase.create(
name: 'Tirs chrono',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetReps: 1,
steps: [linkedStep],
),
throwsA(isA<DomainException>()),
);
});
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<DomainException>()),
);
});
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<DomainException>()),
);
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<DomainException>()),
);
});
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<DomainException>()),
);
});
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<DomainException>()),
);
},
);
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<DomainException>()),
);
});
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<DomainException>()),
);
},
);
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<DomainException>()),
);
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<DomainException>()),
);
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<RemoteAuthException>().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<RemoteAuthException>().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<DomainException>()),
);
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<DomainException>()),
);
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<DomainException>()),
);
});
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(
'WorkoutHistoryUseCases applies pending heart rate summary when history appears',
() async {
final repository = _FakeWorkoutHistoryRepository();
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,
),
);
repository.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 histories = await useCase.listActive();
expect(histories.single.averageHeartRateBpm, 126.5);
expect(histories.single.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 = <ActiveWorkoutSensorState>[];
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<void>.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<DomainException>()),
);
expect(
() => WorkoutTemplate(
metadata: _metadata('template-tags'),
name: 'Template',
tags: List.generate(9, (index) => 'tag$index'),
),
throwsA(isA<DomainException>()),
);
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<DomainException>()),
);
});
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<LocalBackupException>().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<LocalBackupException>().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<LocalBackupException>().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<LocalBackupException>().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<LocalBackupException>().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<LocalBackupException>().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<LocalBackupException>().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<ProgressionExerciseOptionData> exerciseOptions = const [];
final measureOptions = <String, List<ProgressionMeasureOptionData>>{};
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<ProgressionGlobalStatsData> readGlobalStats(
ProgressionDateRange range,
) async {
lastGlobalRange = range;
return globalStats;
}
@override
Future<List<ProgressionExerciseOptionData>> listExerciseOptions(
ProgressionDateRange range,
) async {
return exerciseOptions;
}
@override
Future<List<ProgressionMeasureOptionData>> listMeasureOptions({
required ProgressionDateRange range,
required String exerciseKey,
}) async {
return measureOptions[exerciseKey] ?? const [];
}
@override
Future<ProgressionExerciseSeriesData> 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<void> clearToken() async {
token = null;
expiresAt = null;
}
@override
Future<String?> readToken() async => token;
@override
Future<void> saveToken(String token, DateTime expiresAt) async {
this.token = token;
this.expiresAt = expiresAt;
}
}
final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
UserAccountSession? session;
var clearCalls = 0;
@override
Future<void> clearSession() async {
clearCalls += 1;
session = null;
}
@override
Future<UserAccountSession?> currentSession() async => session;
@override
Future<void> 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<RemoteAuthResult> 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<RemoteAuthResult> login({
required String email,
required String password,
}) async {
loginCalls += 1;
final exception = loginException;
if (exception != null) {
throw exception;
}
return _authResult(email);
}
@override
Future<void> 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<RemoteSyncPushResult> push({
required String deviceId,
required List<RemoteSyncPushItem> items,
required String token,
}) async {
pushCalls += 1;
final error = exception;
if (error != null) {
throw error;
}
return pushResult;
}
@override
Future<RemoteSyncPullResult> 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 = <SyncMetadataSnapshot>[];
@override
Future<SyncMetadataSnapshot> read() async => metadata;
@override
Future<void> save(SyncMetadataSnapshot metadata) async {
this.metadata = metadata;
saved.add(metadata);
}
}
final class _FakeRemoteResourceMappingRepository
implements RemoteResourceMappingRepository {
final saved = <RemoteResourceMapping>[];
@override
Future<RemoteResourceMapping?> find({
required SyncResourceType resourceType,
required String clientId,
}) async {
return saved
.where(
(mapping) =>
mapping.resourceType == resourceType &&
mapping.clientId == clientId,
)
.firstOrNull;
}
@override
Future<void> 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 = <PendingSyncChange>[];
final syncedChangeLogIds = <String>[];
final appliedItems = <RemoteSyncedItem>[];
DateTime? localUpdatedAt;
@override
Future<List<PendingSyncChange>> listPendingChanges() async {
return pendingChanges;
}
@override
Future<void> markChangesSynced(
List<String> changeLogIds,
DateTime syncedAt,
) async {
syncedChangeLogIds.addAll(changeLogIds);
}
@override
Future<bool> 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<ShareInboxItem> inboxItems = const [];
var sendCalls = 0;
var acceptCalls = 0;
var declineCalls = 0;
var revokeCalls = 0;
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
acceptCalls += 1;
final error = exception;
if (error != null) {
throw error;
}
return acceptResult ?? _remoteSharedProgramItem();
}
@override
Future<void> declineShare(String shareId, String token) async {
declineCalls += 1;
final error = exception;
if (error != null) {
throw error;
}
}
@override
Future<List<ShareInboxItem>> fetchInbox(String token) async {
final error = exception;
if (error != null) {
throw error;
}
return inboxItems;
}
@override
Future<void> revokeShare(String shareId, String token) async {
revokeCalls += 1;
final error = exception;
if (error != null) {
throw error;
}
}
@override
Future<RemoteShareSendResult> sendShare({
required ShareResourceType resourceType,
required Map<String, Object?> payload,
required List<String> 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 = <ShareInboxItem>[];
@override
Future<ShareInboxItem?> findByShareId(String shareId) async {
return items.where((item) => item.shareId == shareId).firstOrNull;
}
@override
Future<List<ShareInboxItem>> listAll() async => List.of(items);
@override
Future<void> 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<void> upsert(ShareInboxItem item) async {
items.removeWhere((existing) => existing.shareId == item.shareId);
items.add(item);
}
@override
Future<void> upsertAll(List<ShareInboxItem> items) async {
for (final item in items) {
await upsert(item);
}
}
}
final class _FakePendingShareActionRepository
implements PendingShareActionRepository {
final actions = <PendingShareAction>[];
@override
Future<void> add(PendingShareAction action) async {
actions.add(action);
}
@override
Future<List<PendingShareAction>> listPending() async {
return actions
.where((action) => action.status != PendingShareActionStatus.succeeded)
.toList();
}
@override
Future<void> markFailed(String id, DateTime attemptedAt) async {
_mark(id, attemptedAt, PendingShareActionStatus.failed);
}
@override
Future<void> 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<String, Object?> _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 = <Exercise>[];
@override
Future<Exercise?> findById(String id) async {
return exercise?.metadata.id == id ? exercise : null;
}
@override
Future<bool> isReferencedByProgram(String id) async => false;
@override
Future<List<Exercise>> listActive() async =>
exercise == null || exercise!.metadata.deletedAt != null
? const []
: [exercise!];
@override
Future<void> save(Exercise exercise) async {
this.exercise = exercise;
saved.add(exercise);
}
}
LocalDataExportSnapshot _backupSnapshot({
DateTime? exportedAt,
List<LocalBackupResource> mediaAssets = const [],
List<LocalBackupResource> exercises = const [],
List<LocalBackupResource> programs = const [],
List<LocalBackupResource> workoutTemplates = const [],
List<LocalBackupResource> workoutHistories = const [],
List<EmbeddedBackupMediaFile> 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<String, Object?> payload,
}) {
return LocalBackupResource(id: id, updatedAt: updatedAt, payload: payload);
}
Map<String, Object?> _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 = <LocalBackupImportMode>[];
final appliedSnapshots = <LocalDataExportSnapshot>[];
@override
Future<LocalBackupImportResult> 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<bool> hasAnyUserData() async => false;
@override
Future<bool> hasOpenActiveWorkoutSession() async => hasOpenSession;
@override
Future<LocalDataExportSnapshot> readExportSnapshot(
DateTime exportedAt,
) async {
return snapshot.copyWith(mediaFiles: snapshot.mediaFiles);
}
}
final class _ThrowingLocalDataBackupRepository
implements LocalDataBackupRepository {
@override
Future<LocalBackupImportResult> applyImportSnapshot({
required LocalDataExportSnapshot snapshot,
required LocalBackupImportMode mode,
required DateTime importedAt,
}) async {
throw Exception('transaction failed');
}
@override
Future<bool> hasAnyUserData() async => false;
@override
Future<bool> hasOpenActiveWorkoutSession() async => false;
@override
Future<LocalDataExportSnapshot> readExportSnapshot(
DateTime exportedAt,
) async {
return _backupSnapshot();
}
}
final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore {
_FakeLocalBackupMediaStore({this.files = const [], this.restored = const {}});
final List<EmbeddedBackupMediaFile> files;
final Map<String, RestoredMediaFile> restored;
final requestedMediaIds = <String>[];
@override
Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
List<LocalBackupResource> mediaAssets,
) async {
requestedMediaIds.addAll(mediaAssets.map((asset) => asset.id));
return files;
}
@override
Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
List<EmbeddedBackupMediaFile> files,
) async {
return restored;
}
}
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
final templates = <WorkoutTemplate>[];
@override
Future<WorkoutTemplate?> findById(String id) async {
for (final template in templates) {
if (template.metadata.id == id && template.metadata.deletedAt == null) {
return template;
}
}
return null;
}
@override
Future<List<WorkoutTemplate>> listActive() async {
return templates
.where((template) => template.metadata.deletedAt == null)
.toList();
}
@override
Future<void> replaceComposition(
WorkoutTemplate template,
DateTime deletedAt,
) async {
await save(template);
}
@override
Future<void> save(WorkoutTemplate template) async {
templates.removeWhere((saved) => saved.metadata.id == template.metadata.id);
templates.add(template);
}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}
final class _FakeProgramRepository implements ProgramRepository {
final programs = <Program>[];
@override
Future<Program?> findById(String id) async {
for (final program in programs) {
if (program.metadata.id == id && program.metadata.deletedAt == null) {
return program;
}
}
return null;
}
@override
Future<List<Program>> listActive() async {
return programs
.where((program) => program.metadata.deletedAt == null)
.toList();
}
@override
Future<void> replaceExercises(Program program, DateTime deletedAt) async {
await save(program);
}
@override
Future<void> save(Program program) async {
programs.removeWhere((saved) => saved.metadata.id == program.metadata.id);
programs.add(program);
}
@override
Future<void> 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 = <WorkoutHistory>[];
@override
Future<WorkoutHistory?> findById(String id) async {
return histories.where((history) => history.metadata.id == id).firstOrNull;
}
@override
Future<List<WorkoutHistory>> listActive() async {
return histories
.where((history) => history.metadata.deletedAt == null)
.toList();
}
@override
Future<void> save(WorkoutHistory history) async {
histories.removeWhere(
(existing) => existing.metadata.id == history.metadata.id,
);
histories.add(history);
}
@override
Future<void> 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<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
@override
Future<void> 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 = <ActiveSetResult>[];
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final manualScoreStates = <String, ActiveManualScoreState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<ActiveRestState?> findRestStateById(String id) async => restStates[id];
@override
Future<ActiveScoreStopwatchState?> 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<ActiveManualScoreState?> 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<ActiveSetTimerState?> 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<ActiveExerciseStepProgressState?> 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<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates.values
.where((rest) => rest.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
return scoreStopwatchStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.metadata.deletedAt == null,
)
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.metadata.deletedAt == null,
)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.metadata.deletedAt == null,
)
.toList();
}
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return stepProgressStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return stepResults
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<void> save(ActiveWorkoutSession session) async {
this.session = session;
}
@override
Future<void> saveRestState(ActiveRestState restState) async {
restStates[restState.metadata.id] = restState;
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
setTimerStates[state.metadata.id] = state;
}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
manualScoreStates[state.metadata.id] = state;
}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {
stepProgressStates[state.metadata.id] = state;
}
@override
Future<void> 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<void> 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<void> 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<void> 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<ExerciseStep> 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<ExerciseStep> _twoTimedSteps() {
return [
_step(
id: 'step-1',
position: 0,
name: 'Gauche',
type: ExerciseStepType.time,
defaultTargetValue: 1,
),
_step(
id: 'step-2',
position: 1,
name: 'Droite',
type: ExerciseStepType.time,
defaultTargetValue: 1,
),
];
}