Files
GameTime/test/application/use_cases_test.dart
Blomios 36027edafa test(exercice): couverture QA reprise après kill d'app, enchaînement désactivé (ticket #79)
Ajoute le test de reprise de séance après kill d'app avec
auto-enchaînement des chronos d'étapes désactivé
(test/application/use_cases_test.dart). QA n'a trouvé aucun bug de
production sur le chantier "auto-enchaînement des chronos d'étapes"
(#73) — couverture de test supplémentaire uniquement. analyze propre
(mêmes infos préexistantes), 154/154 tests verts, build APK debug
validé. Dernier ticket du chantier (#75 à #79 tous terminés).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:39:14 +02:00

3077 lines
90 KiB
Dart

import 'dart:convert';
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('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>()),
);
});
}
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';
}
}
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);
}
}
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 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<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<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> 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,
List<ExerciseStep> exerciseSteps = const [],
bool? autoStartNextTimedStepSnapshot = true,
bool? autoStartNextTimedStepOverride,
bool? templateAutoStartNextTimedStepOverride,
}) {
final exerciseSnapshot = {
'id': 'exercise-snapshot-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': setsCount,
'timeEnabled': false,
'repsEnabled': true,
'scoreEnabled': false,
'targetReps': targetReps,
'exerciseStepsSnapshot': exerciseSteps
.map((step) => step.toSnapshotJson())
.toList(),
if (autoStartNextTimedStepSnapshot != null)
'autoStartNextTimedStepSnapshot': autoStartNextTimedStepSnapshot,
if (autoStartNextTimedStepOverride != null)
'autoStartNextTimedStepOverride': autoStartNextTimedStepOverride,
};
return ActiveWorkoutSession(
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,
),
];
}