Corrige l'acceptation de partage hors-ligne (acceptShare, _acceptCachedShareAndQueueAck, _importSharedPayload dans application/use_cases.dart) : crée bien une copie locale indépendante avec de nouveaux IDs avant de mettre l'accusé serveur en file d'attente. Ajuste app_bootstrap.dart, profile_screen.dart et share_screen.dart en conséquence. flutter pub get OK, dart format appliqué (aucun changement), analyze propre (mêmes infos préexistantes), 137/137 tests verts, build APK debug validé. Dernier ticket du chantier "Ajouter les features serveur au client" (#63, tickets #64 à #70 tous terminés). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2538 lines
74 KiB
Dart
2538 lines
74 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:gametime/application/application.dart';
|
|
import 'package:gametime/domain/domain.dart';
|
|
|
|
void main() {
|
|
test('Exercise requires at least one active measure', () {
|
|
expect(
|
|
() => Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Push up',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: false,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Exercise use case rejects active measures without default targets',
|
|
() async {
|
|
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
|
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Squat',
|
|
hasTimeMeasure: true,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: false,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Pompes',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Charge',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('Exercise use case rejects invalid default targets', () async {
|
|
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
|
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Squat',
|
|
hasTimeMeasure: true,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: false,
|
|
defaultTargetTimeSeconds: 0,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Pompes',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: -1,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Charge',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
defaultTargetScore: -1,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Exercise use case accepts a zero manual score default target',
|
|
() async {
|
|
final repository = _FakeExerciseRepository();
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
final exercise = await useCase.create(
|
|
name: 'Charge',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
defaultTargetScore: 0,
|
|
);
|
|
|
|
expect(exercise.defaultTargetScore, 0);
|
|
expect(repository.saved.single.defaultTargetScore, 0);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'Exercise use case accepts stopwatch score without default target',
|
|
() async {
|
|
final repository = _FakeExerciseRepository();
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
final exercise = await useCase.create(
|
|
name: 'Sprint',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
scoreLabel: 'Temps réalisé',
|
|
);
|
|
|
|
expect(exercise.defaultTargetScoreTimeMs, isNull);
|
|
expect(repository.saved.single.metadata.id, exercise.metadata.id);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'Exercise use case rejects non-positive stopwatch score default target',
|
|
() async {
|
|
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
|
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Sprint',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
scoreLabel: 'Temps réalisé',
|
|
defaultTargetScoreTimeMs: 0,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('Exercise update validates default targets too', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
await expectLater(
|
|
useCase.update(
|
|
id: 'exercise-1',
|
|
name: 'Squat',
|
|
hasTimeMeasure: true,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: false,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Exercise use case rejects more than eight steps', () async {
|
|
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
|
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Complexe',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 1,
|
|
steps: [
|
|
for (var index = 0; index < 9; index++)
|
|
_step(id: 'step-$index', position: index),
|
|
],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Exercise use case rejects non-contiguous step positions', () async {
|
|
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
|
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Complexe',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 1,
|
|
steps: [
|
|
_step(id: 'step-1', position: 1),
|
|
_step(id: 'step-0', position: 0),
|
|
],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Exercise step validation rejects invalid basic fields', () {
|
|
expect(() => _step(name: ''), throwsA(isA<DomainException>()));
|
|
expect(() => _step(defaultTargetValue: 0), throwsA(isA<DomainException>()));
|
|
});
|
|
|
|
test('Exercise step validation rejects disabled score values', () {
|
|
expect(
|
|
() => _step(hasScore: false, scoreLabel: 'Charge'),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => _step(hasScore: false, defaultTargetScore: 0),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Exercise step manual score validation accepts zero and rejects negative',
|
|
() {
|
|
final step = _step(
|
|
hasScore: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
defaultTargetScore: 0,
|
|
);
|
|
|
|
expect(step.defaultTargetScore, 0);
|
|
expect(
|
|
() => _step(
|
|
hasScore: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
defaultTargetScore: -1,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => _step(hasScore: true, scoreLabel: 'Charge'),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('Exercise step stopwatch score validation rejects manual fields', () {
|
|
final step = _step(
|
|
hasScore: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
defaultTargetScoreTimeMs: 12000,
|
|
);
|
|
|
|
expect(step.defaultTargetScoreTimeMs, 12000);
|
|
expect(
|
|
() => _step(
|
|
hasScore: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
defaultTargetScoreTimeMs: 0,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => _step(
|
|
hasScore: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
scoreLabel: 'Charge',
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Exercise image gallery rejects a sixth image', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const [
|
|
'media-1',
|
|
'media-2',
|
|
'media-3',
|
|
'media-4',
|
|
'media-5',
|
|
],
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
await expectLater(
|
|
useCase.addImage(exerciseId: 'exercise-1', mediaAssetId: 'media-6'),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Exercise image gallery reorders images', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
final updated = await useCase.reorderImages(
|
|
exerciseId: 'exercise-1',
|
|
orderedMediaIds: const ['media-3', 'media-1', 'media-2'],
|
|
);
|
|
|
|
expect(updated.imageMediaIds, ['media-3', 'media-1', 'media-2']);
|
|
expect(repository.saved.single.imageMediaIds, updated.imageMediaIds);
|
|
});
|
|
|
|
test('Exercise image gallery removes an image', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
final updated = await useCase.removeImage(
|
|
exerciseId: 'exercise-1',
|
|
mediaAssetId: 'media-2',
|
|
);
|
|
|
|
expect(updated.imageMediaIds, ['media-1', 'media-3']);
|
|
expect(updated.imageMediaId, 'media-1');
|
|
});
|
|
|
|
test('Exercise icon can be set to an image from the gallery', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const ['media-1', 'media-2'],
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
final updated = await useCase.setIcon(
|
|
exerciseId: 'exercise-1',
|
|
mediaAssetId: 'media-2',
|
|
);
|
|
|
|
expect(updated.iconMediaId, 'media-2');
|
|
expect(repository.saved.single.iconMediaId, 'media-2');
|
|
});
|
|
|
|
test('Exercise icon rejects images outside the gallery', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const ['media-1'],
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
await expectLater(
|
|
useCase.setIcon(exerciseId: 'exercise-1', mediaAssetId: 'media-2'),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(repository.saved, isEmpty);
|
|
});
|
|
|
|
test('Exercise icon is reset when its image is removed', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const ['media-1', 'media-2'],
|
|
iconMediaId: 'media-2',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
final updated = await useCase.removeImage(
|
|
exerciseId: 'exercise-1',
|
|
mediaAssetId: 'media-2',
|
|
);
|
|
|
|
expect(updated.imageMediaIds, ['media-1']);
|
|
expect(updated.iconMediaId, isNull);
|
|
expect(repository.saved.single.iconMediaId, isNull);
|
|
});
|
|
|
|
test('program exercise snapshot keeps the full image gallery', () async {
|
|
final exerciseRepository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
|
|
videoMediaId: 'video-1',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final programRepository = _FakeProgramRepository()
|
|
..programs.add(
|
|
Program(
|
|
metadata: _metadata('program-1'),
|
|
name: 'Jambes',
|
|
defaultRestSeconds: 60,
|
|
),
|
|
);
|
|
final useCase = ProgramUseCases(
|
|
programRepository: programRepository,
|
|
exerciseRepository: exerciseRepository,
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final snapshot = await useCase.addExercise(
|
|
programId: 'program-1',
|
|
exerciseId: 'exercise-1',
|
|
position: 0,
|
|
setsCount: 3,
|
|
enabledMeasures: const {WorkoutMeasure.reps},
|
|
);
|
|
|
|
expect(snapshot.exerciseImageMediaIdSnapshot, 'media-1');
|
|
expect(snapshot.exerciseImageMediaIdsSnapshot, [
|
|
'media-1',
|
|
'media-2',
|
|
'media-3',
|
|
]);
|
|
expect(snapshot.exerciseVideoMediaIdSnapshot, 'video-1');
|
|
expect(snapshot.toSnapshotJson()['imageMediaIdsSnapshot'], [
|
|
'media-1',
|
|
'media-2',
|
|
'media-3',
|
|
]);
|
|
expect(
|
|
programRepository
|
|
.programs
|
|
.single
|
|
.exercises
|
|
.single
|
|
.exerciseImageMediaIdsSnapshot,
|
|
snapshot.exerciseImageMediaIdsSnapshot,
|
|
);
|
|
});
|
|
|
|
test('delete exercise keeps existing program snapshots unchanged', () async {
|
|
final exerciseRepository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final programRepository = _FakeProgramRepository()
|
|
..programs.add(
|
|
Program(
|
|
metadata: _metadata('program-1'),
|
|
name: 'Jambes',
|
|
defaultRestSeconds: 60,
|
|
exercises: [
|
|
_programExercise(
|
|
id: 'program-exercise-1',
|
|
programId: 'program-1',
|
|
sourceExerciseId: 'exercise-1',
|
|
position: 0,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final useCase = _exerciseUseCase(
|
|
exerciseRepository,
|
|
programRepository: programRepository,
|
|
);
|
|
|
|
final deleted = await useCase.delete('exercise-1');
|
|
|
|
expect(deleted.metadata.deletedAt, isNotNull);
|
|
expect(exerciseRepository.listActive(), completion(isEmpty));
|
|
expect(programRepository.programs.single.exercises, hasLength(1));
|
|
expect(
|
|
programRepository.programs.single.exercises.single.sourceExerciseId,
|
|
'exercise-1',
|
|
);
|
|
expect(
|
|
programRepository.programs.single.exercises.single.exerciseNameSnapshot,
|
|
'Exercise program-exercise-1',
|
|
);
|
|
expect(programRepository.programs.single.exercises.single.position, 0);
|
|
});
|
|
|
|
test(
|
|
'delete program removes it from referencing workout templates',
|
|
() async {
|
|
final programRepository = _FakeProgramRepository()
|
|
..programs.add(
|
|
Program(
|
|
metadata: _metadata('program-1'),
|
|
name: 'Jambes',
|
|
defaultRestSeconds: 60,
|
|
),
|
|
);
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.add(
|
|
WorkoutTemplate(
|
|
metadata: _metadata('template-1'),
|
|
name: 'Séance',
|
|
programs: [
|
|
_templateProgram(
|
|
id: 'template-program-1',
|
|
workoutTemplateId: 'template-1',
|
|
sourceProgramId: 'program-1',
|
|
position: 0,
|
|
),
|
|
_templateProgram(
|
|
id: 'template-program-2',
|
|
workoutTemplateId: 'template-1',
|
|
sourceProgramId: 'program-2',
|
|
position: 1,
|
|
),
|
|
],
|
|
overrides: [
|
|
WorkoutTemplateExerciseOverride(
|
|
metadata: _metadata('override-1'),
|
|
workoutTemplateProgramId: 'template-program-1',
|
|
snapshotProgramExerciseId: 'exercise-snapshot-1',
|
|
targetRepsOverride: 8,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final useCase = ProgramUseCases(
|
|
programRepository: programRepository,
|
|
exerciseRepository: _FakeExerciseRepository(),
|
|
templateRepository: templateRepository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final deleted = await useCase.delete('program-1');
|
|
|
|
expect(deleted.metadata.deletedAt, isNotNull);
|
|
expect(programRepository.listActive(), completion(isEmpty));
|
|
final template = templateRepository.templates.single;
|
|
expect(template.programs, hasLength(1));
|
|
expect(template.programs.single.sourceProgramId, 'program-2');
|
|
expect(template.programs.single.position, 0);
|
|
expect(template.overrides, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('delete workout template hides it from active list', () async {
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.add(
|
|
WorkoutTemplate(metadata: _metadata('template-1'), name: 'Séance'),
|
|
);
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: templateRepository,
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final deleted = await useCase.delete('template-1');
|
|
|
|
expect(deleted.metadata.deletedAt, isNotNull);
|
|
expect(templateRepository.listActive(), completion(isEmpty));
|
|
});
|
|
|
|
test('history stays unchanged after deleting its source template', () async {
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.add(
|
|
WorkoutTemplate(metadata: _metadata('template-1'), name: 'Séance'),
|
|
);
|
|
final history = WorkoutHistory(
|
|
metadata: _metadata('history-1'),
|
|
sourceWorkoutTemplateId: 'template-1',
|
|
nameSnapshot: 'Séance historique',
|
|
startedAt: DateTime.utc(2026, 7, 17, 10),
|
|
endedAt: DateTime.utc(2026, 7, 17, 11),
|
|
totalActiveMs: 3600000,
|
|
completed: true,
|
|
historySnapshotJson: '{"name":"Séance historique"}',
|
|
);
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: templateRepository,
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await useCase.delete('template-1');
|
|
|
|
expect(history.sourceWorkoutTemplateId, 'template-1');
|
|
expect(history.nameSnapshot, 'Séance historique');
|
|
expect(history.historySnapshotJson, '{"name":"Séance historique"}');
|
|
});
|
|
|
|
test('Workout template use case rejects active measure overrides', () async {
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.overrideExerciseTargets(
|
|
workoutTemplateProgramId: 'template-program-1',
|
|
snapshotProgramExerciseId: 'exercise-snapshot-1',
|
|
attemptedMeasureOverride: {WorkoutMeasure.reps},
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Workout template use case rejects target overrides for inactive measures',
|
|
() async {
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.saveConfigured(
|
|
name: 'Séance tir',
|
|
programs: [
|
|
WorkoutTemplateProgramConfig(
|
|
clientKey: 'program-1',
|
|
programNameSnapshot: 'Programme tir',
|
|
defaultRestSecondsSnapshot: 30,
|
|
programSnapshotJson: jsonEncode({
|
|
'exercises': [
|
|
{
|
|
'id': 'exercise-snapshot-1',
|
|
'exerciseNameSnapshot': 'Lancers francs',
|
|
'setsCount': 3,
|
|
'timeEnabled': true,
|
|
'repsEnabled': false,
|
|
'scoreEnabled': false,
|
|
'targetTimeSeconds': 60,
|
|
'targetReps': null,
|
|
'targetScore': null,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
],
|
|
overrides: const [
|
|
WorkoutTemplateExerciseOverrideConfig(
|
|
workoutTemplateProgramClientKey: 'program-1',
|
|
snapshotProgramExerciseId: 'exercise-snapshot-1',
|
|
targetRepsOverride: 10,
|
|
),
|
|
],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('Active session elapsed time is based on persisted timestamps', () {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = ActiveWorkoutSession(
|
|
metadata: _metadata('session-1'),
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: clock.now(),
|
|
lastPersistedAt: clock.now(),
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: '{}',
|
|
);
|
|
|
|
clock.value = clock.value.add(const Duration(seconds: 30));
|
|
final paused = session.pause(clock.now());
|
|
expect(paused.elapsedActiveMs, 30000);
|
|
|
|
clock.value = clock.value.add(const Duration(minutes: 5));
|
|
final resumed = paused.resume(clock.now());
|
|
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 30000);
|
|
|
|
clock.value = clock.value.add(const Duration(seconds: 15));
|
|
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000);
|
|
});
|
|
|
|
test('adjustRestSeconds persists adjusted rest duration', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final repository = _FakeActiveSessionRepository()
|
|
..restStates['rest-1'] = ActiveRestState(
|
|
metadata: _metadata('rest-1'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 0,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now(),
|
|
);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final adjusted = await useCase.adjustRestSeconds(
|
|
restStateId: 'rest-1',
|
|
deltaSeconds: -75,
|
|
);
|
|
|
|
expect(adjusted.adjustedRestSeconds, 0);
|
|
expect(repository.restStates['rest-1']!.adjustedRestSeconds, 0);
|
|
});
|
|
|
|
test('skipRest persists skippedAt timestamp', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final repository = _FakeActiveSessionRepository()
|
|
..restStates['rest-1'] = ActiveRestState(
|
|
metadata: _metadata('rest-1'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 0,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now(),
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 10));
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final skipped = await useCase.skipRest(restStateId: 'rest-1');
|
|
|
|
expect(skipped.skippedAt, clock.now());
|
|
expect(repository.restStates['rest-1']!.skippedAt, clock.now());
|
|
});
|
|
|
|
test('findActiveRest returns latest non-ended non-skipped rest', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final repository = _FakeActiveSessionRepository()
|
|
..restStates['ended'] = ActiveRestState(
|
|
metadata: _metadata('ended'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 0,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now(),
|
|
endedAt: clock.now().add(const Duration(seconds: 60)),
|
|
)
|
|
..restStates['older-active'] = ActiveRestState(
|
|
metadata: _metadata('older-active'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 1,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now().add(const Duration(seconds: 30)),
|
|
)
|
|
..restStates['latest-active'] = ActiveRestState(
|
|
metadata: _metadata('latest-active'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 2,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now().add(const Duration(seconds: 90)),
|
|
)
|
|
..restStates['skipped'] = ActiveRestState(
|
|
metadata: _metadata('skipped'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 3,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now().add(const Duration(seconds: 120)),
|
|
skippedAt: clock.now().add(const Duration(seconds: 125)),
|
|
);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final active = await useCase.findActiveRest(sessionId: 'session-1');
|
|
|
|
expect(active?.metadata.id, 'latest-active');
|
|
});
|
|
|
|
test('upsertSetResultAtPosition does not move session cursor', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 1,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
await useCase.upsertSetResultAtPosition(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
status: SetResultStatus.completed,
|
|
actualReps: 12,
|
|
);
|
|
|
|
expect(repository.session?.currentProgramIndex, 0);
|
|
expect(repository.session?.currentExerciseIndex, 0);
|
|
expect(repository.session?.currentSetIndex, 1);
|
|
expect(repository.results.single.actualReps, 12);
|
|
});
|
|
|
|
test(
|
|
'upsertSetResultAtPosition preserves existing id and createdAt',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 1,
|
|
);
|
|
final existingCreatedAt = DateTime.utc(2026, 7, 17, 10);
|
|
final existing = ActiveSetResult(
|
|
metadata: EntityMetadata(
|
|
id: 'result-1',
|
|
createdAt: existingCreatedAt,
|
|
updatedAt: existingCreatedAt,
|
|
originDeviceId: 'device-1',
|
|
),
|
|
activeWorkoutSessionId: session.metadata.id,
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualReps: 8,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()
|
|
..session = session
|
|
..results.add(existing);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final updated = await useCase.upsertSetResultAtPosition(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
status: SetResultStatus.completed,
|
|
actualReps: 12,
|
|
);
|
|
|
|
expect(updated.metadata.id, 'result-1');
|
|
expect(updated.metadata.createdAt, existingCreatedAt);
|
|
expect(updated.actualReps, 12);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'listSetResults returns pending, skipped and completed statuses',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 2,
|
|
setsCount: 3,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()
|
|
..session = session
|
|
..results.addAll([
|
|
ActiveSetResult(
|
|
metadata: _metadata('completed'),
|
|
activeWorkoutSessionId: session.metadata.id,
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualReps: 10,
|
|
status: SetResultStatus.completed,
|
|
),
|
|
ActiveSetResult(
|
|
metadata: _metadata('skipped'),
|
|
activeWorkoutSessionId: session.metadata.id,
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 1,
|
|
status: SetResultStatus.skipped,
|
|
),
|
|
]);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final states = await useCase.listSetResults(session.metadata.id);
|
|
|
|
expect(states.map((state) => state.status), [
|
|
SetPositionStatus.completed,
|
|
SetPositionStatus.skipped,
|
|
SetPositionStatus.pending,
|
|
]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'score stopwatch start stop resume accumulates elapsed duration',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
await useCase.startScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 5));
|
|
final stopped = await useCase.stopScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 10));
|
|
await useCase.resumeScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 3));
|
|
final stoppedAgain = await useCase.stopScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(stopped.accumulatedMs, 5000);
|
|
expect(stoppedAgain.accumulatedMs, 8000);
|
|
},
|
|
);
|
|
|
|
test('score stopwatch state survives use case reconstruction', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
|
|
await _activeUseCase(repository, clock).startScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 4));
|
|
|
|
final reconstructed = _activeUseCase(repository, clock);
|
|
final stopped = await reconstructed.stopScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(stopped.accumulatedMs, 4000);
|
|
});
|
|
|
|
test(
|
|
'exercise step progress auto-advances consecutive elapsed timers after kill',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
setsCount: 1,
|
|
targetReps: 2,
|
|
exerciseSteps: [
|
|
_step(
|
|
id: 'step-1',
|
|
position: 0,
|
|
name: 'Gauche',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 1,
|
|
),
|
|
_step(
|
|
id: 'step-2',
|
|
position: 1,
|
|
name: 'Droite',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 1,
|
|
),
|
|
],
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await useCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await useCase.startTimer(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
clock.value = clock.value.add(const Duration(milliseconds: 4500));
|
|
final reconstructed = ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final view = await reconstructed.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(
|
|
view.state.status,
|
|
ActiveExerciseStepProgressStatus.sequenceComplete,
|
|
);
|
|
expect(repository.stepResults, hasLength(4));
|
|
expect(repository.stepResults.map((result) => result.passageIndex), [
|
|
0,
|
|
0,
|
|
1,
|
|
1,
|
|
]);
|
|
expect(repository.stepResults.map((result) => result.stepIndex), [
|
|
0,
|
|
1,
|
|
0,
|
|
1,
|
|
]);
|
|
expect(repository.stepResults.map((result) => result.actualTimeMs), [
|
|
1000,
|
|
1000,
|
|
1000,
|
|
1000,
|
|
]);
|
|
},
|
|
);
|
|
|
|
test('AuthUseCases register stores token and account session', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final tokenStore = _FakeAuthTokenStore();
|
|
final accountRepository = _FakeOnlineAccountRepository();
|
|
final remoteAuthApi = _FakeRemoteAuthApi();
|
|
final useCase = _authUseCase(
|
|
tokenStore: tokenStore,
|
|
accountRepository: accountRepository,
|
|
remoteAuthApi: remoteAuthApi,
|
|
clock: clock,
|
|
);
|
|
|
|
final session = await useCase.register(
|
|
email: 'USER@example.com',
|
|
password: 'password123',
|
|
displayName: 'User',
|
|
);
|
|
|
|
expect(session.serverUserId, 'server-user-1');
|
|
expect(session.email, 'user@example.com');
|
|
expect(session.displayName, 'User');
|
|
expect(session.isLoggedIn, isTrue);
|
|
expect(tokenStore.token, 'token-1');
|
|
expect(accountRepository.session, session);
|
|
});
|
|
|
|
test('AuthUseCases register propagates email already used error', () async {
|
|
final remoteAuthApi = _FakeRemoteAuthApi()
|
|
..registerException = const RemoteAuthException(
|
|
RemoteAuthFailure.emailAlreadyUsed,
|
|
);
|
|
|
|
await expectLater(
|
|
_authUseCase(
|
|
remoteAuthApi: remoteAuthApi,
|
|
).register(email: 'user@example.com', password: 'password123'),
|
|
throwsA(
|
|
isA<RemoteAuthException>().having(
|
|
(error) => error.failure,
|
|
'failure',
|
|
RemoteAuthFailure.emailAlreadyUsed,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('AuthUseCases login stores token and account session', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final tokenStore = _FakeAuthTokenStore();
|
|
final accountRepository = _FakeOnlineAccountRepository();
|
|
final remoteAuthApi = _FakeRemoteAuthApi();
|
|
final useCase = _authUseCase(
|
|
tokenStore: tokenStore,
|
|
accountRepository: accountRepository,
|
|
remoteAuthApi: remoteAuthApi,
|
|
clock: clock,
|
|
);
|
|
|
|
final session = await useCase.login(
|
|
email: 'user@example.com',
|
|
password: 'password123',
|
|
);
|
|
|
|
expect(session.serverUserId, 'server-user-1');
|
|
expect(session.isLoggedIn, isTrue);
|
|
expect(tokenStore.token, 'token-1');
|
|
expect(tokenStore.expiresAt, DateTime.utc(2026, 8, 16, 12));
|
|
expect(accountRepository.session?.lastAuthenticatedAt, clock.now());
|
|
});
|
|
|
|
test('AuthUseCases login propagates invalid credentials', () async {
|
|
final remoteAuthApi = _FakeRemoteAuthApi()
|
|
..loginException = const RemoteAuthException(
|
|
RemoteAuthFailure.invalidCredentials,
|
|
);
|
|
|
|
await expectLater(
|
|
_authUseCase(
|
|
remoteAuthApi: remoteAuthApi,
|
|
).login(email: 'user@example.com', password: 'wrong-password'),
|
|
throwsA(
|
|
isA<RemoteAuthException>().having(
|
|
(error) => error.failure,
|
|
'failure',
|
|
RemoteAuthFailure.invalidCredentials,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('AuthUseCases logout clears local token even if remote fails', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final tokenStore = _FakeAuthTokenStore()
|
|
..token = 'token-1'
|
|
..expiresAt = DateTime.utc(2026, 8, 16, 12);
|
|
final accountRepository = _FakeOnlineAccountRepository()
|
|
..session = UserAccountSession(
|
|
id: 'session-1',
|
|
serverUserId: 'server-user-1',
|
|
email: 'user@example.com',
|
|
isLoggedIn: true,
|
|
createdAt: DateTime.utc(2026, 7, 17, 11),
|
|
updatedAt: DateTime.utc(2026, 7, 17, 11),
|
|
);
|
|
final remoteAuthApi = _FakeRemoteAuthApi()
|
|
..logoutException = const RemoteAuthException(RemoteAuthFailure.network);
|
|
|
|
await _authUseCase(
|
|
tokenStore: tokenStore,
|
|
accountRepository: accountRepository,
|
|
remoteAuthApi: remoteAuthApi,
|
|
clock: clock,
|
|
).logout();
|
|
|
|
expect(tokenStore.token, isNull);
|
|
expect(accountRepository.session?.isLoggedIn, isFalse);
|
|
expect(remoteAuthApi.logoutCalls, 1);
|
|
});
|
|
|
|
test('AuthUseCases currentSession reads local cache only', () async {
|
|
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
|
final accountRepository = _FakeOnlineAccountRepository()
|
|
..session = UserAccountSession(
|
|
id: 'session-1',
|
|
serverUserId: 'server-user-1',
|
|
email: 'user@example.com',
|
|
isLoggedIn: true,
|
|
createdAt: DateTime.utc(2026, 7, 17, 11),
|
|
updatedAt: DateTime.utc(2026, 7, 17, 11),
|
|
);
|
|
final remoteAuthApi = _FakeRemoteAuthApi();
|
|
|
|
final session = await _authUseCase(
|
|
tokenStore: tokenStore,
|
|
accountRepository: accountRepository,
|
|
remoteAuthApi: remoteAuthApi,
|
|
).currentSession();
|
|
|
|
expect(session?.email, 'user@example.com');
|
|
expect(remoteAuthApi.registerCalls, 0);
|
|
expect(remoteAuthApi.loginCalls, 0);
|
|
expect(remoteAuthApi.logoutCalls, 0);
|
|
});
|
|
|
|
test('SyncUseCases skips silently without token', () async {
|
|
final remoteSyncApi = _FakeRemoteSyncApi();
|
|
final metadataRepository = _FakeSyncMetadataRepository();
|
|
final localChanges = _FakeLocalSyncChangeRepository();
|
|
|
|
final summary = await _syncUseCase(
|
|
tokenStore: _FakeAuthTokenStore(),
|
|
remoteSyncApi: remoteSyncApi,
|
|
metadataRepository: metadataRepository,
|
|
localChanges: localChanges,
|
|
).synchronize(manual: true);
|
|
|
|
expect(summary.skipped, isTrue);
|
|
expect(remoteSyncApi.pushCalls, 0);
|
|
expect(remoteSyncApi.pullCalls, 0);
|
|
expect(metadataRepository.saved, isEmpty);
|
|
});
|
|
|
|
test('SyncUseCases successful push marks change log rows synced', () async {
|
|
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
|
final localChanges = _FakeLocalSyncChangeRepository()
|
|
..pendingChanges.add(_pendingExerciseChange());
|
|
final remoteSyncApi = _FakeRemoteSyncApi()
|
|
..pushResult = RemoteSyncPushResult(
|
|
serverCursor: 'cursor-1',
|
|
results: [
|
|
RemoteSyncPushItemResult(
|
|
resourceType: SyncResourceType.exercise,
|
|
clientId: 'exercise-1',
|
|
serverId: 'server-exercise-1',
|
|
status: RemoteSyncPushStatus.accepted,
|
|
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
|
|
),
|
|
],
|
|
);
|
|
final mappingRepository = _FakeRemoteResourceMappingRepository();
|
|
|
|
final summary = await _syncUseCase(
|
|
tokenStore: tokenStore,
|
|
remoteSyncApi: remoteSyncApi,
|
|
mappingRepository: mappingRepository,
|
|
localChanges: localChanges,
|
|
).synchronize(manual: true);
|
|
|
|
expect(summary.pushedChanges, 1);
|
|
expect(localChanges.syncedChangeLogIds, ['change-1']);
|
|
expect(mappingRepository.saved.single.serverId, 'server-exercise-1');
|
|
});
|
|
|
|
test('SyncUseCases pull applies a newer remote item', () async {
|
|
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
|
final localChanges = _FakeLocalSyncChangeRepository()
|
|
..localUpdatedAt = DateTime.utc(2026, 7, 17, 11);
|
|
final remoteSyncApi = _FakeRemoteSyncApi()
|
|
..pullResult = RemoteSyncPullResult(
|
|
serverCursor: 'cursor-2',
|
|
items: [
|
|
_remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 12)),
|
|
],
|
|
);
|
|
|
|
final summary = await _syncUseCase(
|
|
tokenStore: tokenStore,
|
|
remoteSyncApi: remoteSyncApi,
|
|
localChanges: localChanges,
|
|
).synchronize(manual: true);
|
|
|
|
expect(summary.pulledChanges, 1);
|
|
expect(localChanges.appliedItems.single.clientId, 'exercise-1');
|
|
});
|
|
|
|
test('SyncUseCases pull ignores an older remote item', () async {
|
|
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
|
final localChanges = _FakeLocalSyncChangeRepository()
|
|
..localUpdatedAt = DateTime.utc(2026, 7, 17, 12);
|
|
final remoteSyncApi = _FakeRemoteSyncApi()
|
|
..pullResult = RemoteSyncPullResult(
|
|
serverCursor: 'cursor-2',
|
|
items: [
|
|
_remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 11)),
|
|
],
|
|
);
|
|
|
|
final summary = await _syncUseCase(
|
|
tokenStore: tokenStore,
|
|
remoteSyncApi: remoteSyncApi,
|
|
localChanges: localChanges,
|
|
).synchronize(manual: true);
|
|
|
|
expect(summary.pulledChanges, 0);
|
|
expect(localChanges.appliedItems, isEmpty);
|
|
});
|
|
|
|
test('SyncUseCases stores failure status without throwing', () async {
|
|
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
|
final metadataRepository = _FakeSyncMetadataRepository();
|
|
final remoteSyncApi = _FakeRemoteSyncApi()
|
|
..exception = const RemoteAuthException(RemoteAuthFailure.network);
|
|
|
|
final summary = await _syncUseCase(
|
|
tokenStore: tokenStore,
|
|
remoteSyncApi: remoteSyncApi,
|
|
metadataRepository: metadataRepository,
|
|
).synchronize(manual: true);
|
|
|
|
expect(summary.failed, isTrue);
|
|
expect(metadataRepository.metadata.status, OnlineSyncStatus.failure);
|
|
expect(metadataRepository.metadata.lastFailureAt, isNotNull);
|
|
});
|
|
|
|
test(
|
|
'ShareUseCases sendShare returns neutral result without token',
|
|
() async {
|
|
final remoteShareApi = _FakeRemoteShareApi();
|
|
final pendingRepository = _FakePendingShareActionRepository();
|
|
final programRepository = _FakeProgramRepository()
|
|
..programs.add(_shareableProgram());
|
|
|
|
final result =
|
|
await _shareUseCase(
|
|
tokenStore: _FakeAuthTokenStore(),
|
|
remoteShareApi: remoteShareApi,
|
|
pendingActionRepository: pendingRepository,
|
|
programRepository: programRepository,
|
|
).sendShare(
|
|
resourceType: ShareResourceType.program,
|
|
localResourceId: 'program-1',
|
|
recipientEmails: const ['friend@example.com'],
|
|
);
|
|
|
|
expect(result.status, ShareSendStatus.notConnected);
|
|
expect(remoteShareApi.sendCalls, 0);
|
|
expect(pendingRepository.actions, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('ShareUseCases sendShare queues action when network fails', () async {
|
|
final remoteShareApi = _FakeRemoteShareApi()
|
|
..exception = const RemoteAuthException(RemoteAuthFailure.network);
|
|
final pendingRepository = _FakePendingShareActionRepository();
|
|
final programRepository = _FakeProgramRepository()
|
|
..programs.add(_shareableProgram());
|
|
|
|
final result =
|
|
await _shareUseCase(
|
|
remoteShareApi: remoteShareApi,
|
|
pendingActionRepository: pendingRepository,
|
|
programRepository: programRepository,
|
|
).sendShare(
|
|
resourceType: ShareResourceType.program,
|
|
localResourceId: 'program-1',
|
|
recipientEmails: const ['friend@example.com'],
|
|
);
|
|
|
|
expect(result.status, ShareSendStatus.queued);
|
|
expect(
|
|
pendingRepository.actions.single.actionType,
|
|
PendingShareActionType.send,
|
|
);
|
|
expect(
|
|
pendingRepository.actions.single.resourceType,
|
|
ShareResourceType.program,
|
|
);
|
|
expect(pendingRepository.actions.single.payloadJson, contains('Programme'));
|
|
});
|
|
|
|
test('ShareUseCases acceptShare imports returned resource locally', () async {
|
|
final remoteShareApi = _FakeRemoteShareApi()
|
|
..acceptResult = _remoteSharedProgramItem();
|
|
final localChanges = _FakeLocalSyncChangeRepository();
|
|
final inboxRepository = _FakeShareInboxRepository()
|
|
..items.add(_shareInboxItem(status: ShareInboxStatus.pending));
|
|
|
|
await _shareUseCase(
|
|
remoteShareApi: remoteShareApi,
|
|
localChanges: localChanges,
|
|
inboxRepository: inboxRepository,
|
|
).acceptShare('share-1');
|
|
|
|
expect(localChanges.appliedItems.single.clientId, 'shared-program-1');
|
|
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
|
});
|
|
|
|
test(
|
|
'ShareUseCases acceptShare sans token importe une copie locale indépendante',
|
|
() async {
|
|
final remoteShareApi = _FakeRemoteShareApi();
|
|
final pendingRepository = _FakePendingShareActionRepository();
|
|
final programRepository = _FakeProgramRepository();
|
|
final inboxRepository = _FakeShareInboxRepository()
|
|
..items.add(
|
|
_shareInboxItem(
|
|
status: ShareInboxStatus.pending,
|
|
payloadJson: jsonEncode(_sharedProgramPayload()),
|
|
),
|
|
);
|
|
|
|
await _shareUseCase(
|
|
tokenStore: _FakeAuthTokenStore(),
|
|
remoteShareApi: remoteShareApi,
|
|
inboxRepository: inboxRepository,
|
|
pendingActionRepository: pendingRepository,
|
|
programRepository: programRepository,
|
|
).acceptShare('share-1');
|
|
|
|
expect(remoteShareApi.acceptCalls, 0);
|
|
expect(
|
|
pendingRepository.actions.single.actionType,
|
|
PendingShareActionType.accept,
|
|
);
|
|
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
|
final imported = programRepository.programs.single;
|
|
expect(imported.metadata.id, isNot('program-from-sender'));
|
|
expect(imported.metadata.originDeviceId, 'device-1');
|
|
expect(imported.name, 'Programme partagé');
|
|
expect(
|
|
imported.exercises.single.metadata.id,
|
|
isNot('exercise-from-sender'),
|
|
);
|
|
expect(imported.exercises.single.sourceExerciseId, isNull);
|
|
expect(
|
|
imported.exercises.single.exerciseStepsSnapshot.single.name,
|
|
'Sprint',
|
|
);
|
|
},
|
|
);
|
|
|
|
test('ShareUseCases refreshInbox updates the local cache', () async {
|
|
final remoteShareApi = _FakeRemoteShareApi()
|
|
..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)];
|
|
final inboxRepository = _FakeShareInboxRepository();
|
|
|
|
final items = await _shareUseCase(
|
|
remoteShareApi: remoteShareApi,
|
|
inboxRepository: inboxRepository,
|
|
).refreshInbox();
|
|
|
|
expect(items.single.shareId, 'share-1');
|
|
expect(inboxRepository.items.single.senderUserId, 'sender-1');
|
|
});
|
|
|
|
test('ShareUseCases decline and revoke update local statuses', () async {
|
|
final inboxRepository = _FakeShareInboxRepository()
|
|
..items.add(_shareInboxItem(status: ShareInboxStatus.pending))
|
|
..items.add(
|
|
_shareInboxItem(shareId: 'share-2', status: ShareInboxStatus.pending),
|
|
);
|
|
final useCase = _shareUseCase(inboxRepository: inboxRepository);
|
|
|
|
await useCase.declineShare('share-1');
|
|
await useCase.revokeShare('share-2');
|
|
|
|
expect(
|
|
inboxRepository.items
|
|
.singleWhere((item) => item.shareId == 'share-1')
|
|
.status,
|
|
ShareInboxStatus.declined,
|
|
);
|
|
expect(
|
|
inboxRepository.items
|
|
.singleWhere((item) => item.shareId == 'share-2')
|
|
.status,
|
|
ShareInboxStatus.revoked,
|
|
);
|
|
});
|
|
|
|
test('score result enforces manual xor stopwatch values', () {
|
|
expect(
|
|
() => ActiveSetResult(
|
|
metadata: _metadata('result-1'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
programSnapshotId: 'program-1',
|
|
exerciseSnapshotId: 'exercise-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualScore: 12,
|
|
actualScoreTimeMs: 12000,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => ActiveSetResult(
|
|
metadata: _metadata('result-2'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
programSnapshotId: 'program-1',
|
|
exerciseSnapshotId: 'exercise-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualScore: 12,
|
|
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => ActiveSetResult(
|
|
metadata: _metadata('result-3'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
programSnapshotId: 'program-1',
|
|
exerciseSnapshotId: 'exercise-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualScoreTimeMs: 12000,
|
|
scoreInputModeSnapshot: ScoreInputMode.manual,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
}
|
|
|
|
ExerciseStep _step({
|
|
String id = 'step-1',
|
|
int position = 0,
|
|
String name = 'Phase',
|
|
ExerciseStepType type = ExerciseStepType.time,
|
|
int defaultTargetValue = 30,
|
|
bool hasScore = false,
|
|
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
|
String? scoreLabel,
|
|
String? scoreUnit,
|
|
double? defaultTargetScore,
|
|
int? defaultTargetScoreTimeMs,
|
|
}) {
|
|
return ExerciseStep(
|
|
id: id,
|
|
position: position,
|
|
name: name,
|
|
type: type,
|
|
defaultTargetValue: defaultTargetValue,
|
|
hasScore: hasScore,
|
|
scoreInputMode: scoreInputMode,
|
|
scoreLabel: scoreLabel,
|
|
scoreUnit: scoreUnit,
|
|
defaultTargetScore: defaultTargetScore,
|
|
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
|
);
|
|
}
|
|
|
|
EntityMetadata _metadata(String id) {
|
|
return EntityMetadata(
|
|
id: id,
|
|
createdAt: DateTime.utc(2026, 7, 17),
|
|
updatedAt: DateTime.utc(2026, 7, 17),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
}
|
|
|
|
final class _FakeClock implements Clock {
|
|
_FakeClock(this.value);
|
|
|
|
DateTime value;
|
|
|
|
@override
|
|
DateTime now() => value;
|
|
}
|
|
|
|
final class _FakeIds implements IdGenerator {
|
|
var _next = 0;
|
|
|
|
@override
|
|
String newId() {
|
|
_next += 1;
|
|
return 'id-$_next';
|
|
}
|
|
}
|
|
|
|
AuthUseCases _authUseCase({
|
|
_FakeAuthTokenStore? tokenStore,
|
|
_FakeOnlineAccountRepository? accountRepository,
|
|
_FakeRemoteAuthApi? remoteAuthApi,
|
|
_FakeClock? clock,
|
|
}) {
|
|
return AuthUseCases(
|
|
tokenStore: tokenStore ?? _FakeAuthTokenStore(),
|
|
accountRepository: accountRepository ?? _FakeOnlineAccountRepository(),
|
|
remoteAuthApi: remoteAuthApi ?? _FakeRemoteAuthApi(),
|
|
clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
);
|
|
}
|
|
|
|
final class _FakeAuthTokenStore implements AuthTokenStore {
|
|
String? token;
|
|
DateTime? expiresAt;
|
|
|
|
@override
|
|
Future<void> clearToken() async {
|
|
token = null;
|
|
expiresAt = null;
|
|
}
|
|
|
|
@override
|
|
Future<String?> readToken() async => token;
|
|
|
|
@override
|
|
Future<void> saveToken(String token, DateTime expiresAt) async {
|
|
this.token = token;
|
|
this.expiresAt = expiresAt;
|
|
}
|
|
}
|
|
|
|
final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
|
|
UserAccountSession? session;
|
|
var clearCalls = 0;
|
|
|
|
@override
|
|
Future<void> clearSession() async {
|
|
clearCalls += 1;
|
|
session = null;
|
|
}
|
|
|
|
@override
|
|
Future<UserAccountSession?> currentSession() async => session;
|
|
|
|
@override
|
|
Future<void> saveSession(UserAccountSession session) async {
|
|
this.session = session;
|
|
}
|
|
}
|
|
|
|
final class _FakeRemoteAuthApi implements RemoteAuthApi {
|
|
RemoteAuthException? registerException;
|
|
RemoteAuthException? loginException;
|
|
RemoteAuthException? logoutException;
|
|
var registerCalls = 0;
|
|
var loginCalls = 0;
|
|
var logoutCalls = 0;
|
|
|
|
@override
|
|
Future<RemoteAuthResult> register({
|
|
required String email,
|
|
required String password,
|
|
String? displayName,
|
|
}) async {
|
|
registerCalls += 1;
|
|
final exception = registerException;
|
|
if (exception != null) {
|
|
throw exception;
|
|
}
|
|
return _authResult(email);
|
|
}
|
|
|
|
@override
|
|
Future<RemoteAuthResult> login({
|
|
required String email,
|
|
required String password,
|
|
}) async {
|
|
loginCalls += 1;
|
|
final exception = loginException;
|
|
if (exception != null) {
|
|
throw exception;
|
|
}
|
|
return _authResult(email);
|
|
}
|
|
|
|
@override
|
|
Future<void> logout(String token) async {
|
|
logoutCalls += 1;
|
|
final exception = logoutException;
|
|
if (exception != null) {
|
|
throw exception;
|
|
}
|
|
}
|
|
|
|
RemoteAuthResult _authResult(String email) {
|
|
return RemoteAuthResult(
|
|
userId: 'server-user-1',
|
|
email: email.trim().toLowerCase(),
|
|
token: 'token-1',
|
|
expiresAt: DateTime.utc(2026, 8, 16, 12),
|
|
);
|
|
}
|
|
}
|
|
|
|
SyncUseCases _syncUseCase({
|
|
_FakeAuthTokenStore? tokenStore,
|
|
_FakeRemoteSyncApi? remoteSyncApi,
|
|
_FakeSyncMetadataRepository? metadataRepository,
|
|
_FakeRemoteResourceMappingRepository? mappingRepository,
|
|
_FakeLocalSyncChangeRepository? localChanges,
|
|
_FakeClock? clock,
|
|
}) {
|
|
return SyncUseCases(
|
|
tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'),
|
|
remoteSyncApi: remoteSyncApi ?? _FakeRemoteSyncApi(),
|
|
metadataRepository: metadataRepository ?? _FakeSyncMetadataRepository(),
|
|
mappingRepository:
|
|
mappingRepository ?? _FakeRemoteResourceMappingRepository(),
|
|
localChanges: localChanges ?? _FakeLocalSyncChangeRepository(),
|
|
clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
deviceId: 'device-1',
|
|
);
|
|
}
|
|
|
|
final class _FakeRemoteSyncApi implements RemoteSyncApi {
|
|
RemoteSyncPushResult pushResult = const RemoteSyncPushResult(
|
|
serverCursor: null,
|
|
results: [],
|
|
);
|
|
RemoteSyncPullResult pullResult = const RemoteSyncPullResult(
|
|
serverCursor: null,
|
|
items: [],
|
|
);
|
|
Exception? exception;
|
|
var pushCalls = 0;
|
|
var pullCalls = 0;
|
|
|
|
@override
|
|
Future<RemoteSyncPushResult> push({
|
|
required String deviceId,
|
|
required List<RemoteSyncPushItem> items,
|
|
required String token,
|
|
}) async {
|
|
pushCalls += 1;
|
|
final error = exception;
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
return pushResult;
|
|
}
|
|
|
|
@override
|
|
Future<RemoteSyncPullResult> pull({
|
|
required String? since,
|
|
required String token,
|
|
}) async {
|
|
pullCalls += 1;
|
|
final error = exception;
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
return pullResult;
|
|
}
|
|
}
|
|
|
|
final class _FakeSyncMetadataRepository implements SyncMetadataRepository {
|
|
SyncMetadataSnapshot metadata = const SyncMetadataSnapshot();
|
|
final saved = <SyncMetadataSnapshot>[];
|
|
|
|
@override
|
|
Future<SyncMetadataSnapshot> read() async => metadata;
|
|
|
|
@override
|
|
Future<void> save(SyncMetadataSnapshot metadata) async {
|
|
this.metadata = metadata;
|
|
saved.add(metadata);
|
|
}
|
|
}
|
|
|
|
final class _FakeRemoteResourceMappingRepository
|
|
implements RemoteResourceMappingRepository {
|
|
final saved = <RemoteResourceMapping>[];
|
|
|
|
@override
|
|
Future<RemoteResourceMapping?> find({
|
|
required SyncResourceType resourceType,
|
|
required String clientId,
|
|
}) async {
|
|
return saved
|
|
.where(
|
|
(mapping) =>
|
|
mapping.resourceType == resourceType &&
|
|
mapping.clientId == clientId,
|
|
)
|
|
.firstOrNull;
|
|
}
|
|
|
|
@override
|
|
Future<void> save(RemoteResourceMapping mapping) async {
|
|
saved.removeWhere(
|
|
(existing) =>
|
|
existing.resourceType == mapping.resourceType &&
|
|
existing.clientId == mapping.clientId,
|
|
);
|
|
saved.add(mapping);
|
|
}
|
|
}
|
|
|
|
final class _FakeLocalSyncChangeRepository
|
|
implements LocalSyncChangeRepository {
|
|
final pendingChanges = <PendingSyncChange>[];
|
|
final syncedChangeLogIds = <String>[];
|
|
final appliedItems = <RemoteSyncedItem>[];
|
|
DateTime? localUpdatedAt;
|
|
|
|
@override
|
|
Future<List<PendingSyncChange>> listPendingChanges() async {
|
|
return pendingChanges;
|
|
}
|
|
|
|
@override
|
|
Future<void> markChangesSynced(
|
|
List<String> changeLogIds,
|
|
DateTime syncedAt,
|
|
) async {
|
|
syncedChangeLogIds.addAll(changeLogIds);
|
|
}
|
|
|
|
@override
|
|
Future<bool> applyRemoteItem(RemoteSyncedItem item) async {
|
|
final updatedAt = localUpdatedAt;
|
|
if (updatedAt != null && !item.clientUpdatedAt.isAfter(updatedAt)) {
|
|
return false;
|
|
}
|
|
appliedItems.add(item);
|
|
localUpdatedAt = item.clientUpdatedAt;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
PendingSyncChange _pendingExerciseChange() {
|
|
return PendingSyncChange(
|
|
changeLogIds: const ['change-1'],
|
|
item: RemoteSyncPushItem(
|
|
resourceType: SyncResourceType.exercise,
|
|
clientId: 'exercise-1',
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: DateTime.utc(2026, 7, 17, 12),
|
|
deletedAt: null,
|
|
payload: const {'id': 'exercise-1', 'name': 'Squat'},
|
|
),
|
|
);
|
|
}
|
|
|
|
RemoteSyncedItem _remoteExerciseItem({required DateTime clientUpdatedAt}) {
|
|
return RemoteSyncedItem(
|
|
resourceType: SyncResourceType.exercise,
|
|
clientId: 'exercise-1',
|
|
serverId: 'server-exercise-1',
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: clientUpdatedAt,
|
|
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
|
|
deletedAt: null,
|
|
payload: const {'id': 'exercise-1', 'name': 'Squat'},
|
|
);
|
|
}
|
|
|
|
ShareUseCases _shareUseCase({
|
|
_FakeAuthTokenStore? tokenStore,
|
|
_FakeRemoteShareApi? remoteShareApi,
|
|
_FakeShareInboxRepository? inboxRepository,
|
|
_FakePendingShareActionRepository? pendingActionRepository,
|
|
_FakeLocalSyncChangeRepository? localChanges,
|
|
_FakeProgramRepository? programRepository,
|
|
_FakeWorkoutTemplateRepository? templateRepository,
|
|
}) {
|
|
return ShareUseCases(
|
|
tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'),
|
|
remoteShareApi: remoteShareApi ?? _FakeRemoteShareApi(),
|
|
inboxRepository: inboxRepository ?? _FakeShareInboxRepository(),
|
|
pendingActionRepository:
|
|
pendingActionRepository ?? _FakePendingShareActionRepository(),
|
|
localChanges: localChanges ?? _FakeLocalSyncChangeRepository(),
|
|
programRepository: programRepository ?? _FakeProgramRepository(),
|
|
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
}
|
|
|
|
final class _FakeRemoteShareApi implements RemoteShareApi {
|
|
Exception? exception;
|
|
RemoteSyncedItem? acceptResult;
|
|
List<ShareInboxItem> inboxItems = const [];
|
|
var sendCalls = 0;
|
|
var acceptCalls = 0;
|
|
var declineCalls = 0;
|
|
var revokeCalls = 0;
|
|
|
|
@override
|
|
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
|
acceptCalls += 1;
|
|
final error = exception;
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
return acceptResult ?? _remoteSharedProgramItem();
|
|
}
|
|
|
|
@override
|
|
Future<void> declineShare(String shareId, String token) async {
|
|
declineCalls += 1;
|
|
final error = exception;
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<List<ShareInboxItem>> fetchInbox(String token) async {
|
|
final error = exception;
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
return inboxItems;
|
|
}
|
|
|
|
@override
|
|
Future<void> revokeShare(String shareId, String token) async {
|
|
revokeCalls += 1;
|
|
final error = exception;
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<RemoteShareSendResult> sendShare({
|
|
required ShareResourceType resourceType,
|
|
required Map<String, Object?> payload,
|
|
required List<String> recipientEmails,
|
|
required String token,
|
|
}) async {
|
|
sendCalls += 1;
|
|
final error = exception;
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
return const RemoteShareSendResult(
|
|
shareId: 'share-1',
|
|
recipientUserIds: ['server-user-2'],
|
|
unresolvedEmails: [],
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _FakeShareInboxRepository implements ShareInboxRepository {
|
|
final items = <ShareInboxItem>[];
|
|
|
|
@override
|
|
Future<ShareInboxItem?> findByShareId(String shareId) async {
|
|
return items.where((item) => item.shareId == shareId).firstOrNull;
|
|
}
|
|
|
|
@override
|
|
Future<List<ShareInboxItem>> listAll() async => List.of(items);
|
|
|
|
@override
|
|
Future<void> markStatus(
|
|
String shareId,
|
|
ShareInboxStatus status,
|
|
DateTime respondedAt,
|
|
) async {
|
|
final index = items.indexWhere((item) => item.shareId == shareId);
|
|
if (index == -1) {
|
|
return;
|
|
}
|
|
items[index] = items[index].copyWith(
|
|
status: status,
|
|
respondedAt: respondedAt,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> upsert(ShareInboxItem item) async {
|
|
items.removeWhere((existing) => existing.shareId == item.shareId);
|
|
items.add(item);
|
|
}
|
|
|
|
@override
|
|
Future<void> upsertAll(List<ShareInboxItem> items) async {
|
|
for (final item in items) {
|
|
await upsert(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
final class _FakePendingShareActionRepository
|
|
implements PendingShareActionRepository {
|
|
final actions = <PendingShareAction>[];
|
|
|
|
@override
|
|
Future<void> add(PendingShareAction action) async {
|
|
actions.add(action);
|
|
}
|
|
|
|
@override
|
|
Future<List<PendingShareAction>> listPending() async {
|
|
return actions
|
|
.where((action) => action.status != PendingShareActionStatus.succeeded)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> markFailed(String id, DateTime attemptedAt) async {
|
|
_mark(id, attemptedAt, PendingShareActionStatus.failed);
|
|
}
|
|
|
|
@override
|
|
Future<void> markSucceeded(String id, DateTime attemptedAt) async {
|
|
_mark(id, attemptedAt, PendingShareActionStatus.succeeded);
|
|
}
|
|
|
|
void _mark(String id, DateTime attemptedAt, PendingShareActionStatus status) {
|
|
final index = actions.indexWhere((action) => action.id == id);
|
|
if (index == -1) {
|
|
return;
|
|
}
|
|
final action = actions[index];
|
|
actions[index] = action.copyWith(
|
|
lastAttemptAt: attemptedAt,
|
|
attemptCount: action.attemptCount + 1,
|
|
status: status,
|
|
);
|
|
}
|
|
}
|
|
|
|
Program _shareableProgram() {
|
|
return Program(
|
|
metadata: _metadata('program-1'),
|
|
name: 'Programme',
|
|
defaultRestSeconds: 60,
|
|
);
|
|
}
|
|
|
|
ShareInboxItem _shareInboxItem({
|
|
String shareId = 'share-1',
|
|
required ShareInboxStatus status,
|
|
String payloadJson = '{"name":"Programme partagé"}',
|
|
}) {
|
|
return ShareInboxItem(
|
|
shareId: shareId,
|
|
senderUserId: 'sender-1',
|
|
resourceType: ShareResourceType.program,
|
|
payloadJson: payloadJson,
|
|
status: status,
|
|
createdAt: DateTime.utc(2026, 7, 17, 12),
|
|
);
|
|
}
|
|
|
|
Map<String, Object?> _sharedProgramPayload() {
|
|
return {
|
|
'id': 'program-from-sender',
|
|
'name': 'Programme partagé',
|
|
'defaultRestSeconds': 45,
|
|
'exercises': [
|
|
{
|
|
'id': 'exercise-from-sender',
|
|
'sourceExerciseId': 'source-exercise-from-sender',
|
|
'position': 0,
|
|
'exerciseNameSnapshot': 'Sprint',
|
|
'exerciseStepsSnapshot': [
|
|
{
|
|
'id': 'step-from-sender',
|
|
'position': 0,
|
|
'name': 'Sprint',
|
|
'type': 'time',
|
|
'defaultTargetValue': 20,
|
|
'hasScore': false,
|
|
'scoreInputMode': 'manual',
|
|
'scoreLabel': null,
|
|
'scoreUnit': null,
|
|
'defaultTargetScore': null,
|
|
'defaultTargetScoreTimeMs': null,
|
|
},
|
|
],
|
|
'availableTimeSnapshot': true,
|
|
'availableRepsSnapshot': false,
|
|
'availableScoreSnapshot': false,
|
|
'scoreInputModeSnapshot': 'manual',
|
|
'setsCount': 2,
|
|
'timeEnabled': true,
|
|
'repsEnabled': false,
|
|
'scoreEnabled': false,
|
|
'targetTimeSeconds': 20,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
RemoteSyncedItem _remoteSharedProgramItem() {
|
|
return RemoteSyncedItem(
|
|
resourceType: SyncResourceType.program,
|
|
clientId: 'shared-program-1',
|
|
serverId: 'server-program-1',
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: DateTime.utc(2026, 7, 17, 12),
|
|
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
|
|
deletedAt: null,
|
|
payload: const {'id': 'shared-program-1', 'name': 'Programme partagé'},
|
|
);
|
|
}
|
|
|
|
ExerciseUseCases _exerciseUseCase(
|
|
_FakeExerciseRepository repository, {
|
|
_FakeProgramRepository? programRepository,
|
|
}) {
|
|
return ExerciseUseCases(
|
|
repository: repository,
|
|
programRepository: programRepository ?? _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
}
|
|
|
|
ProgramExercise _programExercise({
|
|
required String id,
|
|
required String programId,
|
|
required String sourceExerciseId,
|
|
required int position,
|
|
}) {
|
|
return ProgramExercise(
|
|
metadata: _metadata(id),
|
|
programId: programId,
|
|
sourceExerciseId: sourceExerciseId,
|
|
position: position,
|
|
exerciseNameSnapshot: 'Exercise $id',
|
|
availableTimeSnapshot: false,
|
|
availableRepsSnapshot: true,
|
|
availableScoreSnapshot: false,
|
|
setsCount: 3,
|
|
timeEnabled: false,
|
|
repsEnabled: true,
|
|
scoreEnabled: false,
|
|
targetReps: 10,
|
|
);
|
|
}
|
|
|
|
WorkoutTemplateProgram _templateProgram({
|
|
required String id,
|
|
required String workoutTemplateId,
|
|
required String sourceProgramId,
|
|
required int position,
|
|
}) {
|
|
return WorkoutTemplateProgram(
|
|
metadata: _metadata(id),
|
|
workoutTemplateId: workoutTemplateId,
|
|
sourceProgramId: sourceProgramId,
|
|
position: position,
|
|
programNameSnapshot: 'Program $id',
|
|
defaultRestSecondsSnapshot: 60,
|
|
programSnapshotJson: '{"exercises":[]}',
|
|
);
|
|
}
|
|
|
|
final class _FakeExerciseRepository implements ExerciseRepository {
|
|
Exercise? exercise;
|
|
final saved = <Exercise>[];
|
|
|
|
@override
|
|
Future<Exercise?> findById(String id) async {
|
|
return exercise?.metadata.id == id ? exercise : null;
|
|
}
|
|
|
|
@override
|
|
Future<bool> isReferencedByProgram(String id) async => false;
|
|
|
|
@override
|
|
Future<List<Exercise>> listActive() async =>
|
|
exercise == null || exercise!.metadata.deletedAt != null
|
|
? const []
|
|
: [exercise!];
|
|
|
|
@override
|
|
Future<void> save(Exercise exercise) async {
|
|
this.exercise = exercise;
|
|
saved.add(exercise);
|
|
}
|
|
}
|
|
|
|
final class _FakeWorkoutTemplateRepository
|
|
implements WorkoutTemplateRepository {
|
|
final templates = <WorkoutTemplate>[];
|
|
|
|
@override
|
|
Future<WorkoutTemplate?> findById(String id) async {
|
|
for (final template in templates) {
|
|
if (template.metadata.id == id && template.metadata.deletedAt == null) {
|
|
return template;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<List<WorkoutTemplate>> listActive() async {
|
|
return templates
|
|
.where((template) => template.metadata.deletedAt == null)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> replaceComposition(
|
|
WorkoutTemplate template,
|
|
DateTime deletedAt,
|
|
) async {
|
|
await save(template);
|
|
}
|
|
|
|
@override
|
|
Future<void> save(WorkoutTemplate template) async {
|
|
templates.removeWhere((saved) => saved.metadata.id == template.metadata.id);
|
|
templates.add(template);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
|
|
|
@override
|
|
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
|
|
}
|
|
|
|
final class _FakeProgramRepository implements ProgramRepository {
|
|
final programs = <Program>[];
|
|
|
|
@override
|
|
Future<Program?> findById(String id) async {
|
|
for (final program in programs) {
|
|
if (program.metadata.id == id && program.metadata.deletedAt == null) {
|
|
return program;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<List<Program>> listActive() async {
|
|
return programs
|
|
.where((program) => program.metadata.deletedAt == null)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> replaceExercises(Program program, DateTime deletedAt) async {
|
|
await save(program);
|
|
}
|
|
|
|
@override
|
|
Future<void> save(Program program) async {
|
|
programs.removeWhere((saved) => saved.metadata.id == program.metadata.id);
|
|
programs.add(program);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveExercise(ProgramExercise exercise) async {
|
|
final index = programs.indexWhere(
|
|
(program) => program.metadata.id == exercise.programId,
|
|
);
|
|
if (index == -1) {
|
|
return;
|
|
}
|
|
final program = programs[index];
|
|
programs[index] = program.copyWith(
|
|
exercises: [
|
|
...program.exercises.where(
|
|
(saved) => saved.metadata.id != exercise.metadata.id,
|
|
),
|
|
exercise,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
ActiveWorkoutSessionUseCases _activeUseCase(
|
|
_FakeActiveSessionRepository repository,
|
|
_FakeClock clock,
|
|
) {
|
|
return ActiveWorkoutSessionUseCases(
|
|
sessionRepository: repository,
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
}
|
|
|
|
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|
ActiveWorkoutSession? session;
|
|
final results = <ActiveSetResult>[];
|
|
final restStates = <String, ActiveRestState>{};
|
|
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
|
|
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
|
|
final stepResults = <ActiveExerciseStepResult>[];
|
|
|
|
@override
|
|
Future<ActiveWorkoutSession?> findById(String id) async {
|
|
return session?.metadata.id == id ? session : null;
|
|
}
|
|
|
|
@override
|
|
Future<ActiveWorkoutSession?> findOpen() async => session;
|
|
|
|
@override
|
|
Future<ActiveRestState?> findRestStateById(String id) async => restStates[id];
|
|
|
|
@override
|
|
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
return scoreStopwatchStates.values
|
|
.where(
|
|
(state) =>
|
|
state.activeWorkoutSessionId == sessionId &&
|
|
state.programIndex == programIndex &&
|
|
state.exerciseIndex == exerciseIndex &&
|
|
state.setIndex == setIndex &&
|
|
state.metadata.deletedAt == null,
|
|
)
|
|
.firstOrNull;
|
|
}
|
|
|
|
@override
|
|
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
return stepProgressStates.values
|
|
.where(
|
|
(state) =>
|
|
state.activeWorkoutSessionId == sessionId &&
|
|
state.programIndex == programIndex &&
|
|
state.exerciseIndex == exerciseIndex &&
|
|
state.setIndex == setIndex &&
|
|
state.metadata.deletedAt == null,
|
|
)
|
|
.firstOrNull;
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
|
return restStates.values
|
|
.where((rest) => rest.activeWorkoutSessionId == sessionId)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
|
|
String sessionId,
|
|
) async {
|
|
return scoreStopwatchStates.values
|
|
.where(
|
|
(state) =>
|
|
state.activeWorkoutSessionId == sessionId &&
|
|
state.metadata.deletedAt == null,
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
|
|
return results
|
|
.where((result) => result.activeWorkoutSessionId == sessionId)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
|
|
String sessionId,
|
|
) async {
|
|
return stepProgressStates.values
|
|
.where((state) => state.activeWorkoutSessionId == sessionId)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
|
|
String sessionId,
|
|
) async {
|
|
return stepResults
|
|
.where((result) => result.activeWorkoutSessionId == sessionId)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> save(ActiveWorkoutSession session) async {
|
|
this.session = session;
|
|
}
|
|
|
|
@override
|
|
Future<void> saveRestState(ActiveRestState restState) async {
|
|
restStates[restState.metadata.id] = restState;
|
|
}
|
|
|
|
@override
|
|
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
|
|
scoreStopwatchStates[state.metadata.id] = state;
|
|
}
|
|
|
|
@override
|
|
Future<void> saveExerciseStepProgressState(
|
|
ActiveExerciseStepProgressState state,
|
|
) async {
|
|
stepProgressStates[state.metadata.id] = state;
|
|
}
|
|
|
|
@override
|
|
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
|
|
stepResults.removeWhere(
|
|
(existing) =>
|
|
existing.activeWorkoutSessionId == result.activeWorkoutSessionId &&
|
|
existing.programIndex == result.programIndex &&
|
|
existing.exerciseIndex == result.exerciseIndex &&
|
|
existing.setIndex == result.setIndex &&
|
|
existing.passageIndex == result.passageIndex &&
|
|
existing.stepIndex == result.stepIndex,
|
|
);
|
|
stepResults.add(result);
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteScoreStopwatchState({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
required DateTime deletedAt,
|
|
}) async {
|
|
final state = await findScoreStopwatchState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
if (state == null) {
|
|
return;
|
|
}
|
|
scoreStopwatchStates.remove(state.metadata.id);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveSetResult(ActiveSetResult result) async {
|
|
results.removeWhere(
|
|
(existing) =>
|
|
existing.activeWorkoutSessionId == result.activeWorkoutSessionId &&
|
|
existing.programIndex == result.programIndex &&
|
|
existing.exerciseIndex == result.exerciseIndex &&
|
|
existing.setIndex == result.setIndex,
|
|
);
|
|
results.add(result);
|
|
}
|
|
}
|
|
|
|
ActiveWorkoutSession _sessionWithSnapshot({
|
|
required int currentProgramIndex,
|
|
required int currentExerciseIndex,
|
|
required int currentSetIndex,
|
|
int setsCount = 2,
|
|
int targetReps = 10,
|
|
List<ExerciseStep> exerciseSteps = const [],
|
|
}) {
|
|
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': [
|
|
{
|
|
'id': 'exercise-snapshot-1',
|
|
'exerciseNameSnapshot': 'Squat',
|
|
'setsCount': setsCount,
|
|
'timeEnabled': false,
|
|
'repsEnabled': true,
|
|
'scoreEnabled': false,
|
|
'targetReps': targetReps,
|
|
'exerciseStepsSnapshot': exerciseSteps
|
|
.map((step) => step.toSnapshotJson())
|
|
.toList(),
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
}
|