4068 lines
121 KiB
Dart
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,
|
|
),
|
|
];
|
|
}
|