- Implémente la couche de synchronisation avec le serveur - Ajoute les fixtures versionnées pour les tests - Met à jour Drift database et repositories pour le support sync - Améliore les tests de synchronisation - Corrige et améliore le watch companion pour la collecte de métriques Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6165 lines
184 KiB
Dart
6165 lines
184 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:gametime/application/application.dart';
|
|
import 'package:gametime/domain/domain.dart';
|
|
import 'package:gametime/infrastructure/local/local.dart' as local;
|
|
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
|
|
|
void main() {
|
|
test('Exercise requires at least one active measure', () {
|
|
expect(
|
|
() => Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Push up',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: false,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Exercise rejects manual score defaults on stopwatch score mode', () {
|
|
expect(
|
|
() => Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Sprint',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
defaultTargetScore: 10,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => Exercise(
|
|
metadata: _metadata('exercise-2'),
|
|
name: 'Sprint',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
defaultTargetScore: 10,
|
|
defaultTargetScoreTimeMs: 12000,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Program exercise allows reps or time with stopwatch score', () {
|
|
final repsAndScore = ProgramExercise(
|
|
metadata: _metadata('program-exercise-1'),
|
|
programId: 'program-1',
|
|
position: 0,
|
|
exerciseNameSnapshot: 'Sprint reps',
|
|
availableTimeSnapshot: false,
|
|
availableRepsSnapshot: true,
|
|
availableScoreSnapshot: true,
|
|
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
|
|
setsCount: 3,
|
|
timeEnabled: false,
|
|
repsEnabled: true,
|
|
scoreEnabled: true,
|
|
targetReps: 10,
|
|
targetScoreTimeMs: 12000,
|
|
);
|
|
final timeAndScore = ProgramExercise(
|
|
metadata: _metadata('program-exercise-2'),
|
|
programId: 'program-1',
|
|
position: 1,
|
|
exerciseNameSnapshot: 'Sprint time',
|
|
availableTimeSnapshot: true,
|
|
availableRepsSnapshot: false,
|
|
availableScoreSnapshot: true,
|
|
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
|
|
setsCount: 3,
|
|
timeEnabled: true,
|
|
repsEnabled: false,
|
|
scoreEnabled: true,
|
|
targetTimeSeconds: 30,
|
|
targetScoreTimeMs: 12000,
|
|
);
|
|
|
|
expect(repsAndScore.scoreInputModeSnapshot, ScoreInputMode.stopwatch);
|
|
expect(timeAndScore.scoreInputModeSnapshot, ScoreInputMode.stopwatch);
|
|
});
|
|
|
|
test('Program exercise rejects manual score target on stopwatch score', () {
|
|
expect(
|
|
() => ProgramExercise(
|
|
metadata: _metadata('program-exercise-1'),
|
|
programId: 'program-1',
|
|
position: 0,
|
|
exerciseNameSnapshot: 'Sprint score',
|
|
availableTimeSnapshot: false,
|
|
availableRepsSnapshot: true,
|
|
availableScoreSnapshot: true,
|
|
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
|
|
setsCount: 3,
|
|
timeEnabled: false,
|
|
repsEnabled: true,
|
|
scoreEnabled: true,
|
|
targetReps: 10,
|
|
targetScore: 10,
|
|
targetScoreTimeMs: 12000,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Exercise use case rejects active measures without default targets',
|
|
() async {
|
|
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
|
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Squat',
|
|
hasTimeMeasure: true,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: false,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Pompes',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Charge',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('Exercise use case rejects invalid default targets', () async {
|
|
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
|
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Squat',
|
|
hasTimeMeasure: true,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: false,
|
|
defaultTargetTimeSeconds: 0,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Pompes',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: -1,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Charge',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
defaultTargetScore: -1,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Exercise use case accepts a zero manual score default target',
|
|
() async {
|
|
final repository = _FakeExerciseRepository();
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
final exercise = await useCase.create(
|
|
name: 'Charge',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
defaultTargetScore: 0,
|
|
);
|
|
|
|
expect(exercise.defaultTargetScore, 0);
|
|
expect(repository.saved.single.defaultTargetScore, 0);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'Exercise use case accepts stopwatch score without default target',
|
|
() async {
|
|
final repository = _FakeExerciseRepository();
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
final exercise = await useCase.create(
|
|
name: 'Sprint',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
scoreLabel: 'Temps réalisé',
|
|
);
|
|
|
|
expect(exercise.defaultTargetScoreTimeMs, isNull);
|
|
expect(repository.saved.single.metadata.id, exercise.metadata.id);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'Exercise use case rejects non-positive stopwatch score default target',
|
|
() async {
|
|
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
|
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Sprint',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
scoreLabel: 'Temps réalisé',
|
|
defaultTargetScoreTimeMs: 0,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('Exercise update validates default targets too', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
await expectLater(
|
|
useCase.update(
|
|
id: 'exercise-1',
|
|
name: 'Squat',
|
|
hasTimeMeasure: true,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: false,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Exercise use case rejects more than eight steps', () async {
|
|
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
|
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Complexe',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 1,
|
|
steps: [
|
|
for (var index = 0; index < 9; index++)
|
|
_step(id: 'step-$index', position: index),
|
|
],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Exercise use case rejects non-contiguous step positions', () async {
|
|
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
|
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Complexe',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 1,
|
|
steps: [
|
|
_step(id: 'step-1', position: 1),
|
|
_step(id: 'step-0', position: 0),
|
|
],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Exercise step validation rejects invalid basic fields', () {
|
|
expect(() => _step(name: ''), throwsA(isA<DomainException>()));
|
|
expect(() => _step(defaultTargetValue: 0), throwsA(isA<DomainException>()));
|
|
});
|
|
|
|
test('Exercise step validation rejects disabled score values', () {
|
|
expect(
|
|
() => _step(hasScore: false, scoreLabel: 'Charge'),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => _step(hasScore: false, defaultTargetScore: 0),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Exercise step manual score validation accepts zero and rejects negative',
|
|
() {
|
|
final step = _step(
|
|
hasScore: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
defaultTargetScore: 0,
|
|
);
|
|
|
|
expect(step.defaultTargetScore, 0);
|
|
expect(
|
|
() => _step(
|
|
hasScore: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
defaultTargetScore: -1,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => _step(hasScore: true, scoreLabel: 'Charge'),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('Exercise step stopwatch score validation rejects manual fields', () {
|
|
final step = _step(
|
|
hasScore: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
defaultTargetScoreTimeMs: 12000,
|
|
);
|
|
|
|
expect(step.defaultTargetScoreTimeMs, 12000);
|
|
expect(
|
|
() => _step(
|
|
hasScore: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
defaultTargetScoreTimeMs: 0,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => _step(
|
|
hasScore: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
scoreLabel: 'Charge',
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('linked step score requires manual series score', () async {
|
|
final linkedStep = _step(
|
|
hasScore: true,
|
|
scoreLabel: 'Paniers',
|
|
scoreUnit: 'pts',
|
|
linkedToSeriesScore: true,
|
|
);
|
|
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
|
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Tirs',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 1,
|
|
steps: [linkedStep],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Tirs chrono',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
defaultTargetReps: 1,
|
|
steps: [linkedStep],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Exercise image gallery rejects a sixth image', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const [
|
|
'media-1',
|
|
'media-2',
|
|
'media-3',
|
|
'media-4',
|
|
'media-5',
|
|
],
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
await expectLater(
|
|
useCase.addImage(exerciseId: 'exercise-1', mediaAssetId: 'media-6'),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Exercise image gallery reorders images', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
final updated = await useCase.reorderImages(
|
|
exerciseId: 'exercise-1',
|
|
orderedMediaIds: const ['media-3', 'media-1', 'media-2'],
|
|
);
|
|
|
|
expect(updated.imageMediaIds, ['media-3', 'media-1', 'media-2']);
|
|
expect(repository.saved.single.imageMediaIds, updated.imageMediaIds);
|
|
});
|
|
|
|
test('Exercise image gallery removes an image', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
final updated = await useCase.removeImage(
|
|
exerciseId: 'exercise-1',
|
|
mediaAssetId: 'media-2',
|
|
);
|
|
|
|
expect(updated.imageMediaIds, ['media-1', 'media-3']);
|
|
expect(updated.imageMediaId, 'media-1');
|
|
});
|
|
|
|
test('Exercise icon can be set to an image from the gallery', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const ['media-1', 'media-2'],
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
final updated = await useCase.setIcon(
|
|
exerciseId: 'exercise-1',
|
|
mediaAssetId: 'media-2',
|
|
);
|
|
|
|
expect(updated.iconMediaId, 'media-2');
|
|
expect(repository.saved.single.iconMediaId, 'media-2');
|
|
});
|
|
|
|
test('Exercise icon rejects images outside the gallery', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const ['media-1'],
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
await expectLater(
|
|
useCase.setIcon(exerciseId: 'exercise-1', mediaAssetId: 'media-2'),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(repository.saved, isEmpty);
|
|
});
|
|
|
|
test('Exercise icon is reset when its image is removed', () async {
|
|
final repository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const ['media-1', 'media-2'],
|
|
iconMediaId: 'media-2',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final useCase = _exerciseUseCase(repository);
|
|
|
|
final updated = await useCase.removeImage(
|
|
exerciseId: 'exercise-1',
|
|
mediaAssetId: 'media-2',
|
|
);
|
|
|
|
expect(updated.imageMediaIds, ['media-1']);
|
|
expect(updated.iconMediaId, isNull);
|
|
expect(repository.saved.single.iconMediaId, isNull);
|
|
});
|
|
|
|
test('program exercise snapshot keeps the full image gallery', () async {
|
|
final exerciseRepository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
|
|
videoMediaId: 'video-1',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final programRepository = _FakeProgramRepository()
|
|
..programs.add(
|
|
Program(
|
|
metadata: _metadata('program-1'),
|
|
name: 'Jambes',
|
|
defaultRestSeconds: 60,
|
|
),
|
|
);
|
|
final useCase = ProgramUseCases(
|
|
programRepository: programRepository,
|
|
exerciseRepository: exerciseRepository,
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final snapshot = await useCase.addExercise(
|
|
programId: 'program-1',
|
|
exerciseId: 'exercise-1',
|
|
position: 0,
|
|
setsCount: 3,
|
|
enabledMeasures: const {WorkoutMeasure.reps},
|
|
);
|
|
|
|
expect(snapshot.exerciseImageMediaIdSnapshot, 'media-1');
|
|
expect(snapshot.exerciseImageMediaIdsSnapshot, [
|
|
'media-1',
|
|
'media-2',
|
|
'media-3',
|
|
]);
|
|
expect(snapshot.exerciseVideoMediaIdSnapshot, 'video-1');
|
|
expect(snapshot.toSnapshotJson()['imageMediaIdsSnapshot'], [
|
|
'media-1',
|
|
'media-2',
|
|
'media-3',
|
|
]);
|
|
expect(
|
|
programRepository
|
|
.programs
|
|
.single
|
|
.exercises
|
|
.single
|
|
.exerciseImageMediaIdsSnapshot,
|
|
snapshot.exerciseImageMediaIdsSnapshot,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'program exercise snapshots step chaining setting from exercise',
|
|
() async {
|
|
final exerciseRepository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Circuit',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
autoStartNextTimedStep: false,
|
|
);
|
|
final programRepository = _FakeProgramRepository()
|
|
..programs.add(
|
|
Program(
|
|
metadata: _metadata('program-1'),
|
|
name: 'Programme',
|
|
defaultRestSeconds: 60,
|
|
),
|
|
);
|
|
final useCase = ProgramUseCases(
|
|
programRepository: programRepository,
|
|
exerciseRepository: exerciseRepository,
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final snapshot = await useCase.addExercise(
|
|
programId: 'program-1',
|
|
exerciseId: 'exercise-1',
|
|
position: 0,
|
|
setsCount: 3,
|
|
enabledMeasures: const {WorkoutMeasure.reps},
|
|
);
|
|
|
|
expect(snapshot.autoStartNextTimedStepSnapshot, isFalse);
|
|
expect(snapshot.autoStartNextTimedStepOverride, isNull);
|
|
expect(
|
|
snapshot.toSnapshotJson()['autoStartNextTimedStepSnapshot'],
|
|
isFalse,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('program saveConfigured preserves step chaining override', () async {
|
|
final programRepository = _FakeProgramRepository();
|
|
final useCase = ProgramUseCases(
|
|
programRepository: programRepository,
|
|
exerciseRepository: _FakeExerciseRepository(),
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final program = await useCase.saveConfigured(
|
|
name: 'Programme',
|
|
defaultRestSeconds: 60,
|
|
exercises: const [
|
|
ProgramExerciseConfig(
|
|
exerciseNameSnapshot: 'Circuit',
|
|
availableTimeSnapshot: false,
|
|
availableRepsSnapshot: true,
|
|
availableScoreSnapshot: false,
|
|
setsCount: 3,
|
|
enabledMeasures: {WorkoutMeasure.reps},
|
|
targetReps: 10,
|
|
autoStartNextTimedStepSnapshot: true,
|
|
autoStartNextTimedStepOverride: false,
|
|
),
|
|
],
|
|
);
|
|
|
|
expect(program.exercises.single.autoStartNextTimedStepSnapshot, isTrue);
|
|
expect(program.exercises.single.autoStartNextTimedStepOverride, isFalse);
|
|
});
|
|
|
|
test('program saveConfigured rejects empty exercise list', () async {
|
|
final useCase = ProgramUseCases(
|
|
programRepository: _FakeProgramRepository(),
|
|
exerciseRepository: _FakeExerciseRepository(),
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.saveConfigured(
|
|
name: 'Programme',
|
|
defaultRestSeconds: 60,
|
|
exercises: const [],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('workout template override preserves step chaining override', () async {
|
|
final templateRepository = _FakeWorkoutTemplateRepository();
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: templateRepository,
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final template = await useCase.saveConfigured(
|
|
name: 'Séance',
|
|
programs: [
|
|
WorkoutTemplateProgramConfig(
|
|
clientKey: 'program-1',
|
|
programNameSnapshot: 'Programme',
|
|
defaultRestSecondsSnapshot: 60,
|
|
programSnapshotJson: jsonEncode({
|
|
'exercises': [
|
|
{
|
|
'id': 'exercise-snapshot-1',
|
|
'exerciseNameSnapshot': 'Circuit',
|
|
'setsCount': 3,
|
|
'timeEnabled': false,
|
|
'repsEnabled': true,
|
|
'scoreEnabled': false,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
],
|
|
overrides: const [
|
|
WorkoutTemplateExerciseOverrideConfig(
|
|
workoutTemplateProgramClientKey: 'program-1',
|
|
snapshotProgramExerciseId: 'exercise-snapshot-1',
|
|
autoStartNextTimedStepOverride: false,
|
|
),
|
|
],
|
|
);
|
|
|
|
expect(template.overrides.single.autoStartNextTimedStepOverride, isFalse);
|
|
});
|
|
|
|
test('workout template saveConfigured rejects empty program list', () async {
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.saveConfigured(
|
|
name: 'Séance',
|
|
programs: const [],
|
|
overrides: const [],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'workout template saveConfigured rejects program snapshot without sets',
|
|
() async {
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.saveConfigured(
|
|
name: 'Séance',
|
|
programs: [
|
|
WorkoutTemplateProgramConfig(
|
|
clientKey: 'program-1',
|
|
programNameSnapshot: 'Programme',
|
|
defaultRestSecondsSnapshot: 60,
|
|
programSnapshotJson: jsonEncode({'exercises': const []}),
|
|
),
|
|
],
|
|
overrides: const [],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('delete exercise keeps existing program snapshots unchanged', () async {
|
|
final exerciseRepository = _FakeExerciseRepository()
|
|
..exercise = Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Squat',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
final programRepository = _FakeProgramRepository()
|
|
..programs.add(
|
|
Program(
|
|
metadata: _metadata('program-1'),
|
|
name: 'Jambes',
|
|
defaultRestSeconds: 60,
|
|
exercises: [
|
|
_programExercise(
|
|
id: 'program-exercise-1',
|
|
programId: 'program-1',
|
|
sourceExerciseId: 'exercise-1',
|
|
position: 0,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final useCase = _exerciseUseCase(
|
|
exerciseRepository,
|
|
programRepository: programRepository,
|
|
);
|
|
|
|
final deleted = await useCase.delete('exercise-1');
|
|
|
|
expect(deleted.metadata.deletedAt, isNotNull);
|
|
expect(exerciseRepository.listActive(), completion(isEmpty));
|
|
expect(programRepository.programs.single.exercises, hasLength(1));
|
|
expect(
|
|
programRepository.programs.single.exercises.single.sourceExerciseId,
|
|
'exercise-1',
|
|
);
|
|
expect(
|
|
programRepository.programs.single.exercises.single.exerciseNameSnapshot,
|
|
'Exercise program-exercise-1',
|
|
);
|
|
expect(programRepository.programs.single.exercises.single.position, 0);
|
|
});
|
|
|
|
test(
|
|
'delete program removes it from referencing workout templates',
|
|
() async {
|
|
final programRepository = _FakeProgramRepository()
|
|
..programs.add(
|
|
Program(
|
|
metadata: _metadata('program-1'),
|
|
name: 'Jambes',
|
|
defaultRestSeconds: 60,
|
|
),
|
|
);
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.add(
|
|
WorkoutTemplate(
|
|
metadata: _metadata('template-1'),
|
|
name: 'Séance',
|
|
programs: [
|
|
_templateProgram(
|
|
id: 'template-program-1',
|
|
workoutTemplateId: 'template-1',
|
|
sourceProgramId: 'program-1',
|
|
position: 0,
|
|
),
|
|
_templateProgram(
|
|
id: 'template-program-2',
|
|
workoutTemplateId: 'template-1',
|
|
sourceProgramId: 'program-2',
|
|
position: 1,
|
|
),
|
|
],
|
|
overrides: [
|
|
WorkoutTemplateExerciseOverride(
|
|
metadata: _metadata('override-1'),
|
|
workoutTemplateProgramId: 'template-program-1',
|
|
snapshotProgramExerciseId: 'exercise-snapshot-1',
|
|
targetRepsOverride: 8,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final useCase = ProgramUseCases(
|
|
programRepository: programRepository,
|
|
exerciseRepository: _FakeExerciseRepository(),
|
|
templateRepository: templateRepository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final deleted = await useCase.delete('program-1');
|
|
|
|
expect(deleted.metadata.deletedAt, isNotNull);
|
|
expect(programRepository.listActive(), completion(isEmpty));
|
|
final template = templateRepository.templates.single;
|
|
expect(template.programs, hasLength(1));
|
|
expect(template.programs.single.sourceProgramId, 'program-2');
|
|
expect(template.programs.single.position, 0);
|
|
expect(template.overrides, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('delete workout template hides it from active list', () async {
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.add(
|
|
WorkoutTemplate(metadata: _metadata('template-1'), name: 'Séance'),
|
|
);
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: templateRepository,
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final deleted = await useCase.delete('template-1');
|
|
|
|
expect(deleted.metadata.deletedAt, isNotNull);
|
|
expect(templateRepository.listActive(), completion(isEmpty));
|
|
});
|
|
|
|
test('history stays unchanged after deleting its source template', () async {
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.add(
|
|
WorkoutTemplate(metadata: _metadata('template-1'), name: 'Séance'),
|
|
);
|
|
final history = WorkoutHistory(
|
|
metadata: _metadata('history-1'),
|
|
sourceWorkoutTemplateId: 'template-1',
|
|
nameSnapshot: 'Séance historique',
|
|
startedAt: DateTime.utc(2026, 7, 17, 10),
|
|
endedAt: DateTime.utc(2026, 7, 17, 11),
|
|
totalActiveMs: 3600000,
|
|
completed: true,
|
|
historySnapshotJson: '{"name":"Séance historique"}',
|
|
);
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: templateRepository,
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await useCase.delete('template-1');
|
|
|
|
expect(history.sourceWorkoutTemplateId, 'template-1');
|
|
expect(history.nameSnapshot, 'Séance historique');
|
|
expect(history.historySnapshotJson, '{"name":"Séance historique"}');
|
|
});
|
|
|
|
test('Workout template use case rejects active measure overrides', () async {
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.overrideExerciseTargets(
|
|
workoutTemplateProgramId: 'template-program-1',
|
|
snapshotProgramExerciseId: 'exercise-snapshot-1',
|
|
attemptedMeasureOverride: {WorkoutMeasure.reps},
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Workout template use case rejects target overrides for inactive measures',
|
|
() async {
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.saveConfigured(
|
|
name: 'Séance tir',
|
|
programs: [
|
|
WorkoutTemplateProgramConfig(
|
|
clientKey: 'program-1',
|
|
programNameSnapshot: 'Programme tir',
|
|
defaultRestSecondsSnapshot: 30,
|
|
programSnapshotJson: jsonEncode({
|
|
'exercises': [
|
|
{
|
|
'id': 'exercise-snapshot-1',
|
|
'exerciseNameSnapshot': 'Lancers francs',
|
|
'setsCount': 3,
|
|
'timeEnabled': true,
|
|
'repsEnabled': false,
|
|
'scoreEnabled': false,
|
|
'targetTimeSeconds': 60,
|
|
'targetReps': null,
|
|
'targetScore': null,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
],
|
|
overrides: const [
|
|
WorkoutTemplateExerciseOverrideConfig(
|
|
workoutTemplateProgramClientKey: 'program-1',
|
|
snapshotProgramExerciseId: 'exercise-snapshot-1',
|
|
targetRepsOverride: 10,
|
|
),
|
|
],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('Active session elapsed time is based on persisted timestamps', () {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = ActiveWorkoutSession(
|
|
metadata: _metadata('session-1'),
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: clock.now(),
|
|
lastPersistedAt: clock.now(),
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: '{}',
|
|
);
|
|
|
|
clock.value = clock.value.add(const Duration(seconds: 30));
|
|
final paused = session.pause(clock.now());
|
|
expect(paused.elapsedActiveMs, 30000);
|
|
|
|
clock.value = clock.value.add(const Duration(minutes: 5));
|
|
final resumed = paused.resume(clock.now());
|
|
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 30000);
|
|
|
|
clock.value = clock.value.add(const Duration(seconds: 15));
|
|
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000);
|
|
});
|
|
|
|
test(
|
|
'startFromTemplate rejects template without playable exercise',
|
|
() async {
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.add(
|
|
WorkoutTemplate(
|
|
metadata: _metadata('template-1'),
|
|
name: 'Séance vide',
|
|
),
|
|
);
|
|
final repository = _FakeActiveSessionRepository();
|
|
final useCase = ActiveWorkoutSessionUseCases(
|
|
sessionRepository: repository,
|
|
templateRepository: templateRepository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.startFromTemplate('template-1'),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(repository.session, isNull);
|
|
},
|
|
);
|
|
|
|
test('startFromTemplate stamps the source template start time', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.add(_playableTemplate(id: 'template-1', name: 'A'));
|
|
final repository = _FakeActiveSessionRepository();
|
|
final useCase = ActiveWorkoutSessionUseCases(
|
|
sessionRepository: repository,
|
|
templateRepository: templateRepository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final session = await useCase.startFromTemplate('template-1');
|
|
|
|
expect(session.sourceWorkoutTemplateId, 'template-1');
|
|
expect(repository.session, session);
|
|
expect(templateRepository.templates.single.lastStartedAt, clock.now());
|
|
});
|
|
|
|
test(
|
|
'startFromLastTemplate starts most recently used playable template',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.addAll([
|
|
_playableTemplate(
|
|
id: 'older',
|
|
name: 'B',
|
|
lastStartedAt: DateTime.utc(2026, 7, 10, 12),
|
|
),
|
|
_playableTemplate(
|
|
id: 'latest',
|
|
name: 'A',
|
|
lastStartedAt: DateTime.utc(2026, 7, 12, 12),
|
|
),
|
|
WorkoutTemplate(metadata: _metadata('empty'), name: 'Empty'),
|
|
]);
|
|
final repository = _FakeActiveSessionRepository();
|
|
final useCase = ActiveWorkoutSessionUseCases(
|
|
sessionRepository: repository,
|
|
templateRepository: templateRepository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final session = await useCase.startFromLastTemplate();
|
|
|
|
expect(session.sourceWorkoutTemplateId, 'latest');
|
|
expect(repository.session, session);
|
|
},
|
|
);
|
|
|
|
test('resume abandons invalid session snapshot before throwing', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = ActiveWorkoutSession(
|
|
metadata: _metadata('session-1'),
|
|
status: ActiveWorkoutStatus.paused,
|
|
startedAt: clock.now(),
|
|
pausedAt: clock.now(),
|
|
lastPersistedAt: clock.now(),
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}),
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
await expectLater(
|
|
useCase.resume(session.metadata.id),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
|
|
expect(repository.session?.endedAt, clock.now());
|
|
});
|
|
|
|
test('findOpen silently abandons invalid legacy session', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = ActiveWorkoutSession(
|
|
metadata: _metadata('session-1'),
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: clock.now(),
|
|
lastPersistedAt: clock.now(),
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}),
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final open = await useCase.findOpen();
|
|
|
|
expect(open, isNull);
|
|
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
|
|
expect(repository.session?.endedAt, clock.now());
|
|
});
|
|
|
|
test('findOpen returns playable open session unchanged', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final open = await useCase.findOpen();
|
|
|
|
expect(open, session);
|
|
expect(repository.session?.status, ActiveWorkoutStatus.running);
|
|
expect(repository.session?.endedAt, isNull);
|
|
});
|
|
|
|
test('close materializes session telemetry aggregate into history', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 28, 10));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
);
|
|
final sessionRepository = _FakeActiveSessionRepository()..session = session;
|
|
final historyRepository = _FakeWorkoutHistoryRepository();
|
|
final telemetryRepository = _FakeWorkoutTelemetryRepository()
|
|
..aggregates.add(
|
|
WorkoutTelemetryAggregate(
|
|
sessionId: session.metadata.id,
|
|
scope: WorkoutTelemetryAggregateScope.session,
|
|
sampleCount: 3,
|
|
minHeartRateBpm: 118,
|
|
averageHeartRateBpm: 132.5,
|
|
maxHeartRateBpm: 151,
|
|
totalDistanceMeters: 720,
|
|
totalCaloriesKcal: 56,
|
|
),
|
|
);
|
|
final useCase = CloseWorkoutSessionUseCase(
|
|
sessionRepository: sessionRepository,
|
|
historyRepository: historyRepository,
|
|
telemetryRepository: telemetryRepository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final history = await useCase.close(
|
|
sessionId: session.metadata.id,
|
|
nameSnapshot: 'Séance',
|
|
completed: true,
|
|
);
|
|
|
|
expect(history.minHeartRateBpm, 118);
|
|
expect(history.averageHeartRateBpm, 132.5);
|
|
expect(history.maxHeartRateBpm, 151);
|
|
expect(history.totalDistanceMeters, 720);
|
|
expect(history.totalCaloriesKcal, 56);
|
|
expect(historyRepository.histories.single, history);
|
|
});
|
|
|
|
test('adjustRestSeconds persists adjusted rest duration', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final repository = _FakeActiveSessionRepository()
|
|
..restStates['rest-1'] = ActiveRestState(
|
|
metadata: _metadata('rest-1'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 0,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now(),
|
|
);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final adjusted = await useCase.adjustRestSeconds(
|
|
restStateId: 'rest-1',
|
|
deltaSeconds: -75,
|
|
);
|
|
|
|
expect(adjusted.adjustedRestSeconds, 0);
|
|
expect(repository.restStates['rest-1']!.adjustedRestSeconds, 0);
|
|
});
|
|
|
|
test('skipRest persists skippedAt timestamp', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final repository = _FakeActiveSessionRepository()
|
|
..restStates['rest-1'] = ActiveRestState(
|
|
metadata: _metadata('rest-1'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 0,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now(),
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 10));
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final skipped = await useCase.skipRest(restStateId: 'rest-1');
|
|
|
|
expect(skipped.skippedAt, clock.now());
|
|
expect(repository.restStates['rest-1']!.skippedAt, clock.now());
|
|
});
|
|
|
|
test('findActiveRest returns latest non-ended non-skipped rest', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final repository = _FakeActiveSessionRepository()
|
|
..restStates['ended'] = ActiveRestState(
|
|
metadata: _metadata('ended'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 0,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now(),
|
|
endedAt: clock.now().add(const Duration(seconds: 60)),
|
|
)
|
|
..restStates['older-active'] = ActiveRestState(
|
|
metadata: _metadata('older-active'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 1,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now().add(const Duration(seconds: 30)),
|
|
)
|
|
..restStates['latest-active'] = ActiveRestState(
|
|
metadata: _metadata('latest-active'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 2,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now().add(const Duration(seconds: 90)),
|
|
)
|
|
..restStates['skipped'] = ActiveRestState(
|
|
metadata: _metadata('skipped'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 3,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now().add(const Duration(seconds: 120)),
|
|
skippedAt: clock.now().add(const Duration(seconds: 125)),
|
|
);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final active = await useCase.findActiveRest(sessionId: 'session-1');
|
|
|
|
expect(active?.metadata.id, 'latest-active');
|
|
});
|
|
|
|
test(
|
|
'startSetExecution starts set timer score stopwatch and first timed step',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
timeEnabled: true,
|
|
repsEnabled: false,
|
|
scoreEnabled: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
exerciseSteps: [_step(type: ExerciseStepType.time)],
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final started = await useCase.startSetExecution(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(started.setTimer?.status, ActiveSetTimerStatus.running);
|
|
expect(
|
|
started.scoreStopwatch?.status,
|
|
ActiveScoreStopwatchStatus.running,
|
|
);
|
|
expect(
|
|
started.stepProgress?.status,
|
|
ActiveExerciseStepProgressStatus.runningTimer,
|
|
);
|
|
expect(repository.setTimerStates.length, 1);
|
|
expect(repository.scoreStopwatchStates.length, 1);
|
|
expect(repository.stepProgressStates.length, 1);
|
|
},
|
|
);
|
|
|
|
test('startSetExecution is idempotent for already running timers', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
timeEnabled: true,
|
|
repsEnabled: false,
|
|
scoreEnabled: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
exerciseSteps: [_step(type: ExerciseStepType.time)],
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final first = await useCase.startSetExecution(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 3));
|
|
final second = await useCase.startSetExecution(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(second.setTimer?.metadata.id, first.setTimer?.metadata.id);
|
|
expect(second.setTimer?.startedAt, first.setTimer?.startedAt);
|
|
expect(repository.setTimerStates.length, 1);
|
|
expect(repository.scoreStopwatchStates.length, 1);
|
|
expect(repository.stepProgressStates.length, 1);
|
|
});
|
|
|
|
test(
|
|
'startSetExecution does not start a next timed step left stopped',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
timeEnabled: true,
|
|
repsEnabled: false,
|
|
exerciseSteps: _twoTimedSteps(),
|
|
);
|
|
final repository = _FakeActiveSessionRepository()
|
|
..session = session
|
|
..stepProgressStates['step-state'] = ActiveExerciseStepProgressState(
|
|
metadata: _metadata('step-state'),
|
|
activeWorkoutSessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
currentPassageIndex: 0,
|
|
currentStepIndex: 1,
|
|
currentStepSnapshotId: 'step-2',
|
|
status: ActiveExerciseStepProgressStatus.stoppedTimer,
|
|
accumulatedMs: 0,
|
|
lastTransitionAt: clock.now(),
|
|
);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final started = await useCase.startSetExecution(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(
|
|
started.stepProgress?.status,
|
|
ActiveExerciseStepProgressStatus.stoppedTimer,
|
|
);
|
|
expect(started.stepProgress?.currentStepIndex, 1);
|
|
expect(started.stepProgress?.startedAt, isNull);
|
|
},
|
|
);
|
|
|
|
test('pause and resume restore running persistent timers', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
timeEnabled: true,
|
|
repsEnabled: false,
|
|
scoreEnabled: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
exerciseSteps: [_step(type: ExerciseStepType.time)],
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
await useCase.startSetExecution(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await useCase.startRestAfterSet(
|
|
sessionId: session.metadata.id,
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 0,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 5));
|
|
await useCase.pause(session.metadata.id);
|
|
|
|
expect(
|
|
repository.setTimerStates.values.single.status,
|
|
ActiveSetTimerStatus.paused,
|
|
);
|
|
expect(repository.setTimerStates.values.single.accumulatedMs, 5000);
|
|
expect(
|
|
repository.scoreStopwatchStates.values.single.status,
|
|
ActiveScoreStopwatchStatus.paused,
|
|
);
|
|
expect(
|
|
repository.stepProgressStates.values.single.status,
|
|
ActiveExerciseStepProgressStatus.pausedTimer,
|
|
);
|
|
final pausedRest = repository.restStates.values.single;
|
|
expect(pausedRest.pausedAt, clock.now());
|
|
expect(pausedRest.remainingMillisecondsAt(clock.now()), 55000);
|
|
|
|
clock.value = clock.value.add(const Duration(minutes: 2));
|
|
await useCase.resume(session.metadata.id);
|
|
|
|
expect(
|
|
repository.setTimerStates.values.single.status,
|
|
ActiveSetTimerStatus.running,
|
|
);
|
|
expect(
|
|
repository.scoreStopwatchStates.values.single.status,
|
|
ActiveScoreStopwatchStatus.running,
|
|
);
|
|
expect(
|
|
repository.stepProgressStates.values.single.status,
|
|
ActiveExerciseStepProgressStatus.runningTimer,
|
|
);
|
|
final resumedRest = repository.restStates.values.single;
|
|
expect(resumedRest.pausedAt, isNull);
|
|
expect(resumedRest.accumulatedPausedMs, 120000);
|
|
expect(resumedRest.remainingMillisecondsAt(clock.now()), 55000);
|
|
});
|
|
|
|
test('upsertSetResultAtPosition does not move session cursor', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 1,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
await useCase.upsertSetResultAtPosition(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
status: SetResultStatus.completed,
|
|
actualReps: 12,
|
|
);
|
|
|
|
expect(repository.session?.currentProgramIndex, 0);
|
|
expect(repository.session?.currentExerciseIndex, 0);
|
|
expect(repository.session?.currentSetIndex, 1);
|
|
expect(repository.results.single.actualReps, 12);
|
|
});
|
|
|
|
test(
|
|
'upsertSetResultAtPosition preserves existing id and createdAt',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 1,
|
|
);
|
|
final existingCreatedAt = DateTime.utc(2026, 7, 17, 10);
|
|
final existing = ActiveSetResult(
|
|
metadata: EntityMetadata(
|
|
id: 'result-1',
|
|
createdAt: existingCreatedAt,
|
|
updatedAt: existingCreatedAt,
|
|
originDeviceId: 'device-1',
|
|
),
|
|
activeWorkoutSessionId: session.metadata.id,
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualReps: 8,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()
|
|
..session = session
|
|
..results.add(existing);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final updated = await useCase.upsertSetResultAtPosition(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
status: SetResultStatus.completed,
|
|
actualReps: 12,
|
|
);
|
|
|
|
expect(updated.metadata.id, 'result-1');
|
|
expect(updated.metadata.createdAt, existingCreatedAt);
|
|
expect(updated.actualReps, 12);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'listSetResults returns pending, skipped and completed statuses',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 2,
|
|
setsCount: 3,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()
|
|
..session = session
|
|
..results.addAll([
|
|
ActiveSetResult(
|
|
metadata: _metadata('completed'),
|
|
activeWorkoutSessionId: session.metadata.id,
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualReps: 10,
|
|
status: SetResultStatus.completed,
|
|
),
|
|
ActiveSetResult(
|
|
metadata: _metadata('skipped'),
|
|
activeWorkoutSessionId: session.metadata.id,
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 1,
|
|
status: SetResultStatus.skipped,
|
|
),
|
|
]);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final states = await useCase.listSetResults(session.metadata.id);
|
|
|
|
expect(states.map((state) => state.status), [
|
|
SetPositionStatus.completed,
|
|
SetPositionStatus.skipped,
|
|
SetPositionStatus.pending,
|
|
]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'score stopwatch start stop resume accumulates elapsed duration',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
await useCase.startScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 5));
|
|
final stopped = await useCase.stopScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 10));
|
|
await useCase.resumeScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 3));
|
|
final stoppedAgain = await useCase.stopScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(stopped.accumulatedMs, 5000);
|
|
expect(stoppedAgain.accumulatedMs, 8000);
|
|
},
|
|
);
|
|
|
|
test('score stopwatch state survives use case reconstruction', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
|
|
await _activeUseCase(repository, clock).startScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 4));
|
|
|
|
final reconstructed = _activeUseCase(repository, clock);
|
|
final stopped = await reconstructed.stopScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(stopped.accumulatedMs, 4000);
|
|
});
|
|
|
|
test(
|
|
'exercise step progress auto-advances consecutive elapsed timers after kill',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
setsCount: 1,
|
|
targetReps: 2,
|
|
exerciseSteps: [
|
|
_step(
|
|
id: 'step-1',
|
|
position: 0,
|
|
name: 'Gauche',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 1,
|
|
),
|
|
_step(
|
|
id: 'step-2',
|
|
position: 1,
|
|
name: 'Droite',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 1,
|
|
),
|
|
],
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await useCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await useCase.startTimer(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
clock.value = clock.value.add(const Duration(milliseconds: 4500));
|
|
final reconstructed = ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final view = await reconstructed.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(
|
|
view.state.status,
|
|
ActiveExerciseStepProgressStatus.sequenceComplete,
|
|
);
|
|
expect(repository.stepResults, hasLength(4));
|
|
expect(repository.stepResults.map((result) => result.passageIndex), [
|
|
0,
|
|
0,
|
|
1,
|
|
1,
|
|
]);
|
|
expect(repository.stepResults.map((result) => result.stepIndex), [
|
|
0,
|
|
1,
|
|
0,
|
|
1,
|
|
]);
|
|
expect(repository.stepResults.map((result) => result.actualTimeMs), [
|
|
1000,
|
|
1000,
|
|
1000,
|
|
1000,
|
|
]);
|
|
},
|
|
);
|
|
|
|
test('linked step score controls current series manual score', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
setsCount: 1,
|
|
targetReps: 1,
|
|
scoreEnabled: true,
|
|
exerciseSteps: [
|
|
_step(
|
|
id: 'step-1',
|
|
position: 0,
|
|
name: 'Tirs main droite',
|
|
type: ExerciseStepType.reps,
|
|
defaultTargetValue: 10,
|
|
hasScore: true,
|
|
scoreLabel: 'Paniers',
|
|
scoreUnit: 'pts',
|
|
linkedToSeriesScore: true,
|
|
),
|
|
],
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final activeUseCase = _activeUseCase(repository, clock);
|
|
final stepUseCase = ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
activeSessionUseCases: activeUseCase,
|
|
);
|
|
|
|
await stepUseCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await stepUseCase.incrementCurrentStepScore(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await stepUseCase.incrementCurrentStepScore(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await stepUseCase.decrementCurrentStepScore(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(repository.manualScoreStates.values.single.value, 1);
|
|
|
|
final result = await stepUseCase.completeCurrentStep(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualScore: 3,
|
|
);
|
|
|
|
expect(result.actualScore, 3);
|
|
expect(repository.manualScoreStates.values.single.value, 4);
|
|
});
|
|
|
|
test(
|
|
'step chaining resolution uses template override before program override',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
setsCount: 1,
|
|
targetReps: 1,
|
|
autoStartNextTimedStepSnapshot: true,
|
|
autoStartNextTimedStepOverride: true,
|
|
templateAutoStartNextTimedStepOverride: false,
|
|
exerciseSteps: _twoTimedSteps(),
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await useCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await useCase.startTimer(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(milliseconds: 2500));
|
|
|
|
final view = await useCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(view.state.status, ActiveExerciseStepProgressStatus.stoppedTimer);
|
|
expect(view.state.currentStepIndex, 1);
|
|
expect(view.state.accumulatedMs, 0);
|
|
expect(repository.stepResults, hasLength(1));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'step chaining disabled stops at first elapsed timed boundary after kill',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
setsCount: 1,
|
|
targetReps: 1,
|
|
autoStartNextTimedStepSnapshot: false,
|
|
exerciseSteps: [
|
|
..._twoTimedSteps(),
|
|
_step(
|
|
id: 'step-3',
|
|
position: 2,
|
|
name: 'Centre',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 1,
|
|
),
|
|
],
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
|
|
await ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
).startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
).startTimer(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(milliseconds: 4500));
|
|
|
|
final reconstructed = ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
final view = await reconstructed.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(view.state.status, ActiveExerciseStepProgressStatus.stoppedTimer);
|
|
expect(view.state.currentStepIndex, 1);
|
|
expect(view.state.startedAt, isNull);
|
|
expect(view.state.accumulatedMs, 0);
|
|
expect(repository.stepResults, hasLength(1));
|
|
expect(repository.stepResults.single.stepIndex, 0);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'step chaining resolution uses program override before exercise snapshot',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
setsCount: 1,
|
|
targetReps: 1,
|
|
autoStartNextTimedStepSnapshot: false,
|
|
autoStartNextTimedStepOverride: true,
|
|
exerciseSteps: _twoTimedSteps(),
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await useCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await useCase.startTimer(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(milliseconds: 2500));
|
|
|
|
final view = await useCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(
|
|
view.state.status,
|
|
ActiveExerciseStepProgressStatus.sequenceComplete,
|
|
);
|
|
expect(repository.stepResults, hasLength(2));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'step chaining defaults to true when snapshot fields are absent',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
setsCount: 1,
|
|
targetReps: 1,
|
|
autoStartNextTimedStepSnapshot: null,
|
|
exerciseSteps: _twoTimedSteps(),
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await useCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await useCase.startTimer(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(milliseconds: 2500));
|
|
|
|
final view = await useCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(
|
|
view.state.status,
|
|
ActiveExerciseStepProgressStatus.sequenceComplete,
|
|
);
|
|
expect(repository.stepResults, hasLength(2));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'completeCurrentStep leaves next timed step stopped when chaining disabled',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
setsCount: 1,
|
|
targetReps: 1,
|
|
autoStartNextTimedStepSnapshot: false,
|
|
exerciseSteps: _twoTimedSteps(),
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await useCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await useCase.startTimer(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(milliseconds: 500));
|
|
await useCase.completeCurrentStep(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
final view = await useCase.readProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(view.state.status, ActiveExerciseStepProgressStatus.stoppedTimer);
|
|
expect(view.state.currentStepIndex, 1);
|
|
expect(view.state.startedAt, isNull);
|
|
expect(view.state.accumulatedMs, 0);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'completeCurrentStep starts next timed step when chaining is enabled',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
setsCount: 1,
|
|
targetReps: 1,
|
|
autoStartNextTimedStepSnapshot: true,
|
|
exerciseSteps: _twoTimedSteps(),
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await useCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await useCase.startTimer(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(milliseconds: 1500));
|
|
await useCase.completeCurrentStep(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
final view = await useCase.readProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(view.state.status, ActiveExerciseStepProgressStatus.runningTimer);
|
|
expect(view.state.currentStepIndex, 1);
|
|
expect(view.state.elapsedMillisecondsAt(clock.now()), 500);
|
|
},
|
|
);
|
|
|
|
test('startTimer manually starts a stopped next timed step', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
setsCount: 1,
|
|
targetReps: 1,
|
|
autoStartNextTimedStepSnapshot: false,
|
|
exerciseSteps: _twoTimedSteps(),
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = ActiveExerciseStepUseCases(
|
|
sessionRepository: repository,
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await useCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await useCase.startTimer(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(milliseconds: 1500));
|
|
await useCase.startOrResumeProgress(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
final running = await useCase.startTimer(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(running.status, ActiveExerciseStepProgressStatus.runningTimer);
|
|
expect(running.currentStepIndex, 1);
|
|
expect(running.startedAt, clock.now());
|
|
});
|
|
|
|
test('AuthUseCases register stores token and account session', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final tokenStore = _FakeAuthTokenStore();
|
|
final accountRepository = _FakeOnlineAccountRepository();
|
|
final remoteAuthApi = _FakeRemoteAuthApi();
|
|
final useCase = _authUseCase(
|
|
tokenStore: tokenStore,
|
|
accountRepository: accountRepository,
|
|
remoteAuthApi: remoteAuthApi,
|
|
clock: clock,
|
|
);
|
|
|
|
final session = await useCase.register(
|
|
email: 'USER@example.com',
|
|
password: 'password123',
|
|
displayName: 'User',
|
|
);
|
|
|
|
expect(session.serverUserId, 'server-user-1');
|
|
expect(session.email, 'user@example.com');
|
|
expect(session.displayName, 'User');
|
|
expect(session.isLoggedIn, isTrue);
|
|
expect(tokenStore.token, 'token-1');
|
|
expect(accountRepository.session, session);
|
|
});
|
|
|
|
test('AuthUseCases register propagates email already used error', () async {
|
|
final remoteAuthApi = _FakeRemoteAuthApi()
|
|
..registerException = const RemoteAuthException(
|
|
RemoteAuthFailure.emailAlreadyUsed,
|
|
);
|
|
|
|
await expectLater(
|
|
_authUseCase(
|
|
remoteAuthApi: remoteAuthApi,
|
|
).register(email: 'user@example.com', password: 'password123'),
|
|
throwsA(
|
|
isA<RemoteAuthException>().having(
|
|
(error) => error.failure,
|
|
'failure',
|
|
RemoteAuthFailure.emailAlreadyUsed,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('AuthUseCases login stores token and account session', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final tokenStore = _FakeAuthTokenStore();
|
|
final accountRepository = _FakeOnlineAccountRepository();
|
|
final remoteAuthApi = _FakeRemoteAuthApi();
|
|
final useCase = _authUseCase(
|
|
tokenStore: tokenStore,
|
|
accountRepository: accountRepository,
|
|
remoteAuthApi: remoteAuthApi,
|
|
clock: clock,
|
|
);
|
|
|
|
final session = await useCase.login(
|
|
email: 'user@example.com',
|
|
password: 'password123',
|
|
);
|
|
|
|
expect(session.serverUserId, 'server-user-1');
|
|
expect(session.isLoggedIn, isTrue);
|
|
expect(tokenStore.token, 'token-1');
|
|
expect(tokenStore.expiresAt, DateTime.utc(2026, 8, 16, 12));
|
|
expect(accountRepository.session?.lastAuthenticatedAt, clock.now());
|
|
});
|
|
|
|
test('AuthUseCases login propagates invalid credentials', () async {
|
|
final remoteAuthApi = _FakeRemoteAuthApi()
|
|
..loginException = const RemoteAuthException(
|
|
RemoteAuthFailure.invalidCredentials,
|
|
);
|
|
|
|
await expectLater(
|
|
_authUseCase(
|
|
remoteAuthApi: remoteAuthApi,
|
|
).login(email: 'user@example.com', password: 'wrong-password'),
|
|
throwsA(
|
|
isA<RemoteAuthException>().having(
|
|
(error) => error.failure,
|
|
'failure',
|
|
RemoteAuthFailure.invalidCredentials,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('AuthUseCases logout clears local token even if remote fails', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final tokenStore = _FakeAuthTokenStore()
|
|
..token = 'token-1'
|
|
..expiresAt = DateTime.utc(2026, 8, 16, 12);
|
|
final accountRepository = _FakeOnlineAccountRepository()
|
|
..session = UserAccountSession(
|
|
id: 'session-1',
|
|
serverUserId: 'server-user-1',
|
|
email: 'user@example.com',
|
|
isLoggedIn: true,
|
|
createdAt: DateTime.utc(2026, 7, 17, 11),
|
|
updatedAt: DateTime.utc(2026, 7, 17, 11),
|
|
);
|
|
final remoteAuthApi = _FakeRemoteAuthApi()
|
|
..logoutException = const RemoteAuthException(RemoteAuthFailure.network);
|
|
|
|
await _authUseCase(
|
|
tokenStore: tokenStore,
|
|
accountRepository: accountRepository,
|
|
remoteAuthApi: remoteAuthApi,
|
|
clock: clock,
|
|
).logout();
|
|
|
|
expect(tokenStore.token, isNull);
|
|
expect(accountRepository.session?.isLoggedIn, isFalse);
|
|
expect(remoteAuthApi.logoutCalls, 1);
|
|
});
|
|
|
|
test('AuthUseCases currentSession reads local cache only', () async {
|
|
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
|
final accountRepository = _FakeOnlineAccountRepository()
|
|
..session = UserAccountSession(
|
|
id: 'session-1',
|
|
serverUserId: 'server-user-1',
|
|
email: 'user@example.com',
|
|
isLoggedIn: true,
|
|
createdAt: DateTime.utc(2026, 7, 17, 11),
|
|
updatedAt: DateTime.utc(2026, 7, 17, 11),
|
|
);
|
|
final remoteAuthApi = _FakeRemoteAuthApi();
|
|
|
|
final session = await _authUseCase(
|
|
tokenStore: tokenStore,
|
|
accountRepository: accountRepository,
|
|
remoteAuthApi: remoteAuthApi,
|
|
).currentSession();
|
|
|
|
expect(session?.email, 'user@example.com');
|
|
expect(remoteAuthApi.registerCalls, 0);
|
|
expect(remoteAuthApi.loginCalls, 0);
|
|
expect(remoteAuthApi.logoutCalls, 0);
|
|
});
|
|
|
|
test('SyncUseCases skips silently without token', () async {
|
|
final remoteSyncApi = _FakeRemoteSyncApi();
|
|
final metadataRepository = _FakeSyncMetadataRepository();
|
|
final localChanges = _FakeLocalSyncChangeRepository();
|
|
|
|
final summary = await _syncUseCase(
|
|
tokenStore: _FakeAuthTokenStore(),
|
|
remoteSyncApi: remoteSyncApi,
|
|
metadataRepository: metadataRepository,
|
|
localChanges: localChanges,
|
|
).synchronize(manual: true);
|
|
|
|
expect(summary.skipped, isTrue);
|
|
expect(remoteSyncApi.pushCalls, 0);
|
|
expect(remoteSyncApi.pullCalls, 0);
|
|
expect(metadataRepository.saved, isEmpty);
|
|
});
|
|
|
|
test('SyncUseCases successful push marks change log rows synced', () async {
|
|
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
|
final localChanges = _FakeLocalSyncChangeRepository()
|
|
..pendingChanges.add(_pendingExerciseChange());
|
|
final remoteSyncApi = _FakeRemoteSyncApi()
|
|
..pushResult = RemoteSyncPushResult(
|
|
serverCursor: 'cursor-1',
|
|
results: [
|
|
RemoteSyncPushItemResult(
|
|
resourceType: SyncResourceType.exercise,
|
|
clientId: 'exercise-1',
|
|
serverId: 'server-exercise-1',
|
|
status: RemoteSyncPushStatus.accepted,
|
|
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
|
|
),
|
|
],
|
|
);
|
|
final mappingRepository = _FakeRemoteResourceMappingRepository();
|
|
|
|
final summary = await _syncUseCase(
|
|
tokenStore: tokenStore,
|
|
remoteSyncApi: remoteSyncApi,
|
|
mappingRepository: mappingRepository,
|
|
localChanges: localChanges,
|
|
).synchronize(manual: true);
|
|
|
|
expect(summary.pushedChanges, 1);
|
|
expect(localChanges.syncedChangeLogIds, ['change-1']);
|
|
expect(mappingRepository.saved.single.serverId, 'server-exercise-1');
|
|
});
|
|
|
|
test('SyncUseCases pull applies a newer remote item', () async {
|
|
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
|
final localChanges = _FakeLocalSyncChangeRepository()
|
|
..localUpdatedAt = DateTime.utc(2026, 7, 17, 11);
|
|
final remoteSyncApi = _FakeRemoteSyncApi()
|
|
..pullResult = RemoteSyncPullResult(
|
|
serverCursor: 'cursor-2',
|
|
items: [
|
|
_remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 12)),
|
|
],
|
|
);
|
|
|
|
final summary = await _syncUseCase(
|
|
tokenStore: tokenStore,
|
|
remoteSyncApi: remoteSyncApi,
|
|
localChanges: localChanges,
|
|
).synchronize(manual: true);
|
|
|
|
expect(summary.pulledChanges, 1);
|
|
expect(localChanges.appliedItems.single.clientId, 'exercise-1');
|
|
});
|
|
|
|
test('SyncUseCases pull ignores an older remote item', () async {
|
|
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
|
final localChanges = _FakeLocalSyncChangeRepository()
|
|
..localUpdatedAt = DateTime.utc(2026, 7, 17, 12);
|
|
final remoteSyncApi = _FakeRemoteSyncApi()
|
|
..pullResult = RemoteSyncPullResult(
|
|
serverCursor: 'cursor-2',
|
|
items: [
|
|
_remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 11)),
|
|
],
|
|
);
|
|
|
|
final summary = await _syncUseCase(
|
|
tokenStore: tokenStore,
|
|
remoteSyncApi: remoteSyncApi,
|
|
localChanges: localChanges,
|
|
).synchronize(manual: true);
|
|
|
|
expect(summary.pulledChanges, 0);
|
|
expect(localChanges.appliedItems, isEmpty);
|
|
});
|
|
|
|
test('SyncUseCases stores failure status without throwing', () async {
|
|
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
|
|
final metadataRepository = _FakeSyncMetadataRepository();
|
|
final remoteSyncApi = _FakeRemoteSyncApi()
|
|
..exception = const RemoteAuthException(RemoteAuthFailure.network);
|
|
|
|
final summary = await _syncUseCase(
|
|
tokenStore: tokenStore,
|
|
remoteSyncApi: remoteSyncApi,
|
|
metadataRepository: metadataRepository,
|
|
).synchronize(manual: true);
|
|
|
|
expect(summary.failed, isTrue);
|
|
expect(metadataRepository.metadata.status, OnlineSyncStatus.failure);
|
|
expect(metadataRepository.metadata.lastFailureAt, isNotNull);
|
|
});
|
|
|
|
test(
|
|
'ShareUseCases sendShare returns neutral result without token',
|
|
() async {
|
|
final remoteShareApi = _FakeRemoteShareApi();
|
|
final pendingRepository = _FakePendingShareActionRepository();
|
|
final programRepository = _FakeProgramRepository()
|
|
..programs.add(_shareableProgram());
|
|
|
|
final result =
|
|
await _shareUseCase(
|
|
tokenStore: _FakeAuthTokenStore(),
|
|
remoteShareApi: remoteShareApi,
|
|
pendingActionRepository: pendingRepository,
|
|
programRepository: programRepository,
|
|
).sendShare(
|
|
resourceType: ShareResourceType.program,
|
|
localResourceId: 'program-1',
|
|
recipientEmails: const ['friend@example.com'],
|
|
);
|
|
|
|
expect(result.status, ShareSendStatus.notConnected);
|
|
expect(remoteShareApi.sendCalls, 0);
|
|
expect(pendingRepository.actions, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('ShareUseCases sendShare queues action when network fails', () async {
|
|
final remoteShareApi = _FakeRemoteShareApi()
|
|
..exception = const RemoteAuthException(RemoteAuthFailure.network);
|
|
final pendingRepository = _FakePendingShareActionRepository();
|
|
final programRepository = _FakeProgramRepository()
|
|
..programs.add(_shareableProgram());
|
|
|
|
final result =
|
|
await _shareUseCase(
|
|
remoteShareApi: remoteShareApi,
|
|
pendingActionRepository: pendingRepository,
|
|
programRepository: programRepository,
|
|
).sendShare(
|
|
resourceType: ShareResourceType.program,
|
|
localResourceId: 'program-1',
|
|
recipientEmails: const ['friend@example.com'],
|
|
);
|
|
|
|
expect(result.status, ShareSendStatus.queued);
|
|
expect(
|
|
pendingRepository.actions.single.actionType,
|
|
PendingShareActionType.send,
|
|
);
|
|
expect(
|
|
pendingRepository.actions.single.resourceType,
|
|
ShareResourceType.program,
|
|
);
|
|
expect(pendingRepository.actions.single.payloadJson, contains('Programme'));
|
|
});
|
|
|
|
test('ShareUseCases sendShare supports workout packs', () async {
|
|
final remoteShareApi = _FakeRemoteShareApi();
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.add(_playableTemplate(id: 'template-1', name: 'Séance 1'))
|
|
..templates.add(_playableTemplate(id: 'template-2', name: 'Séance 2'));
|
|
|
|
final result =
|
|
await _shareUseCase(
|
|
remoteShareApi: remoteShareApi,
|
|
templateRepository: templateRepository,
|
|
).sendShare(
|
|
resourceType: ShareResourceType.pack,
|
|
localResourceId: '',
|
|
localResourceIds: const ['template-1', 'template-2'],
|
|
packName: 'Pack reprise',
|
|
recipientEmails: const ['coach@example.com'],
|
|
);
|
|
|
|
expect(result.status, ShareSendStatus.sent);
|
|
expect(remoteShareApi.lastResourceType, ShareResourceType.pack);
|
|
expect(remoteShareApi.lastPayload?['name'], 'Pack reprise');
|
|
expect(remoteShareApi.lastPayload?['workouts'], isA<List<Object?>>());
|
|
expect(remoteShareApi.lastPayload?['workouts'], hasLength(2));
|
|
});
|
|
|
|
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 imports all returned resources locally',
|
|
() async {
|
|
final remoteShareApi = _FakeRemoteShareApi()
|
|
..acceptResults = [
|
|
_remoteSharedProgramItem(),
|
|
_remoteSharedTemplateItem(),
|
|
];
|
|
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.map((item) => item.resourceType), [
|
|
SyncResourceType.program,
|
|
SyncResourceType.workoutTemplate,
|
|
]);
|
|
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'ShareUseCases acceptShare imports cached pack payload after remote ack',
|
|
() async {
|
|
final remoteShareApi = _FakeRemoteShareApi();
|
|
final templateRepository = _FakeWorkoutTemplateRepository();
|
|
final inboxRepository = _FakeShareInboxRepository()
|
|
..items.add(
|
|
_shareInboxItem(
|
|
status: ShareInboxStatus.pending,
|
|
resourceType: ShareResourceType.pack,
|
|
payloadJson: jsonEncode({
|
|
'name': 'Pack reprise',
|
|
'workouts': [
|
|
_sharedTemplatePayload(id: 'template-a', name: 'Séance A'),
|
|
_sharedTemplatePayload(id: 'template-b', name: 'Séance B'),
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
await _shareUseCase(
|
|
remoteShareApi: remoteShareApi,
|
|
inboxRepository: inboxRepository,
|
|
templateRepository: templateRepository,
|
|
).acceptShare('share-1');
|
|
|
|
expect(remoteShareApi.acceptCalls, 1);
|
|
expect(templateRepository.templates, hasLength(2));
|
|
expect(
|
|
templateRepository.templates.map((template) => template.name),
|
|
containsAll(['Séance A', 'Séance B']),
|
|
);
|
|
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 acceptShare sans token importe un pack complet',
|
|
() async {
|
|
final pendingRepository = _FakePendingShareActionRepository();
|
|
final templateRepository = _FakeWorkoutTemplateRepository();
|
|
final inboxRepository = _FakeShareInboxRepository()
|
|
..items.add(
|
|
_shareInboxItem(
|
|
status: ShareInboxStatus.pending,
|
|
resourceType: ShareResourceType.pack,
|
|
payloadJson: jsonEncode({
|
|
'name': 'Pack reprise',
|
|
'workouts': [
|
|
_sharedTemplatePayload(id: 'template-a', name: 'Séance A'),
|
|
_sharedTemplatePayload(id: 'template-b', name: 'Séance B'),
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
await _shareUseCase(
|
|
tokenStore: _FakeAuthTokenStore(),
|
|
inboxRepository: inboxRepository,
|
|
pendingActionRepository: pendingRepository,
|
|
templateRepository: templateRepository,
|
|
).acceptShare('share-1');
|
|
|
|
expect(templateRepository.templates, hasLength(2));
|
|
expect(
|
|
templateRepository.templates.map((template) => template.name),
|
|
containsAll(['Séance A', 'Séance B']),
|
|
);
|
|
expect(
|
|
templateRepository.templates.map((template) => template.metadata.id),
|
|
isNot(contains('template-a')),
|
|
);
|
|
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
|
expect(
|
|
pendingRepository.actions.single.actionType,
|
|
PendingShareActionType.accept,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'ShareUseCases acceptShare sans token annule tout le pack si un import échoue',
|
|
() async {
|
|
final database = local.AppDatabase(NativeDatabase.memory());
|
|
addTearDown(database.close);
|
|
final inboxRepository = local.DriftShareInboxRepository(database);
|
|
final pendingRepository = local.DriftPendingShareActionRepository(
|
|
database,
|
|
);
|
|
final templateRepository = local.DriftWorkoutTemplateRepository(database);
|
|
await inboxRepository.upsert(
|
|
_shareInboxItem(
|
|
shareId: 'share-pack-failing',
|
|
status: ShareInboxStatus.pending,
|
|
resourceType: ShareResourceType.pack,
|
|
payloadJson: jsonEncode({
|
|
'name': 'Pack incomplet',
|
|
'workouts': [
|
|
_sharedTemplatePayload(id: 'template-a', name: 'Séance A'),
|
|
{
|
|
'id': 'template-b',
|
|
'name': 'Séance B',
|
|
'programs': [
|
|
{
|
|
'id': 'template-b-program-1',
|
|
'position': 0,
|
|
'programNameSnapshot': 'Programme A',
|
|
'defaultRestSecondsSnapshot': 60,
|
|
'programSnapshotJson': jsonEncode({'exercises': const []}),
|
|
},
|
|
{
|
|
'id': 'template-b-program-2',
|
|
'position': 0,
|
|
'programNameSnapshot': 'Programme B',
|
|
'defaultRestSecondsSnapshot': 60,
|
|
'programSnapshotJson': jsonEncode({'exercises': const []}),
|
|
},
|
|
],
|
|
'overrides': const [],
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
final useCase = ShareUseCases(
|
|
tokenStore: _FakeAuthTokenStore(),
|
|
remoteShareApi: _FakeRemoteShareApi(),
|
|
inboxRepository: inboxRepository,
|
|
pendingActionRepository: pendingRepository,
|
|
localChanges: _FakeLocalSyncChangeRepository(),
|
|
programRepository: local.DriftProgramRepository(database),
|
|
templateRepository: templateRepository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.acceptShare('share-pack-failing'),
|
|
throwsA(isA<Exception>()),
|
|
);
|
|
|
|
expect(await templateRepository.listActive(), isEmpty);
|
|
expect(
|
|
(await inboxRepository.findByShareId('share-pack-failing'))!.status,
|
|
ShareInboxStatus.pending,
|
|
);
|
|
expect(await pendingRepository.listPending(), isEmpty);
|
|
},
|
|
);
|
|
|
|
test('ShareUseCases refreshInbox updates the local cache', () async {
|
|
final remoteShareApi = _FakeRemoteShareApi()
|
|
..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)];
|
|
final inboxRepository = _FakeShareInboxRepository();
|
|
|
|
final items = await _shareUseCase(
|
|
remoteShareApi: remoteShareApi,
|
|
inboxRepository: inboxRepository,
|
|
).refreshInbox();
|
|
|
|
expect(items.single.shareId, 'share-1');
|
|
expect(inboxRepository.items.single.senderUserId, 'sender-1');
|
|
});
|
|
|
|
test('ShareUseCases decline and revoke update local statuses', () async {
|
|
final inboxRepository = _FakeShareInboxRepository()
|
|
..items.add(_shareInboxItem(status: ShareInboxStatus.pending))
|
|
..items.add(
|
|
_shareInboxItem(shareId: 'share-2', status: ShareInboxStatus.pending),
|
|
);
|
|
final useCase = _shareUseCase(inboxRepository: inboxRepository);
|
|
|
|
await useCase.declineShare('share-1');
|
|
await useCase.revokeShare('share-2');
|
|
|
|
expect(
|
|
inboxRepository.items
|
|
.singleWhere((item) => item.shareId == 'share-1')
|
|
.status,
|
|
ShareInboxStatus.declined,
|
|
);
|
|
expect(
|
|
inboxRepository.items
|
|
.singleWhere((item) => item.shareId == 'share-2')
|
|
.status,
|
|
ShareInboxStatus.revoked,
|
|
);
|
|
});
|
|
|
|
test('score result enforces manual xor stopwatch values', () {
|
|
expect(
|
|
() => ActiveSetResult(
|
|
metadata: _metadata('result-1'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
programSnapshotId: 'program-1',
|
|
exerciseSnapshotId: 'exercise-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualScore: 12,
|
|
actualScoreTimeMs: 12000,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => ActiveSetResult(
|
|
metadata: _metadata('result-2'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
programSnapshotId: 'program-1',
|
|
exerciseSnapshotId: 'exercise-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualScore: 12,
|
|
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => ActiveSetResult(
|
|
metadata: _metadata('result-3'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
programSnapshotId: 'program-1',
|
|
exerciseSnapshotId: 'exercise-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualScoreTimeMs: 12000,
|
|
scoreInputModeSnapshot: ScoreInputMode.manual,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'recordCurrentSetResult uses live manual score unless overridden',
|
|
() async {
|
|
final repository = _FakeActiveSessionRepository()
|
|
..session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
scoreEnabled: true,
|
|
);
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
|
|
final useCase = _activeUseCase(repository, clock);
|
|
await useCase.incrementManualScore(
|
|
sessionId: 'session-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
await useCase.incrementManualScore(
|
|
sessionId: 'session-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
final fromLiveState = await useCase.recordCurrentSetResult(
|
|
sessionId: 'session-1',
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
scoreInputModeSnapshot: ScoreInputMode.manual,
|
|
scoreLabelSnapshot: 'Points',
|
|
scoreUnitSnapshot: 'pts',
|
|
);
|
|
|
|
expect(fromLiveState.actualScore, 2);
|
|
expect(repository.manualScoreStates, isEmpty);
|
|
|
|
await useCase.incrementManualScore(
|
|
sessionId: 'session-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
final overridden = await useCase.recordCurrentSetResult(
|
|
sessionId: 'session-1',
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualScore: 5,
|
|
scoreInputModeSnapshot: ScoreInputMode.manual,
|
|
scoreLabelSnapshot: 'Points',
|
|
scoreUnitSnapshot: 'pts',
|
|
);
|
|
|
|
expect(overridden.actualScore, 5);
|
|
},
|
|
);
|
|
|
|
test('recordCurrentSetResult upserts by logical set key', () async {
|
|
final repository = _FakeActiveSessionRepository()
|
|
..session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
scoreEnabled: true,
|
|
);
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final first = await useCase.recordCurrentSetResult(
|
|
sessionId: 'session-1',
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualScore: 2,
|
|
scoreInputModeSnapshot: ScoreInputMode.manual,
|
|
scoreLabelSnapshot: 'Points',
|
|
scoreUnitSnapshot: 'pts',
|
|
);
|
|
clock.value = DateTime.utc(2026, 7, 25, 12, 1);
|
|
final second = await useCase.recordCurrentSetResult(
|
|
sessionId: 'session-1',
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualScore: 5,
|
|
scoreInputModeSnapshot: ScoreInputMode.manual,
|
|
scoreLabelSnapshot: 'Points',
|
|
scoreUnitSnapshot: 'pts',
|
|
);
|
|
|
|
expect(second.metadata.id, first.metadata.id);
|
|
expect(second.metadata.localRevision, first.metadata.localRevision + 1);
|
|
expect(repository.results, hasLength(1));
|
|
expect(repository.results.single.actualScore, 5);
|
|
});
|
|
|
|
test(
|
|
'setManualScore pushes phone corrections into live manual score state',
|
|
() async {
|
|
final repository = _FakeActiveSessionRepository()
|
|
..session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
scoreEnabled: true,
|
|
);
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final corrected = await useCase.setManualScore(
|
|
sessionId: 'session-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
value: 7.5,
|
|
);
|
|
expect(corrected.state.value, 7.5);
|
|
expect(corrected.changed, isTrue);
|
|
|
|
final incremented = await useCase.incrementManualScore(
|
|
sessionId: 'session-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
expect(incremented.state.value, 8.5);
|
|
|
|
final clamped = await useCase.setManualScore(
|
|
sessionId: 'session-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
value: -4,
|
|
);
|
|
expect(clamped.state.value, 0);
|
|
|
|
final decremented = await useCase.decrementManualScore(
|
|
sessionId: 'session-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
expect(decremented.state.value, 0);
|
|
expect(decremented.changed, isFalse);
|
|
},
|
|
);
|
|
|
|
test('WorkoutHistoryUseCases applies heart rate summary once', () async {
|
|
final repository = _FakeWorkoutHistoryRepository()
|
|
..histories.add(
|
|
WorkoutHistory(
|
|
metadata: _metadata('history-1'),
|
|
sourceActiveWorkoutSessionId: 'session-1',
|
|
nameSnapshot: 'Seance',
|
|
startedAt: DateTime.utc(2026, 7, 25, 11),
|
|
endedAt: DateTime.utc(2026, 7, 25, 12),
|
|
totalActiveMs: 3600000,
|
|
completed: true,
|
|
historySnapshotJson: '{"programs":[]}',
|
|
),
|
|
);
|
|
final useCase = WorkoutHistoryUseCases(
|
|
repository: repository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)),
|
|
);
|
|
|
|
await useCase.updateHeartRateSummary(
|
|
const WatchSensorSummary(
|
|
sessionId: 'session-1',
|
|
sampleCount: 12,
|
|
averageHeartRateBpm: 126.5,
|
|
maxHeartRateBpm: 171,
|
|
),
|
|
);
|
|
await useCase.updateHeartRateSummary(
|
|
const WatchSensorSummary(
|
|
sessionId: 'session-1',
|
|
sampleCount: 14,
|
|
averageHeartRateBpm: 130,
|
|
maxHeartRateBpm: 180,
|
|
),
|
|
);
|
|
|
|
expect(repository.histories.single.averageHeartRateBpm, 126.5);
|
|
expect(repository.histories.single.maxHeartRateBpm, 171);
|
|
});
|
|
|
|
test(
|
|
'WorkoutHistoryUseCases applies pending heart rate summary when history appears',
|
|
() async {
|
|
final repository = _FakeWorkoutHistoryRepository();
|
|
final useCase = WorkoutHistoryUseCases(
|
|
repository: repository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)),
|
|
);
|
|
|
|
await useCase.updateHeartRateSummary(
|
|
const WatchSensorSummary(
|
|
sessionId: 'session-1',
|
|
sampleCount: 12,
|
|
averageHeartRateBpm: 126.5,
|
|
maxHeartRateBpm: 171,
|
|
),
|
|
);
|
|
repository.histories.add(
|
|
WorkoutHistory(
|
|
metadata: _metadata('history-1'),
|
|
sourceActiveWorkoutSessionId: 'session-1',
|
|
nameSnapshot: 'Seance',
|
|
startedAt: DateTime.utc(2026, 7, 25, 11),
|
|
endedAt: DateTime.utc(2026, 7, 25, 12),
|
|
totalActiveMs: 3600000,
|
|
completed: true,
|
|
historySnapshotJson: '{"programs":[]}',
|
|
),
|
|
);
|
|
|
|
final histories = await useCase.listActive();
|
|
|
|
expect(histories.single.averageHeartRateBpm, 126.5);
|
|
expect(histories.single.maxHeartRateBpm, 171);
|
|
await useCase.updateHeartRateSummary(
|
|
const WatchSensorSummary(
|
|
sessionId: 'session-1',
|
|
sampleCount: 14,
|
|
averageHeartRateBpm: 130,
|
|
maxHeartRateBpm: 180,
|
|
),
|
|
);
|
|
expect(repository.histories.single.averageHeartRateBpm, 126.5);
|
|
expect(repository.histories.single.maxHeartRateBpm, 171);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'ActiveWorkoutSensorUseCases tracks live heart rate and calories',
|
|
() async {
|
|
final useCase = ActiveWorkoutSensorUseCases(
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)),
|
|
);
|
|
final updates = <ActiveWorkoutSensorState>[];
|
|
final subscription = useCase.updates.listen(updates.add);
|
|
|
|
expect(
|
|
useCase.recordHeartRateSample(
|
|
const WatchSensorSample(
|
|
sessionId: '',
|
|
recordedAtEpochMs: 0,
|
|
heartRateBpm: 120,
|
|
),
|
|
),
|
|
isNull,
|
|
);
|
|
useCase.recordHeartRateSample(
|
|
WatchSensorSample(
|
|
sessionId: 'session-1',
|
|
recordedAtEpochMs: DateTime.utc(
|
|
2026,
|
|
7,
|
|
25,
|
|
12,
|
|
).millisecondsSinceEpoch,
|
|
heartRateBpm: 120,
|
|
),
|
|
);
|
|
final state = useCase.recordHeartRateSample(
|
|
WatchSensorSample(
|
|
sessionId: 'session-1',
|
|
recordedAtEpochMs: DateTime.utc(
|
|
2026,
|
|
7,
|
|
25,
|
|
12,
|
|
30,
|
|
).millisecondsSinceEpoch,
|
|
heartRateBpm: 150,
|
|
),
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(state?.latestHeartRateBpm, 150);
|
|
expect(state?.sampleCount, 2);
|
|
expect(state?.averageHeartRateBpm, 135);
|
|
expect(state?.maxHeartRateBpm, 150);
|
|
expect(state?.estimatedCaloriesKcal, 187.5);
|
|
expect(updates, hasLength(2));
|
|
|
|
useCase.clear('session-1');
|
|
expect(useCase.current('session-1'), isNull);
|
|
await subscription.cancel();
|
|
await useCase.dispose();
|
|
},
|
|
);
|
|
|
|
test(
|
|
'ActiveWorkoutSensorUseCases tracks telemetry and ignores duplicates',
|
|
() async {
|
|
final useCase = ActiveWorkoutSensorUseCases(
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)),
|
|
);
|
|
|
|
final first = useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sampleId: 'sample-1',
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: DateTime.utc(
|
|
2026,
|
|
7,
|
|
25,
|
|
12,
|
|
).millisecondsSinceEpoch,
|
|
heartRateBpm: 120,
|
|
distanceMeters: 500,
|
|
caloriesKcal: 42,
|
|
),
|
|
);
|
|
final duplicate = useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sampleId: 'sample-1',
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: DateTime.utc(
|
|
2026,
|
|
7,
|
|
25,
|
|
12,
|
|
1,
|
|
).millisecondsSinceEpoch,
|
|
heartRateBpm: 160,
|
|
distanceMeters: 300,
|
|
caloriesKcal: 10,
|
|
),
|
|
);
|
|
final distanceOnly = useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sampleId: 'sample-2',
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: DateTime.utc(
|
|
2026,
|
|
7,
|
|
25,
|
|
12,
|
|
2,
|
|
).millisecondsSinceEpoch,
|
|
distanceMeters: 620,
|
|
caloriesKcal: 48,
|
|
),
|
|
);
|
|
|
|
expect(first?.latestHeartRateBpm, 120);
|
|
expect(duplicate, isNull);
|
|
expect(distanceOnly?.sampleCount, 2);
|
|
expect(distanceOnly?.latestHeartRateBpm, 120);
|
|
expect(distanceOnly?.minHeartRateBpm, 120);
|
|
expect(distanceOnly?.averageHeartRateBpm, 120);
|
|
expect(distanceOnly?.maxHeartRateBpm, 120);
|
|
expect(distanceOnly?.latestDistanceMeters, 620);
|
|
expect(distanceOnly?.latestCaloriesKcal, 48);
|
|
await useCase.dispose();
|
|
},
|
|
);
|
|
|
|
test(
|
|
'ActiveWorkoutSensorUseCases marks distance unavailable when the latest sample omits it',
|
|
() async {
|
|
final useCase = ActiveWorkoutSensorUseCases(
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)),
|
|
);
|
|
|
|
final withDistance = useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sampleId: 'sample-1',
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: DateTime.utc(
|
|
2026,
|
|
7,
|
|
25,
|
|
12,
|
|
).millisecondsSinceEpoch,
|
|
heartRateBpm: 120,
|
|
distanceMeters: 500,
|
|
),
|
|
);
|
|
final withoutDistance = useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sampleId: 'sample-2',
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: DateTime.utc(
|
|
2026,
|
|
7,
|
|
25,
|
|
12,
|
|
1,
|
|
).millisecondsSinceEpoch,
|
|
heartRateBpm: 124,
|
|
),
|
|
);
|
|
|
|
expect(withDistance?.latestDistanceMeters, 500);
|
|
expect(withDistance?.latestDistanceAvailable, isTrue);
|
|
expect(withoutDistance?.latestDistanceMeters, 500);
|
|
expect(withoutDistance?.latestDistanceAvailable, isFalse);
|
|
await useCase.dispose();
|
|
},
|
|
);
|
|
|
|
test(
|
|
'WorkoutTelemetryUseCases persists samples and aggregates by scope',
|
|
() async {
|
|
final repository = _FakeWorkoutTelemetryRepository();
|
|
final useCase = WorkoutTelemetryUseCases(
|
|
repository: repository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 28, 10)),
|
|
ids: _FakeIds(),
|
|
);
|
|
|
|
final ignored = await useCase.recordTelemetrySample(
|
|
const WatchTelemetrySample(
|
|
sampleId: 'sample-empty',
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: 1,
|
|
),
|
|
);
|
|
final first = await useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sampleId: 'sample-1',
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: DateTime.utc(
|
|
2026,
|
|
7,
|
|
28,
|
|
10,
|
|
).millisecondsSinceEpoch,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
stepIndex: 0,
|
|
heartRateBpm: 120,
|
|
distanceMeters: 500,
|
|
caloriesKcal: 42,
|
|
),
|
|
);
|
|
final duplicate = await useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sampleId: 'sample-2',
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: DateTime.utc(
|
|
2026,
|
|
7,
|
|
28,
|
|
10,
|
|
0,
|
|
10,
|
|
).millisecondsSinceEpoch,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
stepIndex: 0,
|
|
heartRateBpm: 160,
|
|
distanceMeters: 530,
|
|
caloriesKcal: 45,
|
|
),
|
|
);
|
|
final olderInSameBucket = await useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sampleId: 'sample-older',
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: DateTime.utc(
|
|
2026,
|
|
7,
|
|
28,
|
|
10,
|
|
0,
|
|
5,
|
|
).millisecondsSinceEpoch,
|
|
heartRateBpm: 130,
|
|
distanceMeters: 510,
|
|
caloriesKcal: 43,
|
|
),
|
|
);
|
|
final second = await useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sampleId: 'sample-3',
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: DateTime.utc(
|
|
2026,
|
|
7,
|
|
28,
|
|
10,
|
|
0,
|
|
20,
|
|
).millisecondsSinceEpoch,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
stepIndex: 0,
|
|
heartRateBpm: 150,
|
|
distanceMeters: 620,
|
|
caloriesKcal: 48,
|
|
),
|
|
);
|
|
final third = await useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sampleId: 'sample-4',
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: DateTime.utc(
|
|
2026,
|
|
7,
|
|
28,
|
|
10,
|
|
0,
|
|
40,
|
|
).millisecondsSinceEpoch,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
stepIndex: 0,
|
|
heartRateBpm: 155,
|
|
distanceMeters: 650,
|
|
caloriesKcal: 51,
|
|
),
|
|
);
|
|
|
|
expect(ignored, isEmpty);
|
|
expect(first, isEmpty);
|
|
expect(duplicate, isEmpty);
|
|
expect(olderInSameBucket, isEmpty);
|
|
expect(second.map((aggregate) => aggregate.scope), [
|
|
WorkoutTelemetryAggregateScope.session,
|
|
WorkoutTelemetryAggregateScope.exercise,
|
|
WorkoutTelemetryAggregateScope.set,
|
|
WorkoutTelemetryAggregateScope.step,
|
|
]);
|
|
expect(repository.samples, hasLength(2));
|
|
expect(repository.samples.first.heartRateBpm, 160);
|
|
|
|
final sessionAggregate = third.singleWhere(
|
|
(aggregate) =>
|
|
aggregate.scope == WorkoutTelemetryAggregateScope.session,
|
|
);
|
|
expect(sessionAggregate.sampleCount, 2);
|
|
expect(sessionAggregate.minHeartRateBpm, 150);
|
|
expect(sessionAggregate.averageHeartRateBpm, 155);
|
|
expect(sessionAggregate.maxHeartRateBpm, 160);
|
|
expect(sessionAggregate.totalDistanceMeters, 620);
|
|
expect(sessionAggregate.totalCaloriesKcal, 48);
|
|
expect(
|
|
second.any(
|
|
(aggregate) =>
|
|
aggregate.scope == WorkoutTelemetryAggregateScope.step &&
|
|
aggregate.programIndex == 0 &&
|
|
aggregate.exerciseIndex == 0 &&
|
|
aggregate.setIndex == 0 &&
|
|
aggregate.stepIndex == 0,
|
|
),
|
|
isTrue,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'WorkoutTelemetryUseCases does not advance windows while paused',
|
|
() async {
|
|
final repository = _FakeWorkoutTelemetryRepository();
|
|
final sessionRepository = _FakeActiveSessionRepository();
|
|
final startedAt = DateTime.utc(2026, 7, 28, 10);
|
|
sessionRepository.session = ActiveWorkoutSession(
|
|
metadata: _metadata('session-1'),
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: startedAt,
|
|
lastPersistedAt: startedAt,
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: '{"programs":[]}',
|
|
);
|
|
final useCase = WorkoutTelemetryUseCases(
|
|
repository: repository,
|
|
sessionRepository: sessionRepository,
|
|
clock: _FakeClock(startedAt),
|
|
ids: _FakeIds(),
|
|
);
|
|
|
|
await useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: startedAt.millisecondsSinceEpoch,
|
|
heartRateBpm: 120,
|
|
),
|
|
);
|
|
sessionRepository.session = sessionRepository.session!.pause(
|
|
startedAt.add(const Duration(seconds: 10)),
|
|
);
|
|
await useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: startedAt
|
|
.add(const Duration(minutes: 5))
|
|
.millisecondsSinceEpoch,
|
|
heartRateBpm: 150,
|
|
),
|
|
);
|
|
sessionRepository.session = sessionRepository.session!.resume(
|
|
startedAt.add(const Duration(minutes: 5)),
|
|
);
|
|
final aggregates = await useCase.recordTelemetrySample(
|
|
WatchTelemetrySample(
|
|
sessionId: 'session-1',
|
|
capturedAtEpochMs: startedAt
|
|
.add(const Duration(minutes: 5, seconds: 5))
|
|
.millisecondsSinceEpoch,
|
|
heartRateBpm: 130,
|
|
),
|
|
);
|
|
|
|
expect(aggregates, isNotEmpty);
|
|
expect(repository.samples, hasLength(1));
|
|
expect(repository.samples.single.heartRateBpm, 120);
|
|
expect(repository.samples.single.capturedAt, startedAt);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'WorkoutTelemetryUseCases reads graph samples by scope with relative cumulative metrics',
|
|
() async {
|
|
final repository = _FakeWorkoutTelemetryRepository();
|
|
final useCase = WorkoutTelemetryUseCases(
|
|
repository: repository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 28, 10)),
|
|
ids: _FakeIds(),
|
|
);
|
|
final history = WorkoutHistory(
|
|
metadata: _metadata('history-telemetry'),
|
|
nameSnapshot: 'Séance',
|
|
startedAt: DateTime.utc(2026, 7, 28, 10),
|
|
endedAt: DateTime.utc(2026, 7, 28, 11),
|
|
totalActiveMs: 3600000,
|
|
completed: true,
|
|
historySnapshotJson: jsonEncode({
|
|
'telemetrySamples': [
|
|
{
|
|
'id': 'sample-1',
|
|
'sessionId': 'session-1',
|
|
'capturedAt': DateTime.utc(2026, 7, 28, 10).toIso8601String(),
|
|
'programIndex': 0,
|
|
'exerciseIndex': 0,
|
|
'setIndex': 0,
|
|
'stepIndex': 0,
|
|
'heartRateBpm': 120,
|
|
'distanceMeters': 500,
|
|
'caloriesKcal': 42,
|
|
},
|
|
{
|
|
'id': 'sample-2',
|
|
'sessionId': 'session-1',
|
|
'capturedAt': DateTime.utc(2026, 7, 28, 10, 3).toIso8601String(),
|
|
'programIndex': 0,
|
|
'exerciseIndex': 0,
|
|
'setIndex': 0,
|
|
'stepIndex': 0,
|
|
'heartRateBpm': 150,
|
|
'distanceMeters': 620,
|
|
'caloriesKcal': 48,
|
|
},
|
|
{
|
|
'id': 'sample-other',
|
|
'sessionId': 'session-1',
|
|
'capturedAt': DateTime.utc(2026, 7, 28, 10, 4).toIso8601String(),
|
|
'programIndex': 0,
|
|
'exerciseIndex': 1,
|
|
'heartRateBpm': 90,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
final series = await useCase.readGraphSeriesForHistory(
|
|
history: history,
|
|
scope: WorkoutTelemetryAggregateScope.step,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
stepIndex: 0,
|
|
);
|
|
|
|
expect(series.points, hasLength(2));
|
|
expect(series.minHeartRateBpm, 120);
|
|
expect(series.maxHeartRateBpm, 150);
|
|
expect(series.points.first.distanceMeters, 0);
|
|
expect(series.points.last.distanceMeters, 120);
|
|
expect(series.points.last.caloriesKcal, 6);
|
|
expect(series.points.last.startsAfterGap, isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'WorkoutTelemetryUseCases lists selected instances and real scope markers',
|
|
() async {
|
|
final repository = _FakeWorkoutTelemetryRepository();
|
|
final useCase = WorkoutTelemetryUseCases(
|
|
repository: repository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 28, 10)),
|
|
ids: _FakeIds(),
|
|
);
|
|
final history = WorkoutHistory(
|
|
metadata: _metadata('history-telemetry-markers'),
|
|
nameSnapshot: 'Séance',
|
|
startedAt: DateTime.utc(2026, 7, 28, 10),
|
|
endedAt: DateTime.utc(2026, 7, 28, 11),
|
|
totalActiveMs: 3600000,
|
|
completed: true,
|
|
historySnapshotJson: jsonEncode({
|
|
'telemetrySamples': [
|
|
{
|
|
'id': 'sample-set-1-a',
|
|
'sessionId': 'session-1',
|
|
'capturedAt': DateTime.utc(2026, 7, 28, 10).toIso8601String(),
|
|
'programIndex': 0,
|
|
'exerciseIndex': 0,
|
|
'setIndex': 0,
|
|
'stepIndex': 0,
|
|
'heartRateBpm': 120,
|
|
},
|
|
{
|
|
'id': 'sample-set-1-b',
|
|
'sessionId': 'session-1',
|
|
'capturedAt': DateTime.utc(
|
|
2026,
|
|
7,
|
|
28,
|
|
10,
|
|
0,
|
|
30,
|
|
).toIso8601String(),
|
|
'programIndex': 0,
|
|
'exerciseIndex': 0,
|
|
'setIndex': 0,
|
|
'stepIndex': 1,
|
|
'heartRateBpm': 130,
|
|
},
|
|
{
|
|
'id': 'sample-set-2-a',
|
|
'sessionId': 'session-1',
|
|
'capturedAt': DateTime.utc(2026, 7, 28, 10, 1).toIso8601String(),
|
|
'programIndex': 0,
|
|
'exerciseIndex': 0,
|
|
'setIndex': 1,
|
|
'stepIndex': 0,
|
|
'heartRateBpm': 140,
|
|
},
|
|
{
|
|
'id': 'sample-set-2-b',
|
|
'sessionId': 'session-1',
|
|
'capturedAt': DateTime.utc(
|
|
2026,
|
|
7,
|
|
28,
|
|
10,
|
|
1,
|
|
30,
|
|
).toIso8601String(),
|
|
'programIndex': 0,
|
|
'exerciseIndex': 0,
|
|
'setIndex': 1,
|
|
'stepIndex': 1,
|
|
'heartRateBpm': 150,
|
|
},
|
|
{
|
|
'id': 'sample-exercise-2',
|
|
'sessionId': 'session-1',
|
|
'capturedAt': DateTime.utc(2026, 7, 28, 10, 2).toIso8601String(),
|
|
'programIndex': 0,
|
|
'exerciseIndex': 1,
|
|
'setIndex': 0,
|
|
'stepIndex': 0,
|
|
'heartRateBpm': 110,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
final exerciseInstances = await useCase.listScopeInstancesForHistory(
|
|
history: history,
|
|
scope: WorkoutTelemetryAggregateScope.exercise,
|
|
);
|
|
final setInstances = await useCase.listScopeInstancesForHistory(
|
|
history: history,
|
|
scope: WorkoutTelemetryAggregateScope.set,
|
|
);
|
|
final markers = await useCase.readScopeMarkersForHistory(
|
|
history: history,
|
|
scope: WorkoutTelemetryAggregateScope.exercise,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
);
|
|
|
|
expect(exerciseInstances, hasLength(2));
|
|
expect(exerciseInstances.first.exerciseIndex, 0);
|
|
expect(exerciseInstances.last.exerciseIndex, 1);
|
|
expect(setInstances, hasLength(3));
|
|
expect(setInstances.take(2).map((instance) => instance.setIndex), [0, 1]);
|
|
expect(markers.map((marker) => marker.elapsedMs), [
|
|
0,
|
|
30000,
|
|
60000,
|
|
90000,
|
|
]);
|
|
expect(markers.map((marker) => marker.boundary), [
|
|
ScopeMarkerBoundary.start,
|
|
ScopeMarkerBoundary.end,
|
|
ScopeMarkerBoundary.start,
|
|
ScopeMarkerBoundary.end,
|
|
]);
|
|
expect(markers.map((marker) => marker.label), [
|
|
'Déb. série 1',
|
|
'Fin série 1',
|
|
'Déb. série 2',
|
|
'Fin série 2',
|
|
]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
|
|
() async {
|
|
final repository = _FakeWorkoutHistoryRepository()
|
|
..histories.add(
|
|
WorkoutHistory(
|
|
metadata: _metadata('history-1'),
|
|
sourceActiveWorkoutSessionId: 'session-1',
|
|
nameSnapshot: 'Seance',
|
|
startedAt: DateTime.utc(2026, 7, 25, 11),
|
|
endedAt: DateTime.utc(2026, 7, 25, 12),
|
|
totalActiveMs: 3600000,
|
|
completed: true,
|
|
historySnapshotJson: '{"programs":[]}',
|
|
),
|
|
);
|
|
final useCase = WorkoutHistoryUseCases(
|
|
repository: repository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)),
|
|
);
|
|
|
|
await useCase.updateHeartRateSummary(
|
|
const WatchSensorSummary(
|
|
sessionId: 'session-1',
|
|
sampleCount: 2,
|
|
averageHeartRateBpm: 126.5,
|
|
maxHeartRateBpm: 171,
|
|
),
|
|
);
|
|
|
|
expect(repository.histories.single.averageHeartRateBpm, isNull);
|
|
expect(repository.histories.single.maxHeartRateBpm, isNull);
|
|
},
|
|
);
|
|
|
|
test('Tags are normalized and validated on taggable entities', () {
|
|
final exercise = Exercise(
|
|
metadata: _metadata('exercise-tags'),
|
|
name: 'Shoot',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
tags: const [' Match Prep ', 'INTENSE'],
|
|
);
|
|
|
|
expect(exercise.tags, ['match prep', 'intense']);
|
|
expect(
|
|
() => Program(
|
|
metadata: _metadata('program-tags'),
|
|
name: 'Program',
|
|
defaultRestSeconds: 30,
|
|
tags: const ['match', 'MATCH'],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => WorkoutTemplate(
|
|
metadata: _metadata('template-tags'),
|
|
name: 'Template',
|
|
tags: List.generate(9, (index) => 'tag$index'),
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
expect(
|
|
() => Exercise(
|
|
metadata: _metadata('exercise-long-tag'),
|
|
name: 'Shoot',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
tags: const ['tag beaucoup trop long pour le mvp'],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test('Tag helpers filter by all selected tags and sort suggestions', () {
|
|
final programs = [
|
|
Program(
|
|
metadata: _metadata('program-1'),
|
|
name: 'A',
|
|
defaultRestSeconds: 30,
|
|
tags: const ['match', 'intense'],
|
|
),
|
|
Program(
|
|
metadata: _metadata('program-2'),
|
|
name: 'B',
|
|
defaultRestSeconds: 30,
|
|
tags: const ['match', 'extérieur'],
|
|
),
|
|
Program(
|
|
metadata: _metadata('program-3'),
|
|
name: 'C',
|
|
defaultRestSeconds: 30,
|
|
tags: const ['intense'],
|
|
),
|
|
];
|
|
|
|
final filtered = filterByRequiredTags(programs, {
|
|
' MATCH ',
|
|
'intense',
|
|
}, (program) => program.tags);
|
|
final suggestions = tagSuggestionsFor(programs, (program) => program.tags);
|
|
|
|
expect(filtered.map((program) => program.metadata.id), ['program-1']);
|
|
expect(suggestions.map((usage) => '${usage.tag}:${usage.count}'), [
|
|
'intense:2',
|
|
'match:2',
|
|
'extérieur:1',
|
|
]);
|
|
});
|
|
|
|
test('Local backup codec writes v1 envelope and preview counts', () {
|
|
final codec = LocalBackupCodec();
|
|
final snapshot = _backupSnapshot(
|
|
exportedAt: DateTime.utc(2026, 7, 22, 12),
|
|
exercises: [
|
|
_backupResource(
|
|
id: 'exercise-1',
|
|
updatedAt: DateTime.utc(2026, 7, 22, 10),
|
|
payload: {
|
|
'id': 'exercise-1',
|
|
'metadata': _backupMetadata(
|
|
'exercise-1',
|
|
DateTime.utc(2026, 7, 22, 10),
|
|
),
|
|
'name': 'Shoot',
|
|
},
|
|
),
|
|
],
|
|
mediaFiles: const [
|
|
EmbeddedBackupMediaFile(
|
|
mediaAssetId: 'media-1',
|
|
role: 'original',
|
|
fileName: 'media-1.png',
|
|
mimeType: 'image/png',
|
|
sizeBytes: 4,
|
|
sha256:
|
|
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
|
|
base64: 'dGVzdA==',
|
|
),
|
|
],
|
|
);
|
|
|
|
final decodedJson = jsonDecode(utf8.decode(codec.encode(snapshot))) as Map;
|
|
final preview = codec.preview(codec.encode(snapshot));
|
|
|
|
expect(decodedJson['kind'], 'gametime.localBackup');
|
|
expect(decodedJson['formatVersion'], 1);
|
|
expect(preview.counts.exercises, 1);
|
|
expect(preview.counts.embeddedMediaFiles, 1);
|
|
expect(preview.hasEmbeddedMedia, isTrue);
|
|
expect(preview.missingMediaCount, 0);
|
|
});
|
|
|
|
test('Local backup codec rejects invalid and future files', () {
|
|
final codec = LocalBackupCodec();
|
|
|
|
expect(
|
|
() => codec.decode(Uint8List.fromList(utf8.encode('not json'))),
|
|
throwsA(
|
|
isA<LocalBackupException>().having(
|
|
(error) => error.error,
|
|
'error',
|
|
LocalBackupValidationError.corrupted,
|
|
),
|
|
),
|
|
);
|
|
final future = utf8.encode(
|
|
jsonEncode({
|
|
'kind': 'gametime.localBackup',
|
|
'formatVersion': 2,
|
|
'minSupportedFormatVersion': 2,
|
|
'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(),
|
|
'data': {
|
|
'mediaAssets': const [],
|
|
'exercises': const [],
|
|
'programs': const [],
|
|
'workoutTemplates': const [],
|
|
'workoutHistories': const [],
|
|
},
|
|
'mediaFiles': const [],
|
|
}),
|
|
);
|
|
expect(
|
|
() => codec.decode(Uint8List.fromList(future)),
|
|
throwsA(
|
|
isA<LocalBackupException>().having(
|
|
(error) => error.error,
|
|
'error',
|
|
LocalBackupValidationError.newerVersion,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('Data export use case embeds media and names gametime file', () async {
|
|
final repository = _FakeLocalDataBackupRepository(
|
|
snapshot: _backupSnapshot(
|
|
exportedAt: DateTime.utc(2026, 7, 22),
|
|
mediaAssets: [
|
|
_backupResource(
|
|
id: 'media-1',
|
|
updatedAt: DateTime.utc(2026, 7, 22, 10),
|
|
payload: {
|
|
'id': 'media-1',
|
|
'metadata': _backupMetadata(
|
|
'media-1',
|
|
DateTime.utc(2026, 7, 22, 10),
|
|
),
|
|
'localUri': 'file:///tmp/media-1.png',
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final mediaStore = _FakeLocalBackupMediaStore(
|
|
files: const [
|
|
EmbeddedBackupMediaFile(
|
|
mediaAssetId: 'media-1',
|
|
role: 'original',
|
|
fileName: 'media-1.png',
|
|
mimeType: 'image/png',
|
|
sizeBytes: 4,
|
|
sha256:
|
|
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
|
|
base64: 'dGVzdA==',
|
|
),
|
|
],
|
|
);
|
|
final useCase = DataExportUseCase(
|
|
repository: repository,
|
|
mediaStore: mediaStore,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
|
|
);
|
|
|
|
final document = await useCase.exportAll();
|
|
final preview = const LocalBackupCodec().preview(document.bytes);
|
|
|
|
expect(document.fileName, 'gametime-sauvegarde-2026-07-22.gametime');
|
|
expect(mediaStore.requestedMediaIds, ['media-1']);
|
|
expect(preview.counts.mediaAssets, 1);
|
|
expect(preview.counts.embeddedMediaFiles, 1);
|
|
});
|
|
|
|
test('Data import use case blocks when active workout is open', () async {
|
|
final repository = _FakeLocalDataBackupRepository(hasOpenSession: true);
|
|
final useCase = DataImportUseCase(
|
|
repository: repository,
|
|
mediaStore: _FakeLocalBackupMediaStore(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
|
|
);
|
|
final bytes = const LocalBackupCodec().encode(_backupSnapshot());
|
|
|
|
await expectLater(
|
|
useCase.importFrom(bytes, mode: LocalBackupImportMode.merge),
|
|
throwsA(
|
|
isA<LocalBackupException>().having(
|
|
(error) => error.error,
|
|
'error',
|
|
LocalBackupValidationError.activeWorkoutInProgress,
|
|
),
|
|
),
|
|
);
|
|
expect(repository.appliedModes, isEmpty);
|
|
});
|
|
|
|
test('Data import use case restores media and applies selected mode', () async {
|
|
final repository = _FakeLocalDataBackupRepository();
|
|
final mediaStore = _FakeLocalBackupMediaStore(
|
|
restored: const {
|
|
'media-1': RestoredMediaFile(
|
|
mediaAssetId: 'media-1',
|
|
localUri: 'file:///restored/media-1.png',
|
|
sizeBytes: 4,
|
|
),
|
|
},
|
|
);
|
|
final useCase = DataImportUseCase(
|
|
repository: repository,
|
|
mediaStore: mediaStore,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
|
|
);
|
|
final bytes = const LocalBackupCodec().encode(
|
|
_backupSnapshot(
|
|
mediaAssets: [
|
|
_backupResource(
|
|
id: 'media-1',
|
|
updatedAt: DateTime.utc(2026, 7, 22, 10),
|
|
payload: {
|
|
'id': 'media-1',
|
|
'metadata': _backupMetadata(
|
|
'media-1',
|
|
DateTime.utc(2026, 7, 22, 10),
|
|
),
|
|
'localUri': 'file:///old/media-1.png',
|
|
},
|
|
),
|
|
],
|
|
mediaFiles: const [
|
|
EmbeddedBackupMediaFile(
|
|
mediaAssetId: 'media-1',
|
|
role: 'original',
|
|
fileName: 'media-1.png',
|
|
mimeType: 'image/png',
|
|
sizeBytes: 4,
|
|
sha256:
|
|
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
|
|
base64: 'dGVzdA==',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final result = await useCase.importFrom(
|
|
bytes,
|
|
mode: LocalBackupImportMode.replaceAll,
|
|
);
|
|
|
|
expect(result.insertedCount, 0);
|
|
expect(repository.appliedModes, [LocalBackupImportMode.replaceAll]);
|
|
expect(
|
|
repository.appliedSnapshots.single.mediaAssets.single.payload['localUri'],
|
|
'file:///restored/media-1.png',
|
|
);
|
|
});
|
|
|
|
test('Local backup codec rejects wrong kind and incompatible format', () {
|
|
final codec = LocalBackupCodec();
|
|
|
|
final wrongKind = utf8.encode(
|
|
jsonEncode({
|
|
'kind': 'other.app.backup',
|
|
'formatVersion': 1,
|
|
'minSupportedFormatVersion': 1,
|
|
'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(),
|
|
'data': {
|
|
'mediaAssets': const [],
|
|
'exercises': const [],
|
|
'programs': const [],
|
|
'workoutTemplates': const [],
|
|
'workoutHistories': const [],
|
|
},
|
|
'mediaFiles': const [],
|
|
}),
|
|
);
|
|
expect(
|
|
() => codec.decode(Uint8List.fromList(wrongKind)),
|
|
throwsA(
|
|
isA<LocalBackupException>().having(
|
|
(error) => error.error,
|
|
'error',
|
|
LocalBackupValidationError.invalidFile,
|
|
),
|
|
),
|
|
);
|
|
|
|
final missingVersion = utf8.encode(
|
|
jsonEncode({
|
|
'kind': 'gametime.localBackup',
|
|
'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(),
|
|
'data': {
|
|
'mediaAssets': const [],
|
|
'exercises': const [],
|
|
'programs': const [],
|
|
'workoutTemplates': const [],
|
|
'workoutHistories': const [],
|
|
},
|
|
'mediaFiles': const [],
|
|
}),
|
|
);
|
|
expect(
|
|
() => codec.decode(Uint8List.fromList(missingVersion)),
|
|
throwsA(
|
|
isA<LocalBackupException>().having(
|
|
(error) => error.error,
|
|
'error',
|
|
LocalBackupValidationError.incompatibleFormat,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('Local backup codec rejects malformed data collections', () {
|
|
final codec = LocalBackupCodec();
|
|
|
|
final malformedData = utf8.encode(
|
|
jsonEncode({
|
|
'kind': 'gametime.localBackup',
|
|
'formatVersion': 1,
|
|
'minSupportedFormatVersion': 1,
|
|
'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(),
|
|
'data': 'not-a-map',
|
|
'mediaFiles': const [],
|
|
}),
|
|
);
|
|
expect(
|
|
() => codec.decode(Uint8List.fromList(malformedData)),
|
|
throwsA(
|
|
isA<LocalBackupException>().having(
|
|
(error) => error.error,
|
|
'error',
|
|
LocalBackupValidationError.corrupted,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Data import use case wraps unexpected repository failure as importFailedNoMutation',
|
|
() async {
|
|
final repository = _ThrowingLocalDataBackupRepository();
|
|
final useCase = DataImportUseCase(
|
|
repository: repository,
|
|
mediaStore: _FakeLocalBackupMediaStore(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
|
|
);
|
|
final bytes = const LocalBackupCodec().encode(_backupSnapshot());
|
|
|
|
await expectLater(
|
|
useCase.importFrom(bytes, mode: LocalBackupImportMode.merge),
|
|
throwsA(
|
|
isA<LocalBackupException>().having(
|
|
(error) => error.error,
|
|
'error',
|
|
LocalBackupValidationError.importFailedNoMutation,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'Data import use case counts media assets not successfully restored',
|
|
() async {
|
|
final repository = _FakeLocalDataBackupRepository();
|
|
final mediaStore = _FakeLocalBackupMediaStore(
|
|
restored: const {
|
|
'media-restored': RestoredMediaFile(
|
|
mediaAssetId: 'media-restored',
|
|
localUri: 'file:///restored/media-restored.png',
|
|
sizeBytes: 4,
|
|
),
|
|
},
|
|
);
|
|
final useCase = DataImportUseCase(
|
|
repository: repository,
|
|
mediaStore: mediaStore,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
|
|
);
|
|
final bytes = const LocalBackupCodec().encode(
|
|
_backupSnapshot(
|
|
mediaAssets: [
|
|
_backupResource(
|
|
id: 'media-restored',
|
|
updatedAt: DateTime.utc(2026, 7, 22, 10),
|
|
payload: {
|
|
'id': 'media-restored',
|
|
'metadata': _backupMetadata(
|
|
'media-restored',
|
|
DateTime.utc(2026, 7, 22, 10),
|
|
),
|
|
'localUri': 'file:///old/media-restored.png',
|
|
},
|
|
),
|
|
_backupResource(
|
|
id: 'media-corrupted',
|
|
updatedAt: DateTime.utc(2026, 7, 22, 10),
|
|
payload: {
|
|
'id': 'media-corrupted',
|
|
'metadata': _backupMetadata(
|
|
'media-corrupted',
|
|
DateTime.utc(2026, 7, 22, 10),
|
|
),
|
|
'localUri': 'file:///old/media-corrupted.png',
|
|
},
|
|
),
|
|
],
|
|
mediaFiles: const [
|
|
EmbeddedBackupMediaFile(
|
|
mediaAssetId: 'media-restored',
|
|
role: 'original',
|
|
fileName: 'media-restored.png',
|
|
mimeType: 'image/png',
|
|
sizeBytes: 4,
|
|
sha256:
|
|
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
|
|
base64: 'dGVzdA==',
|
|
),
|
|
EmbeddedBackupMediaFile(
|
|
mediaAssetId: 'media-corrupted',
|
|
role: 'original',
|
|
fileName: 'media-corrupted.png',
|
|
mimeType: 'image/png',
|
|
sizeBytes: 4,
|
|
sha256: 'checksum-mismatch',
|
|
base64: 'dGVzdA==',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final result = await useCase.importFrom(
|
|
bytes,
|
|
mode: LocalBackupImportMode.merge,
|
|
);
|
|
|
|
expect(result.missingMediaCount, 1);
|
|
},
|
|
);
|
|
|
|
test('Program use case duplicates deeply with copy name conflicts', () async {
|
|
final programRepository = _FakeProgramRepository();
|
|
final templateRepository = _FakeWorkoutTemplateRepository();
|
|
final source = Program(
|
|
metadata: _metadata('program-source'),
|
|
name: 'Programme tirs',
|
|
defaultRestSeconds: 45,
|
|
isExample: true,
|
|
tags: const ['match', 'extérieur'],
|
|
exercises: [
|
|
_programExercise(
|
|
id: 'program-exercise-source',
|
|
programId: 'program-source',
|
|
sourceExerciseId: 'exercise-source',
|
|
position: 0,
|
|
),
|
|
],
|
|
);
|
|
programRepository.programs.addAll([
|
|
source,
|
|
Program(
|
|
metadata: _metadata('program-copy-1'),
|
|
name: 'Copie de Programme tirs',
|
|
defaultRestSeconds: 45,
|
|
),
|
|
]);
|
|
final useCase = ProgramUseCases(
|
|
programRepository: programRepository,
|
|
exerciseRepository: _FakeExerciseRepository(),
|
|
templateRepository: templateRepository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final copy = await useCase.duplicate('program-source');
|
|
|
|
expect(copy.metadata.id, isNot('program-source'));
|
|
expect(copy.name, 'Copie 2 de Programme tirs');
|
|
expect(copy.isExample, isFalse);
|
|
expect(copy.tags, source.tags);
|
|
expect(copy.exercises.single.metadata.id, isNot('program-exercise-source'));
|
|
expect(copy.exercises.single.programId, copy.metadata.id);
|
|
expect(copy.exercises.single.sourceExerciseId, 'exercise-source');
|
|
expect(copy.exercises.single.targetReps, 10);
|
|
expect(
|
|
programRepository.programs
|
|
.singleWhere((program) => program.metadata.id == 'program-source')
|
|
.exercises
|
|
.single
|
|
.metadata
|
|
.id,
|
|
'program-exercise-source',
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Workout template use case duplicates deeply and remaps overrides',
|
|
() async {
|
|
final templateRepository = _FakeWorkoutTemplateRepository();
|
|
final source = WorkoutTemplate(
|
|
metadata: _metadata('template-source'),
|
|
name: 'Prépa match',
|
|
lastStartedAt: DateTime.utc(2026, 7, 20, 12),
|
|
isExample: true,
|
|
tags: const ['match', 'intense'],
|
|
programs: [
|
|
_templateProgram(
|
|
id: 'template-program-source',
|
|
workoutTemplateId: 'template-source',
|
|
sourceProgramId: 'program-source',
|
|
position: 0,
|
|
),
|
|
],
|
|
overrides: [
|
|
WorkoutTemplateExerciseOverride(
|
|
metadata: _metadata('override-source'),
|
|
workoutTemplateProgramId: 'template-program-source',
|
|
snapshotProgramExerciseId: 'program-exercise-source',
|
|
setsCountOverride: 4,
|
|
targetRepsOverride: 12,
|
|
autoStartNextTimedStepOverride: false,
|
|
),
|
|
],
|
|
);
|
|
templateRepository.templates.addAll([
|
|
source,
|
|
WorkoutTemplate(
|
|
metadata: _metadata('template-copy-1'),
|
|
name: 'Copie de Prépa match',
|
|
),
|
|
]);
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: templateRepository,
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final copy = await useCase.duplicate('template-source');
|
|
|
|
expect(copy.metadata.id, isNot('template-source'));
|
|
expect(copy.name, 'Copie 2 de Prépa match');
|
|
expect(copy.lastStartedAt, isNull);
|
|
expect(copy.isExample, isFalse);
|
|
expect(copy.tags, source.tags);
|
|
expect(
|
|
copy.programs.single.metadata.id,
|
|
isNot('template-program-source'),
|
|
);
|
|
expect(copy.programs.single.workoutTemplateId, copy.metadata.id);
|
|
expect(copy.overrides.single.metadata.id, isNot('override-source'));
|
|
expect(
|
|
copy.overrides.single.workoutTemplateProgramId,
|
|
copy.programs.single.metadata.id,
|
|
);
|
|
expect(
|
|
templateRepository.templates
|
|
.singleWhere(
|
|
(template) => template.metadata.id == 'template-source',
|
|
)
|
|
.programs
|
|
.single
|
|
.metadata
|
|
.id,
|
|
'template-program-source',
|
|
);
|
|
expect(
|
|
copy.overrides.single.snapshotProgramExerciseId,
|
|
'program-exercise-source',
|
|
);
|
|
expect(copy.overrides.single.setsCountOverride, 4);
|
|
expect(copy.overrides.single.targetRepsOverride, 12);
|
|
expect(copy.overrides.single.autoStartNextTimedStepOverride, isFalse);
|
|
},
|
|
);
|
|
|
|
test('Progression stats use case resolves four week overview', () async {
|
|
final repository = _FakeProgressionStatsRepository();
|
|
final now = DateTime.utc(2026, 7, 22, 12);
|
|
repository.globalStats = ProgressionGlobalStatsData(
|
|
completedSessionCount: 2,
|
|
totalActiveMs: 180000,
|
|
activeWeekStarts: [DateTime(2026, 7, 13), DateTime(2026, 7, 20)],
|
|
hasAnyCompletedHistory: true,
|
|
);
|
|
repository.exerciseOptions = [
|
|
ProgressionExerciseOptionData(
|
|
exerciseKey: 'exercise-1',
|
|
nameSnapshot: 'Shoot',
|
|
isArchived: false,
|
|
lastPerformedAt: now,
|
|
),
|
|
];
|
|
repository.measureOptions['exercise-1'] = const [
|
|
ProgressionMeasureOptionData(
|
|
measure: ProgressionMeasure.manualScore,
|
|
label: 'Score',
|
|
scoreLabel: 'Réussites',
|
|
scoreUnit: 'paniers',
|
|
lowerIsBetter: false,
|
|
),
|
|
];
|
|
|
|
final overview = await ProgressionStatsUseCase(
|
|
repository: repository,
|
|
clock: _FakeClock(now),
|
|
).getOverview(ProgressionPeriod.fourWeeks);
|
|
|
|
expect(repository.lastGlobalRange!.startedAt, DateTime(2026, 6, 29));
|
|
expect(repository.lastGlobalRange!.endedAt, now);
|
|
expect(overview.totalWeekCount, 4);
|
|
expect(overview.activeWeekCount, 2);
|
|
expect(overview.rangeLabelKind, ProgressionRangeLabelKind.sinceDate);
|
|
expect(
|
|
overview.exerciseOptions.single.measures.single.scoreUnit,
|
|
'paniers',
|
|
);
|
|
});
|
|
|
|
test('Progression stats use case resolves all-time empty overview', () async {
|
|
final repository = _FakeProgressionStatsRepository();
|
|
repository.globalStats = const ProgressionGlobalStatsData(
|
|
completedSessionCount: 0,
|
|
totalActiveMs: 0,
|
|
activeWeekStarts: [],
|
|
hasAnyCompletedHistory: false,
|
|
);
|
|
|
|
final overview = await ProgressionStatsUseCase(
|
|
repository: repository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
|
|
).getOverview(ProgressionPeriod.all);
|
|
|
|
expect(repository.lastGlobalRange!.startedAt, isNull);
|
|
expect(overview.totalWeekCount, isNull);
|
|
expect(overview.rangeLabelKind, ProgressionRangeLabelKind.none);
|
|
expect(overview.hasAnyCompletedHistory, isFalse);
|
|
});
|
|
|
|
test(
|
|
'Progression stats use case maps series summaries and empty period',
|
|
() async {
|
|
final repository = _FakeProgressionStatsRepository();
|
|
final now = DateTime.utc(2026, 7, 22, 12);
|
|
repository.series = ProgressionExerciseSeriesData(
|
|
exerciseKey: 'exercise-1',
|
|
exerciseNameSnapshot: 'Shoot',
|
|
measure: const ProgressionMeasureOptionData(
|
|
measure: ProgressionMeasure.reps,
|
|
label: 'Répétitions',
|
|
lowerIsBetter: false,
|
|
),
|
|
points: [
|
|
ProgressionPointData(
|
|
workoutHistoryId: 'history-1',
|
|
workoutNameSnapshot: 'Séance 1',
|
|
startedAt: now.subtract(const Duration(days: 1)),
|
|
value: 12,
|
|
rawValue: 12,
|
|
),
|
|
ProgressionPointData(
|
|
workoutHistoryId: 'history-2',
|
|
workoutNameSnapshot: 'Séance 2',
|
|
startedAt: now,
|
|
value: 15,
|
|
rawValue: 15,
|
|
),
|
|
],
|
|
hasAnyAllTimeData: true,
|
|
hasAnyCompletedExerciseResult: true,
|
|
);
|
|
|
|
final series =
|
|
await ProgressionStatsUseCase(
|
|
repository: repository,
|
|
clock: _FakeClock(now),
|
|
).getExerciseSeries(
|
|
period: ProgressionPeriod.fourWeeks,
|
|
exerciseKey: 'exercise-1',
|
|
measure: ProgressionMeasure.reps,
|
|
);
|
|
|
|
expect(series.state, ProgressionSeriesState.ready);
|
|
expect(series.summary.kind, ProgressionSeriesSummaryKind.totals);
|
|
expect(series.summary.recentTotal, 27);
|
|
expect(series.summary.lastSessionTotal, 15);
|
|
|
|
repository.series = const ProgressionExerciseSeriesData(
|
|
exerciseKey: 'exercise-1',
|
|
exerciseNameSnapshot: 'Shoot',
|
|
measure: ProgressionMeasureOptionData(
|
|
measure: ProgressionMeasure.manualScore,
|
|
label: 'Score',
|
|
lowerIsBetter: false,
|
|
),
|
|
points: [],
|
|
hasAnyAllTimeData: true,
|
|
hasAnyCompletedExerciseResult: true,
|
|
);
|
|
final empty =
|
|
await ProgressionStatsUseCase(
|
|
repository: repository,
|
|
clock: _FakeClock(now),
|
|
).getExerciseSeries(
|
|
period: ProgressionPeriod.fourWeeks,
|
|
exerciseKey: 'exercise-1',
|
|
measure: ProgressionMeasure.manualScore,
|
|
);
|
|
|
|
expect(empty.state, ProgressionSeriesState.emptyButHasAllTimeData);
|
|
expect(empty.summary.kind, ProgressionSeriesSummaryKind.none);
|
|
|
|
repository.series = const ProgressionExerciseSeriesData(
|
|
exerciseKey: 'exercise-1',
|
|
exerciseNameSnapshot: 'Shoot',
|
|
measure: ProgressionMeasureOptionData(
|
|
measure: ProgressionMeasure.manualScore,
|
|
label: 'Score',
|
|
lowerIsBetter: false,
|
|
),
|
|
points: [],
|
|
hasAnyAllTimeData: false,
|
|
hasAnyCompletedExerciseResult: true,
|
|
);
|
|
final noGraphable =
|
|
await ProgressionStatsUseCase(
|
|
repository: repository,
|
|
clock: _FakeClock(now),
|
|
).getExerciseSeries(
|
|
period: ProgressionPeriod.all,
|
|
exerciseKey: 'exercise-1',
|
|
measure: ProgressionMeasure.manualScore,
|
|
);
|
|
|
|
expect(noGraphable.state, ProgressionSeriesState.noGraphableMeasure);
|
|
},
|
|
);
|
|
}
|
|
|
|
ExerciseStep _step({
|
|
String id = 'step-1',
|
|
int position = 0,
|
|
String name = 'Phase',
|
|
ExerciseStepType type = ExerciseStepType.time,
|
|
int defaultTargetValue = 30,
|
|
bool hasScore = false,
|
|
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
|
String? scoreLabel,
|
|
String? scoreUnit,
|
|
double? defaultTargetScore,
|
|
int? defaultTargetScoreTimeMs,
|
|
bool linkedToSeriesScore = false,
|
|
}) {
|
|
return ExerciseStep(
|
|
id: id,
|
|
position: position,
|
|
name: name,
|
|
type: type,
|
|
defaultTargetValue: defaultTargetValue,
|
|
hasScore: hasScore,
|
|
scoreInputMode: scoreInputMode,
|
|
scoreLabel: scoreLabel,
|
|
scoreUnit: scoreUnit,
|
|
defaultTargetScore: defaultTargetScore,
|
|
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
|
linkedToSeriesScore: linkedToSeriesScore,
|
|
);
|
|
}
|
|
|
|
EntityMetadata _metadata(String id) {
|
|
return EntityMetadata(
|
|
id: id,
|
|
createdAt: DateTime.utc(2026, 7, 17),
|
|
updatedAt: DateTime.utc(2026, 7, 17),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
}
|
|
|
|
final class _FakeClock implements Clock {
|
|
_FakeClock(this.value);
|
|
|
|
DateTime value;
|
|
|
|
@override
|
|
DateTime now() => value;
|
|
}
|
|
|
|
final class _FakeIds implements IdGenerator {
|
|
var _next = 0;
|
|
|
|
@override
|
|
String newId() {
|
|
_next += 1;
|
|
return 'id-$_next';
|
|
}
|
|
}
|
|
|
|
final class _FakeProgressionStatsRepository
|
|
implements ProgressionStatsRepository {
|
|
ProgressionDateRange? lastGlobalRange;
|
|
ProgressionDateRange? lastSeriesRange;
|
|
ProgressionGlobalStatsData globalStats = const ProgressionGlobalStatsData(
|
|
completedSessionCount: 0,
|
|
totalActiveMs: 0,
|
|
activeWeekStarts: [],
|
|
hasAnyCompletedHistory: false,
|
|
);
|
|
List<ProgressionExerciseOptionData> exerciseOptions = const [];
|
|
final measureOptions = <String, List<ProgressionMeasureOptionData>>{};
|
|
ProgressionExerciseSeriesData series = const ProgressionExerciseSeriesData(
|
|
exerciseKey: 'exercise-1',
|
|
exerciseNameSnapshot: 'Exercise',
|
|
measure: ProgressionMeasureOptionData(
|
|
measure: ProgressionMeasure.manualScore,
|
|
label: 'Score',
|
|
lowerIsBetter: false,
|
|
),
|
|
points: [],
|
|
hasAnyAllTimeData: false,
|
|
hasAnyCompletedExerciseResult: false,
|
|
);
|
|
|
|
@override
|
|
Future<ProgressionGlobalStatsData> readGlobalStats(
|
|
ProgressionDateRange range,
|
|
) async {
|
|
lastGlobalRange = range;
|
|
return globalStats;
|
|
}
|
|
|
|
@override
|
|
Future<List<ProgressionExerciseOptionData>> listExerciseOptions(
|
|
ProgressionDateRange range,
|
|
) async {
|
|
return exerciseOptions;
|
|
}
|
|
|
|
@override
|
|
Future<List<ProgressionMeasureOptionData>> listMeasureOptions({
|
|
required ProgressionDateRange range,
|
|
required String exerciseKey,
|
|
}) async {
|
|
return measureOptions[exerciseKey] ?? const [];
|
|
}
|
|
|
|
@override
|
|
Future<ProgressionExerciseSeriesData> readExerciseSeries({
|
|
required ProgressionDateRange range,
|
|
required String exerciseKey,
|
|
required ProgressionMeasure measure,
|
|
}) async {
|
|
lastSeriesRange = range;
|
|
return series;
|
|
}
|
|
}
|
|
|
|
AuthUseCases _authUseCase({
|
|
_FakeAuthTokenStore? tokenStore,
|
|
_FakeOnlineAccountRepository? accountRepository,
|
|
_FakeRemoteAuthApi? remoteAuthApi,
|
|
_FakeClock? clock,
|
|
}) {
|
|
return AuthUseCases(
|
|
tokenStore: tokenStore ?? _FakeAuthTokenStore(),
|
|
accountRepository: accountRepository ?? _FakeOnlineAccountRepository(),
|
|
remoteAuthApi: remoteAuthApi ?? _FakeRemoteAuthApi(),
|
|
clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
);
|
|
}
|
|
|
|
final class _FakeAuthTokenStore implements AuthTokenStore {
|
|
String? token;
|
|
DateTime? expiresAt;
|
|
|
|
@override
|
|
Future<void> clearToken() async {
|
|
token = null;
|
|
expiresAt = null;
|
|
}
|
|
|
|
@override
|
|
Future<String?> readToken() async => token;
|
|
|
|
@override
|
|
Future<void> saveToken(String token, DateTime expiresAt) async {
|
|
this.token = token;
|
|
this.expiresAt = expiresAt;
|
|
}
|
|
}
|
|
|
|
final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
|
|
UserAccountSession? session;
|
|
var clearCalls = 0;
|
|
|
|
@override
|
|
Future<void> clearSession() async {
|
|
clearCalls += 1;
|
|
session = null;
|
|
}
|
|
|
|
@override
|
|
Future<UserAccountSession?> currentSession() async => session;
|
|
|
|
@override
|
|
Future<void> saveSession(UserAccountSession session) async {
|
|
this.session = session;
|
|
}
|
|
}
|
|
|
|
final class _FakeRemoteAuthApi implements RemoteAuthApi {
|
|
RemoteAuthException? registerException;
|
|
RemoteAuthException? loginException;
|
|
RemoteAuthException? logoutException;
|
|
var registerCalls = 0;
|
|
var loginCalls = 0;
|
|
var logoutCalls = 0;
|
|
|
|
@override
|
|
Future<RemoteAuthResult> register({
|
|
required String email,
|
|
required String password,
|
|
String? displayName,
|
|
}) async {
|
|
registerCalls += 1;
|
|
final exception = registerException;
|
|
if (exception != null) {
|
|
throw exception;
|
|
}
|
|
return _authResult(email);
|
|
}
|
|
|
|
@override
|
|
Future<RemoteAuthResult> login({
|
|
required String email,
|
|
required String password,
|
|
}) async {
|
|
loginCalls += 1;
|
|
final exception = loginException;
|
|
if (exception != null) {
|
|
throw exception;
|
|
}
|
|
return _authResult(email);
|
|
}
|
|
|
|
@override
|
|
Future<void> logout(String token) async {
|
|
logoutCalls += 1;
|
|
final exception = logoutException;
|
|
if (exception != null) {
|
|
throw exception;
|
|
}
|
|
}
|
|
|
|
RemoteAuthResult _authResult(String email) {
|
|
return RemoteAuthResult(
|
|
userId: 'server-user-1',
|
|
email: email.trim().toLowerCase(),
|
|
token: 'token-1',
|
|
expiresAt: DateTime.utc(2026, 8, 16, 12),
|
|
);
|
|
}
|
|
}
|
|
|
|
SyncUseCases _syncUseCase({
|
|
_FakeAuthTokenStore? tokenStore,
|
|
_FakeRemoteSyncApi? remoteSyncApi,
|
|
_FakeSyncMetadataRepository? metadataRepository,
|
|
_FakeRemoteResourceMappingRepository? mappingRepository,
|
|
_FakeLocalSyncChangeRepository? localChanges,
|
|
_FakeClock? clock,
|
|
}) {
|
|
return SyncUseCases(
|
|
tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'),
|
|
remoteSyncApi: remoteSyncApi ?? _FakeRemoteSyncApi(),
|
|
metadataRepository: metadataRepository ?? _FakeSyncMetadataRepository(),
|
|
mappingRepository:
|
|
mappingRepository ?? _FakeRemoteResourceMappingRepository(),
|
|
localChanges: localChanges ?? _FakeLocalSyncChangeRepository(),
|
|
clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
deviceId: 'device-1',
|
|
);
|
|
}
|
|
|
|
final class _FakeRemoteSyncApi implements RemoteSyncApi {
|
|
RemoteSyncPushResult pushResult = const RemoteSyncPushResult(
|
|
serverCursor: null,
|
|
results: [],
|
|
);
|
|
RemoteSyncPullResult pullResult = const RemoteSyncPullResult(
|
|
serverCursor: null,
|
|
items: [],
|
|
);
|
|
Exception? exception;
|
|
var pushCalls = 0;
|
|
var pullCalls = 0;
|
|
|
|
@override
|
|
Future<RemoteSyncPushResult> push({
|
|
required String deviceId,
|
|
required List<RemoteSyncPushItem> items,
|
|
required String token,
|
|
}) async {
|
|
pushCalls += 1;
|
|
final error = exception;
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
return pushResult;
|
|
}
|
|
|
|
@override
|
|
Future<RemoteSyncPullResult> pull({
|
|
required String? since,
|
|
required String token,
|
|
}) async {
|
|
pullCalls += 1;
|
|
final error = exception;
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
return pullResult;
|
|
}
|
|
}
|
|
|
|
final class _FakeSyncMetadataRepository implements SyncMetadataRepository {
|
|
SyncMetadataSnapshot metadata = const SyncMetadataSnapshot();
|
|
final saved = <SyncMetadataSnapshot>[];
|
|
|
|
@override
|
|
Future<SyncMetadataSnapshot> read() async => metadata;
|
|
|
|
@override
|
|
Future<void> save(SyncMetadataSnapshot metadata) async {
|
|
this.metadata = metadata;
|
|
saved.add(metadata);
|
|
}
|
|
}
|
|
|
|
final class _FakeRemoteResourceMappingRepository
|
|
implements RemoteResourceMappingRepository {
|
|
final saved = <RemoteResourceMapping>[];
|
|
|
|
@override
|
|
Future<RemoteResourceMapping?> find({
|
|
required SyncResourceType resourceType,
|
|
required String clientId,
|
|
}) async {
|
|
return saved
|
|
.where(
|
|
(mapping) =>
|
|
mapping.resourceType == resourceType &&
|
|
mapping.clientId == clientId,
|
|
)
|
|
.firstOrNull;
|
|
}
|
|
|
|
@override
|
|
Future<void> save(RemoteResourceMapping mapping) async {
|
|
saved.removeWhere(
|
|
(existing) =>
|
|
existing.resourceType == mapping.resourceType &&
|
|
existing.clientId == mapping.clientId,
|
|
);
|
|
saved.add(mapping);
|
|
}
|
|
}
|
|
|
|
final class _FakeLocalSyncChangeRepository
|
|
implements LocalSyncChangeRepository {
|
|
final pendingChanges = <PendingSyncChange>[];
|
|
final syncedChangeLogIds = <String>[];
|
|
final appliedItems = <RemoteSyncedItem>[];
|
|
DateTime? localUpdatedAt;
|
|
|
|
@override
|
|
Future<List<PendingSyncChange>> listPendingChanges() async {
|
|
return pendingChanges;
|
|
}
|
|
|
|
@override
|
|
Future<void> markChangesSynced(
|
|
List<String> changeLogIds,
|
|
DateTime syncedAt,
|
|
) async {
|
|
syncedChangeLogIds.addAll(changeLogIds);
|
|
}
|
|
|
|
@override
|
|
Future<bool> applyRemoteItem(RemoteSyncedItem item) async {
|
|
final updatedAt = localUpdatedAt;
|
|
if (updatedAt != null && !item.clientUpdatedAt.isAfter(updatedAt)) {
|
|
return false;
|
|
}
|
|
appliedItems.add(item);
|
|
localUpdatedAt = item.clientUpdatedAt;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
PendingSyncChange _pendingExerciseChange() {
|
|
return PendingSyncChange(
|
|
changeLogIds: const ['change-1'],
|
|
item: RemoteSyncPushItem(
|
|
resourceType: SyncResourceType.exercise,
|
|
clientId: 'exercise-1',
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: DateTime.utc(2026, 7, 17, 12),
|
|
deletedAt: null,
|
|
payload: const {'id': 'exercise-1', 'name': 'Squat'},
|
|
),
|
|
);
|
|
}
|
|
|
|
RemoteSyncedItem _remoteExerciseItem({required DateTime clientUpdatedAt}) {
|
|
return RemoteSyncedItem(
|
|
resourceType: SyncResourceType.exercise,
|
|
clientId: 'exercise-1',
|
|
serverId: 'server-exercise-1',
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: clientUpdatedAt,
|
|
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
|
|
deletedAt: null,
|
|
payload: const {'id': 'exercise-1', 'name': 'Squat'},
|
|
);
|
|
}
|
|
|
|
ShareUseCases _shareUseCase({
|
|
_FakeAuthTokenStore? tokenStore,
|
|
_FakeRemoteShareApi? remoteShareApi,
|
|
_FakeShareInboxRepository? inboxRepository,
|
|
_FakePendingShareActionRepository? pendingActionRepository,
|
|
_FakeLocalSyncChangeRepository? localChanges,
|
|
_FakeProgramRepository? programRepository,
|
|
_FakeWorkoutTemplateRepository? templateRepository,
|
|
}) {
|
|
return ShareUseCases(
|
|
tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'),
|
|
remoteShareApi: remoteShareApi ?? _FakeRemoteShareApi(),
|
|
inboxRepository: inboxRepository ?? _FakeShareInboxRepository(),
|
|
pendingActionRepository:
|
|
pendingActionRepository ?? _FakePendingShareActionRepository(),
|
|
localChanges: localChanges ?? _FakeLocalSyncChangeRepository(),
|
|
programRepository: programRepository ?? _FakeProgramRepository(),
|
|
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
}
|
|
|
|
final class _FakeRemoteShareApi implements RemoteShareApi {
|
|
Exception? exception;
|
|
RemoteSyncedItem? acceptResult;
|
|
List<RemoteSyncedItem>? acceptResults;
|
|
List<ShareInboxItem> inboxItems = const [];
|
|
var sendCalls = 0;
|
|
var acceptCalls = 0;
|
|
var declineCalls = 0;
|
|
var revokeCalls = 0;
|
|
ShareResourceType? lastResourceType;
|
|
Map<String, Object?>? lastPayload;
|
|
|
|
@override
|
|
Future<List<RemoteSyncedItem>> acceptShare(
|
|
String shareId,
|
|
String token,
|
|
) async {
|
|
acceptCalls += 1;
|
|
final error = exception;
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
return acceptResults ?? [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;
|
|
lastResourceType = resourceType;
|
|
lastPayload = payload;
|
|
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',
|
|
ShareResourceType resourceType = ShareResourceType.program,
|
|
required ShareInboxStatus status,
|
|
String payloadJson = '{"name":"Programme partagé"}',
|
|
}) {
|
|
return ShareInboxItem(
|
|
shareId: shareId,
|
|
senderUserId: 'sender-1',
|
|
resourceType: resourceType,
|
|
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,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
Map<String, Object?> _sharedTemplatePayload({
|
|
required String id,
|
|
required String name,
|
|
}) {
|
|
return {
|
|
'id': id,
|
|
'name': name,
|
|
'programs': [
|
|
{
|
|
'id': '$id-program',
|
|
'position': 0,
|
|
'programNameSnapshot': 'Programme',
|
|
'defaultRestSecondsSnapshot': 60,
|
|
'programSnapshotJson': jsonEncode({'exercises': const []}),
|
|
},
|
|
],
|
|
'overrides': const [],
|
|
};
|
|
}
|
|
|
|
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é'},
|
|
);
|
|
}
|
|
|
|
RemoteSyncedItem _remoteSharedTemplateItem() {
|
|
return RemoteSyncedItem(
|
|
resourceType: SyncResourceType.workoutTemplate,
|
|
clientId: 'shared-template-1',
|
|
serverId: 'server-template-1',
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: DateTime.utc(2026, 7, 17, 12, 2),
|
|
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 3),
|
|
deletedAt: null,
|
|
payload: const {'id': 'shared-template-1', 'name': 'Séance partagée'},
|
|
);
|
|
}
|
|
|
|
ExerciseUseCases _exerciseUseCase(
|
|
_FakeExerciseRepository repository, {
|
|
_FakeProgramRepository? programRepository,
|
|
}) {
|
|
return ExerciseUseCases(
|
|
repository: repository,
|
|
programRepository: programRepository ?? _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
}
|
|
|
|
ProgramExercise _programExercise({
|
|
required String id,
|
|
required String programId,
|
|
required String sourceExerciseId,
|
|
required int position,
|
|
}) {
|
|
return ProgramExercise(
|
|
metadata: _metadata(id),
|
|
programId: programId,
|
|
sourceExerciseId: sourceExerciseId,
|
|
position: position,
|
|
exerciseNameSnapshot: 'Exercise $id',
|
|
availableTimeSnapshot: false,
|
|
availableRepsSnapshot: true,
|
|
availableScoreSnapshot: false,
|
|
setsCount: 3,
|
|
timeEnabled: false,
|
|
repsEnabled: true,
|
|
scoreEnabled: false,
|
|
targetReps: 10,
|
|
);
|
|
}
|
|
|
|
WorkoutTemplateProgram _templateProgram({
|
|
required String id,
|
|
required String workoutTemplateId,
|
|
required String sourceProgramId,
|
|
required int position,
|
|
}) {
|
|
return WorkoutTemplateProgram(
|
|
metadata: _metadata(id),
|
|
workoutTemplateId: workoutTemplateId,
|
|
sourceProgramId: sourceProgramId,
|
|
position: position,
|
|
programNameSnapshot: 'Program $id',
|
|
defaultRestSecondsSnapshot: 60,
|
|
programSnapshotJson: '{"exercises":[]}',
|
|
);
|
|
}
|
|
|
|
WorkoutTemplate _playableTemplate({
|
|
required String id,
|
|
required String name,
|
|
DateTime? lastStartedAt,
|
|
}) {
|
|
return WorkoutTemplate(
|
|
metadata: _metadata(id),
|
|
name: name,
|
|
lastStartedAt: lastStartedAt,
|
|
programs: [
|
|
WorkoutTemplateProgram(
|
|
metadata: _metadata('$id-program'),
|
|
workoutTemplateId: id,
|
|
sourceProgramId: null,
|
|
position: 0,
|
|
programNameSnapshot: 'Programme',
|
|
defaultRestSecondsSnapshot: 60,
|
|
programSnapshotJson: jsonEncode({
|
|
'exercises': [
|
|
{
|
|
'id': '$id-exercise',
|
|
'exerciseNameSnapshot': 'Squat',
|
|
'setsCount': 2,
|
|
'timeEnabled': false,
|
|
'repsEnabled': true,
|
|
'scoreEnabled': false,
|
|
'targetReps': 10,
|
|
'exerciseStepsSnapshot': const [],
|
|
'autoStartNextTimedStepSnapshot': false,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
final class _FakeExerciseRepository implements ExerciseRepository {
|
|
Exercise? exercise;
|
|
final saved = <Exercise>[];
|
|
|
|
@override
|
|
Future<Exercise?> findById(String id) async {
|
|
return exercise?.metadata.id == id ? exercise : null;
|
|
}
|
|
|
|
@override
|
|
Future<bool> isReferencedByProgram(String id) async => false;
|
|
|
|
@override
|
|
Future<List<Exercise>> listActive() async =>
|
|
exercise == null || exercise!.metadata.deletedAt != null
|
|
? const []
|
|
: [exercise!];
|
|
|
|
@override
|
|
Future<void> save(Exercise exercise) async {
|
|
this.exercise = exercise;
|
|
saved.add(exercise);
|
|
}
|
|
}
|
|
|
|
LocalDataExportSnapshot _backupSnapshot({
|
|
DateTime? exportedAt,
|
|
List<LocalBackupResource> mediaAssets = const [],
|
|
List<LocalBackupResource> exercises = const [],
|
|
List<LocalBackupResource> programs = const [],
|
|
List<LocalBackupResource> workoutTemplates = const [],
|
|
List<LocalBackupResource> workoutHistories = const [],
|
|
List<EmbeddedBackupMediaFile> mediaFiles = const [],
|
|
}) {
|
|
return LocalDataExportSnapshot(
|
|
exportedAt: exportedAt ?? DateTime.utc(2026, 7, 22, 12),
|
|
appSchemaVersion: 19,
|
|
originDeviceId: 'device-1',
|
|
mediaAssets: mediaAssets,
|
|
exercises: exercises,
|
|
programs: programs,
|
|
workoutTemplates: workoutTemplates,
|
|
workoutHistories: workoutHistories,
|
|
mediaFiles: mediaFiles,
|
|
);
|
|
}
|
|
|
|
LocalBackupResource _backupResource({
|
|
required String id,
|
|
required DateTime updatedAt,
|
|
required Map<String, Object?> payload,
|
|
}) {
|
|
return LocalBackupResource(id: id, updatedAt: updatedAt, payload: payload);
|
|
}
|
|
|
|
Map<String, Object?> _backupMetadata(String id, DateTime updatedAt) => {
|
|
'id': id,
|
|
'createdAt': updatedAt.toUtc().toIso8601String(),
|
|
'updatedAt': updatedAt.toUtc().toIso8601String(),
|
|
'deletedAt': null,
|
|
'schemaVersion': 1,
|
|
'syncState': 'synced',
|
|
'localRevision': 0,
|
|
'originDeviceId': 'device-1',
|
|
};
|
|
|
|
final class _FakeLocalDataBackupRepository
|
|
implements LocalDataBackupRepository {
|
|
_FakeLocalDataBackupRepository({
|
|
LocalDataExportSnapshot? snapshot,
|
|
this.hasOpenSession = false,
|
|
}) : snapshot = snapshot ?? _backupSnapshot();
|
|
|
|
final LocalDataExportSnapshot snapshot;
|
|
final bool hasOpenSession;
|
|
final appliedModes = <LocalBackupImportMode>[];
|
|
final appliedSnapshots = <LocalDataExportSnapshot>[];
|
|
|
|
@override
|
|
Future<LocalBackupImportResult> applyImportSnapshot({
|
|
required LocalDataExportSnapshot snapshot,
|
|
required LocalBackupImportMode mode,
|
|
required DateTime importedAt,
|
|
}) async {
|
|
appliedModes.add(mode);
|
|
appliedSnapshots.add(snapshot);
|
|
return const LocalBackupImportResult(
|
|
insertedCount: 0,
|
|
updatedCount: 0,
|
|
ignoredOlderCount: 0,
|
|
deletedByReplaceCount: 0,
|
|
missingMediaCount: 0,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<bool> hasAnyUserData() async => false;
|
|
|
|
@override
|
|
Future<bool> hasOpenActiveWorkoutSession() async => hasOpenSession;
|
|
|
|
@override
|
|
Future<LocalDataExportSnapshot> readExportSnapshot(
|
|
DateTime exportedAt,
|
|
) async {
|
|
return snapshot.copyWith(mediaFiles: snapshot.mediaFiles);
|
|
}
|
|
}
|
|
|
|
final class _ThrowingLocalDataBackupRepository
|
|
implements LocalDataBackupRepository {
|
|
@override
|
|
Future<LocalBackupImportResult> applyImportSnapshot({
|
|
required LocalDataExportSnapshot snapshot,
|
|
required LocalBackupImportMode mode,
|
|
required DateTime importedAt,
|
|
}) async {
|
|
throw Exception('transaction failed');
|
|
}
|
|
|
|
@override
|
|
Future<bool> hasAnyUserData() async => false;
|
|
|
|
@override
|
|
Future<bool> hasOpenActiveWorkoutSession() async => false;
|
|
|
|
@override
|
|
Future<LocalDataExportSnapshot> readExportSnapshot(
|
|
DateTime exportedAt,
|
|
) async {
|
|
return _backupSnapshot();
|
|
}
|
|
}
|
|
|
|
final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore {
|
|
_FakeLocalBackupMediaStore({this.files = const [], this.restored = const {}});
|
|
|
|
final List<EmbeddedBackupMediaFile> files;
|
|
final Map<String, RestoredMediaFile> restored;
|
|
final requestedMediaIds = <String>[];
|
|
|
|
@override
|
|
Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
|
|
List<LocalBackupResource> mediaAssets,
|
|
) async {
|
|
requestedMediaIds.addAll(mediaAssets.map((asset) => asset.id));
|
|
return files;
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
|
|
List<EmbeddedBackupMediaFile> files,
|
|
) async {
|
|
return restored;
|
|
}
|
|
}
|
|
|
|
final class _FakeWorkoutTemplateRepository
|
|
implements WorkoutTemplateRepository {
|
|
final templates = <WorkoutTemplate>[];
|
|
|
|
@override
|
|
Future<WorkoutTemplate?> findById(String id) async {
|
|
for (final template in templates) {
|
|
if (template.metadata.id == id && template.metadata.deletedAt == null) {
|
|
return template;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<List<WorkoutTemplate>> listActive() async {
|
|
return templates
|
|
.where((template) => template.metadata.deletedAt == null)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> replaceComposition(
|
|
WorkoutTemplate template,
|
|
DateTime deletedAt,
|
|
) async {
|
|
await save(template);
|
|
}
|
|
|
|
@override
|
|
Future<void> save(WorkoutTemplate template) async {
|
|
templates.removeWhere((saved) => saved.metadata.id == template.metadata.id);
|
|
templates.add(template);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveAll(List<WorkoutTemplate> templates) async {
|
|
for (final template in templates) {
|
|
await save(template);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
|
|
|
@override
|
|
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
|
|
}
|
|
|
|
final class _FakeProgramRepository implements ProgramRepository {
|
|
final programs = <Program>[];
|
|
|
|
@override
|
|
Future<Program?> findById(String id) async {
|
|
for (final program in programs) {
|
|
if (program.metadata.id == id && program.metadata.deletedAt == null) {
|
|
return program;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<List<Program>> listActive() async {
|
|
return programs
|
|
.where((program) => program.metadata.deletedAt == null)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> replaceExercises(Program program, DateTime deletedAt) async {
|
|
await save(program);
|
|
}
|
|
|
|
@override
|
|
Future<void> save(Program program) async {
|
|
programs.removeWhere((saved) => saved.metadata.id == program.metadata.id);
|
|
programs.add(program);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveExercise(ProgramExercise exercise) async {
|
|
final index = programs.indexWhere(
|
|
(program) => program.metadata.id == exercise.programId,
|
|
);
|
|
if (index == -1) {
|
|
return;
|
|
}
|
|
final program = programs[index];
|
|
programs[index] = program.copyWith(
|
|
exercises: [
|
|
...program.exercises.where(
|
|
(saved) => saved.metadata.id != exercise.metadata.id,
|
|
),
|
|
exercise,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
|
final histories = <WorkoutHistory>[];
|
|
|
|
@override
|
|
Future<WorkoutHistory?> findById(String id) async {
|
|
return histories.where((history) => history.metadata.id == id).firstOrNull;
|
|
}
|
|
|
|
@override
|
|
Future<List<WorkoutHistory>> listActive() async {
|
|
return histories
|
|
.where((history) => history.metadata.deletedAt == null)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> save(WorkoutHistory history) async {
|
|
histories.removeWhere(
|
|
(existing) => existing.metadata.id == history.metadata.id,
|
|
);
|
|
histories.add(history);
|
|
}
|
|
|
|
@override
|
|
Future<void> patchHeartRateSummary({
|
|
required String historyId,
|
|
int? minHeartRateBpm,
|
|
required double averageHeartRateBpm,
|
|
required int maxHeartRateBpm,
|
|
double? totalDistanceMeters,
|
|
double? totalCaloriesKcal,
|
|
required DateTime patchedAt,
|
|
}) async {
|
|
final index = histories.indexWhere(
|
|
(history) =>
|
|
history.metadata.id == historyId &&
|
|
history.metadata.deletedAt == null &&
|
|
history.averageHeartRateBpm == null &&
|
|
history.maxHeartRateBpm == null,
|
|
);
|
|
if (index == -1) {
|
|
return;
|
|
}
|
|
final history = histories[index];
|
|
histories[index] = history.copyWith(
|
|
metadata: history.metadata.touch(patchedAt),
|
|
minHeartRateBpm: minHeartRateBpm,
|
|
averageHeartRateBpm: averageHeartRateBpm,
|
|
maxHeartRateBpm: maxHeartRateBpm,
|
|
totalDistanceMeters: totalDistanceMeters,
|
|
totalCaloriesKcal: totalCaloriesKcal,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
|
|
|
@override
|
|
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
|
|
|
@override
|
|
Future<void> delete(String id, DateTime deletedAt) async {
|
|
final index = histories.indexWhere((history) => history.metadata.id == id);
|
|
if (index == -1) {
|
|
return;
|
|
}
|
|
histories[index] = histories[index].copyWith(
|
|
metadata: histories[index].metadata.markDeleted(deletedAt),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _FakeWorkoutTelemetryRepository
|
|
implements WorkoutTelemetryRepository {
|
|
final samples = <WorkoutTelemetrySample>[];
|
|
final aggregates = <WorkoutTelemetryAggregate>[];
|
|
final windowStates = <String, ActiveWorkoutTelemetryWindowState>{};
|
|
|
|
@override
|
|
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
|
|
final index = samples.indexWhere((existing) => existing.id == sample.id);
|
|
if (index != -1 && !sample.capturedAt.isAfter(samples[index].capturedAt)) {
|
|
return false;
|
|
}
|
|
if (index == -1) {
|
|
samples.add(sample);
|
|
} else {
|
|
samples[index] = sample;
|
|
}
|
|
samples.sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Future<ActiveWorkoutTelemetryWindowState?> findWindowState(
|
|
String sessionId,
|
|
) async {
|
|
return windowStates[sessionId];
|
|
}
|
|
|
|
@override
|
|
Future<void> saveWindowState(ActiveWorkoutTelemetryWindowState state) async {
|
|
windowStates[state.sessionId] = state;
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteWindowState(String sessionId) async {
|
|
windowStates.remove(sessionId);
|
|
}
|
|
|
|
@override
|
|
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
|
|
return samples
|
|
.where((sample) => sample.sessionId == sessionId)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
@override
|
|
Future<List<WorkoutTelemetrySample>> listSamplesForScope({
|
|
required String sessionId,
|
|
required WorkoutTelemetryAggregateScope scope,
|
|
int? programIndex,
|
|
int? exerciseIndex,
|
|
int? setIndex,
|
|
int? passageIndex,
|
|
int? stepIndex,
|
|
}) async {
|
|
return samples
|
|
.where(
|
|
(sample) =>
|
|
sample.sessionId == sessionId &&
|
|
_fakeTelemetrySampleMatchesScope(
|
|
sample,
|
|
scope: scope,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
passageIndex: passageIndex,
|
|
stepIndex: stepIndex,
|
|
),
|
|
)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
@override
|
|
Future<void> replaceAggregatesForSession({
|
|
required String sessionId,
|
|
required List<WorkoutTelemetryAggregate> aggregates,
|
|
}) async {
|
|
this.aggregates.removeWhere(
|
|
(aggregate) => aggregate.sessionId == sessionId,
|
|
);
|
|
this.aggregates.addAll(aggregates);
|
|
}
|
|
|
|
@override
|
|
Future<List<WorkoutTelemetryAggregate>> listAggregates(
|
|
String sessionId,
|
|
) async {
|
|
return aggregates
|
|
.where((aggregate) => aggregate.sessionId == sessionId)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
@override
|
|
Future<WorkoutTelemetryAggregate?> findAggregate({
|
|
required String sessionId,
|
|
required WorkoutTelemetryAggregateScope scope,
|
|
int? programIndex,
|
|
int? exerciseIndex,
|
|
int? setIndex,
|
|
int? passageIndex,
|
|
int? stepIndex,
|
|
}) async {
|
|
return aggregates
|
|
.where(
|
|
(aggregate) =>
|
|
aggregate.sessionId == sessionId &&
|
|
aggregate.scope == scope &&
|
|
aggregate.programIndex == programIndex &&
|
|
aggregate.exerciseIndex == exerciseIndex &&
|
|
aggregate.setIndex == setIndex &&
|
|
aggregate.passageIndex == passageIndex &&
|
|
aggregate.stepIndex == stepIndex,
|
|
)
|
|
.firstOrNull;
|
|
}
|
|
}
|
|
|
|
bool _fakeTelemetrySampleMatchesScope(
|
|
WorkoutTelemetrySample sample, {
|
|
required WorkoutTelemetryAggregateScope scope,
|
|
int? programIndex,
|
|
int? exerciseIndex,
|
|
int? setIndex,
|
|
int? passageIndex,
|
|
int? stepIndex,
|
|
}) {
|
|
return switch (scope) {
|
|
WorkoutTelemetryAggregateScope.session => true,
|
|
WorkoutTelemetryAggregateScope.exercise =>
|
|
sample.programIndex == programIndex &&
|
|
sample.exerciseIndex == exerciseIndex,
|
|
WorkoutTelemetryAggregateScope.set =>
|
|
sample.programIndex == programIndex &&
|
|
sample.exerciseIndex == exerciseIndex &&
|
|
sample.setIndex == setIndex,
|
|
WorkoutTelemetryAggregateScope.step =>
|
|
sample.programIndex == programIndex &&
|
|
sample.exerciseIndex == exerciseIndex &&
|
|
sample.setIndex == setIndex &&
|
|
(passageIndex == null || sample.passageIndex == passageIndex) &&
|
|
sample.stepIndex == stepIndex,
|
|
};
|
|
}
|
|
|
|
ActiveWorkoutSessionUseCases _activeUseCase(
|
|
_FakeActiveSessionRepository repository,
|
|
_FakeClock clock,
|
|
) {
|
|
return ActiveWorkoutSessionUseCases(
|
|
sessionRepository: repository,
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
}
|
|
|
|
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|
ActiveWorkoutSession? session;
|
|
final results = <ActiveSetResult>[];
|
|
final restStates = <String, ActiveRestState>{};
|
|
final setTimerStates = <String, ActiveSetTimerState>{};
|
|
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
|
|
final manualScoreStates = <String, ActiveManualScoreState>{};
|
|
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
|
|
final stepResults = <ActiveExerciseStepResult>[];
|
|
|
|
@override
|
|
Future<ActiveWorkoutSession?> findById(String id) async {
|
|
return session?.metadata.id == id ? session : null;
|
|
}
|
|
|
|
@override
|
|
Future<ActiveWorkoutSession?> findOpen() async => session;
|
|
|
|
@override
|
|
Future<ActiveRestState?> findRestStateById(String id) async => restStates[id];
|
|
|
|
@override
|
|
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
return scoreStopwatchStates.values
|
|
.where(
|
|
(state) =>
|
|
state.activeWorkoutSessionId == sessionId &&
|
|
state.programIndex == programIndex &&
|
|
state.exerciseIndex == exerciseIndex &&
|
|
state.setIndex == setIndex &&
|
|
state.metadata.deletedAt == null,
|
|
)
|
|
.firstOrNull;
|
|
}
|
|
|
|
@override
|
|
Future<ActiveManualScoreState?> findManualScoreState({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
return manualScoreStates.values
|
|
.where(
|
|
(state) =>
|
|
state.activeWorkoutSessionId == sessionId &&
|
|
state.programIndex == programIndex &&
|
|
state.exerciseIndex == exerciseIndex &&
|
|
state.setIndex == setIndex &&
|
|
state.metadata.deletedAt == null,
|
|
)
|
|
.firstOrNull;
|
|
}
|
|
|
|
@override
|
|
Future<ActiveSetTimerState?> findSetTimerState({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
return setTimerStates.values
|
|
.where(
|
|
(state) =>
|
|
state.activeWorkoutSessionId == sessionId &&
|
|
state.programIndex == programIndex &&
|
|
state.exerciseIndex == exerciseIndex &&
|
|
state.setIndex == setIndex &&
|
|
state.metadata.deletedAt == null,
|
|
)
|
|
.firstOrNull;
|
|
}
|
|
|
|
@override
|
|
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
return stepProgressStates.values
|
|
.where(
|
|
(state) =>
|
|
state.activeWorkoutSessionId == sessionId &&
|
|
state.programIndex == programIndex &&
|
|
state.exerciseIndex == exerciseIndex &&
|
|
state.setIndex == setIndex &&
|
|
state.metadata.deletedAt == null,
|
|
)
|
|
.firstOrNull;
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
|
return restStates.values
|
|
.where((rest) => rest.activeWorkoutSessionId == sessionId)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
|
|
String sessionId,
|
|
) async {
|
|
return scoreStopwatchStates.values
|
|
.where(
|
|
(state) =>
|
|
state.activeWorkoutSessionId == sessionId &&
|
|
state.metadata.deletedAt == null,
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveManualScoreState>> listManualScoreStates(
|
|
String sessionId,
|
|
) async {
|
|
return manualScoreStates.values
|
|
.where(
|
|
(state) =>
|
|
state.activeWorkoutSessionId == sessionId &&
|
|
state.metadata.deletedAt == null,
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
|
|
return results
|
|
.where((result) => result.activeWorkoutSessionId == sessionId)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
|
|
return setTimerStates.values
|
|
.where(
|
|
(state) =>
|
|
state.activeWorkoutSessionId == sessionId &&
|
|
state.metadata.deletedAt == null,
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
|
|
String sessionId,
|
|
) async {
|
|
return stepProgressStates.values
|
|
.where((state) => state.activeWorkoutSessionId == sessionId)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
|
|
String sessionId,
|
|
) async {
|
|
return stepResults
|
|
.where((result) => result.activeWorkoutSessionId == sessionId)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> save(ActiveWorkoutSession session) async {
|
|
this.session = session;
|
|
}
|
|
|
|
@override
|
|
Future<void> saveRestState(ActiveRestState restState) async {
|
|
restStates[restState.metadata.id] = restState;
|
|
}
|
|
|
|
@override
|
|
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
|
|
setTimerStates[state.metadata.id] = state;
|
|
}
|
|
|
|
@override
|
|
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
|
|
scoreStopwatchStates[state.metadata.id] = state;
|
|
}
|
|
|
|
@override
|
|
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
|
|
manualScoreStates[state.metadata.id] = state;
|
|
}
|
|
|
|
@override
|
|
Future<void> saveExerciseStepProgressState(
|
|
ActiveExerciseStepProgressState state,
|
|
) async {
|
|
stepProgressStates[state.metadata.id] = state;
|
|
}
|
|
|
|
@override
|
|
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
|
|
stepResults.removeWhere(
|
|
(existing) =>
|
|
existing.activeWorkoutSessionId == result.activeWorkoutSessionId &&
|
|
existing.programIndex == result.programIndex &&
|
|
existing.exerciseIndex == result.exerciseIndex &&
|
|
existing.setIndex == result.setIndex &&
|
|
existing.passageIndex == result.passageIndex &&
|
|
existing.stepIndex == result.stepIndex,
|
|
);
|
|
stepResults.add(result);
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteScoreStopwatchState({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
required DateTime deletedAt,
|
|
}) async {
|
|
final state = await findScoreStopwatchState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
if (state == null) {
|
|
return;
|
|
}
|
|
scoreStopwatchStates.remove(state.metadata.id);
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteManualScoreState({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
required DateTime deletedAt,
|
|
}) async {
|
|
final state = await findManualScoreState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
if (state == null) {
|
|
return;
|
|
}
|
|
manualScoreStates.remove(state.metadata.id);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveSetResult(ActiveSetResult result) async {
|
|
results.removeWhere(
|
|
(existing) =>
|
|
existing.activeWorkoutSessionId == result.activeWorkoutSessionId &&
|
|
existing.programIndex == result.programIndex &&
|
|
existing.exerciseIndex == result.exerciseIndex &&
|
|
existing.setIndex == result.setIndex,
|
|
);
|
|
results.add(result);
|
|
}
|
|
}
|
|
|
|
ActiveWorkoutSession _sessionWithSnapshot({
|
|
required int currentProgramIndex,
|
|
required int currentExerciseIndex,
|
|
required int currentSetIndex,
|
|
int setsCount = 2,
|
|
int targetReps = 10,
|
|
bool timeEnabled = false,
|
|
bool repsEnabled = true,
|
|
bool scoreEnabled = false,
|
|
int? targetTimeSeconds,
|
|
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
|
int? targetScoreTimeMs,
|
|
List<ExerciseStep> exerciseSteps = const [],
|
|
bool? autoStartNextTimedStepSnapshot = true,
|
|
bool? autoStartNextTimedStepOverride,
|
|
bool? templateAutoStartNextTimedStepOverride,
|
|
}) {
|
|
final exerciseSnapshot = {
|
|
'id': 'exercise-snapshot-1',
|
|
'exerciseNameSnapshot': 'Squat',
|
|
'setsCount': setsCount,
|
|
'timeEnabled': timeEnabled,
|
|
'repsEnabled': repsEnabled,
|
|
'scoreEnabled': scoreEnabled,
|
|
'targetTimeSeconds': targetTimeSeconds,
|
|
'targetReps': repsEnabled ? targetReps : null,
|
|
'targetScoreTimeMs': targetScoreTimeMs,
|
|
'scoreInputModeSnapshot': scoreInputMode.name,
|
|
'exerciseStepsSnapshot': exerciseSteps
|
|
.map((step) => step.toSnapshotJson())
|
|
.toList(),
|
|
'autoStartNextTimedStepSnapshot': ?autoStartNextTimedStepSnapshot,
|
|
'autoStartNextTimedStepOverride': ?autoStartNextTimedStepOverride,
|
|
};
|
|
return ActiveWorkoutSession(
|
|
metadata: _metadata('session-1'),
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: DateTime.utc(2026, 7, 17, 12),
|
|
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: currentProgramIndex,
|
|
currentExerciseIndex: currentExerciseIndex,
|
|
currentSetIndex: currentSetIndex,
|
|
resolvedTemplateSnapshotJson: jsonEncode({
|
|
'programs': [
|
|
{
|
|
'id': 'program-snapshot-1',
|
|
'programNameSnapshot': 'Programme',
|
|
'programSnapshotJson': jsonEncode({
|
|
'exercises': [exerciseSnapshot],
|
|
}),
|
|
},
|
|
],
|
|
'overrides': [
|
|
if (templateAutoStartNextTimedStepOverride != null)
|
|
{
|
|
'workoutTemplateProgramId': 'program-snapshot-1',
|
|
'snapshotProgramExerciseId': 'exercise-snapshot-1',
|
|
'autoStartNextTimedStepOverride':
|
|
templateAutoStartNextTimedStepOverride,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
|
|
List<ExerciseStep> _twoTimedSteps() {
|
|
return [
|
|
_step(
|
|
id: 'step-1',
|
|
position: 0,
|
|
name: 'Gauche',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 1,
|
|
),
|
|
_step(
|
|
id: 'step-2',
|
|
position: 1,
|
|
name: 'Droite',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 1,
|
|
),
|
|
];
|
|
}
|