Files
GameTime/test/application/use_cases_test.dart
2026-07-22 13:30:06 +02:00

4068 lines
121 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';
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 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('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('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('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('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(
'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('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('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,
}) {
return ExerciseStep(
id: id,
position: position,
name: name,
type: type,
defaultTargetValue: defaultTargetValue,
hasScore: hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
);
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
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":[]}',
);
}
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 _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,
],
);
}
}
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 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<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<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> 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> 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,
),
];
}