Ajoute la suppression avec gestion des dépendances en cascade sur les écrans bibliothèque d'exercices, programme et séance-modèle, avec les use cases et le bootstrap applicatif associés. flutter analyze propre, 58/58 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1107 lines
32 KiB
Dart
1107 lines
32 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:gametime/application/application.dart';
|
|
import 'package:gametime/domain/domain.dart';
|
|
|
|
void main() {
|
|
test('Exercise requires at least one active measure', () {
|
|
expect(
|
|
() => Exercise(
|
|
metadata: _metadata('exercise-1'),
|
|
name: 'Push up',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: false,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Exercise use case rejects active measures without default targets',
|
|
() async {
|
|
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
|
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Squat',
|
|
hasTimeMeasure: true,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: false,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Pompes',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.create(
|
|
name: 'Charge',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('Exercise use case rejects non-positive 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: 0,
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
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('delete exercise removes it from referencing programs', () 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,
|
|
),
|
|
_programExercise(
|
|
id: 'program-exercise-2',
|
|
programId: 'program-1',
|
|
sourceExerciseId: 'exercise-2',
|
|
position: 1,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
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-2',
|
|
);
|
|
expect(programRepository.programs.single.exercises.single.position, 0);
|
|
});
|
|
|
|
test(
|
|
'delete program removes it from referencing workout templates',
|
|
() async {
|
|
final programRepository = _FakeProgramRepository()
|
|
..programs.add(
|
|
Program(
|
|
metadata: _metadata('program-1'),
|
|
name: 'Jambes',
|
|
defaultRestSeconds: 60,
|
|
),
|
|
);
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.add(
|
|
WorkoutTemplate(
|
|
metadata: _metadata('template-1'),
|
|
name: 'Séance',
|
|
programs: [
|
|
_templateProgram(
|
|
id: 'template-program-1',
|
|
workoutTemplateId: 'template-1',
|
|
sourceProgramId: 'program-1',
|
|
position: 0,
|
|
),
|
|
_templateProgram(
|
|
id: 'template-program-2',
|
|
workoutTemplateId: 'template-1',
|
|
sourceProgramId: 'program-2',
|
|
position: 1,
|
|
),
|
|
],
|
|
overrides: [
|
|
WorkoutTemplateExerciseOverride(
|
|
metadata: _metadata('override-1'),
|
|
workoutTemplateProgramId: 'template-program-1',
|
|
snapshotProgramExerciseId: 'exercise-snapshot-1',
|
|
targetRepsOverride: 8,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final useCase = ProgramUseCases(
|
|
programRepository: programRepository,
|
|
exerciseRepository: _FakeExerciseRepository(),
|
|
templateRepository: templateRepository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final deleted = await useCase.delete('program-1');
|
|
|
|
expect(deleted.metadata.deletedAt, isNotNull);
|
|
expect(programRepository.listActive(), completion(isEmpty));
|
|
final template = templateRepository.templates.single;
|
|
expect(template.programs, hasLength(1));
|
|
expect(template.programs.single.sourceProgramId, 'program-2');
|
|
expect(template.programs.single.position, 0);
|
|
expect(template.overrides, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('delete workout template hides it from active list', () async {
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.add(
|
|
WorkoutTemplate(metadata: _metadata('template-1'), name: 'Séance'),
|
|
);
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: templateRepository,
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final deleted = await useCase.delete('template-1');
|
|
|
|
expect(deleted.metadata.deletedAt, isNotNull);
|
|
expect(templateRepository.listActive(), completion(isEmpty));
|
|
});
|
|
|
|
test('history stays unchanged after deleting its source template', () async {
|
|
final templateRepository = _FakeWorkoutTemplateRepository()
|
|
..templates.add(
|
|
WorkoutTemplate(metadata: _metadata('template-1'), name: 'Séance'),
|
|
);
|
|
final history = WorkoutHistory(
|
|
metadata: _metadata('history-1'),
|
|
sourceWorkoutTemplateId: 'template-1',
|
|
nameSnapshot: 'Séance historique',
|
|
startedAt: DateTime.utc(2026, 7, 17, 10),
|
|
endedAt: DateTime.utc(2026, 7, 17, 11),
|
|
totalActiveMs: 3600000,
|
|
completed: true,
|
|
historySnapshotJson: '{"name":"Séance historique"}',
|
|
);
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: templateRepository,
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await useCase.delete('template-1');
|
|
|
|
expect(history.sourceWorkoutTemplateId, 'template-1');
|
|
expect(history.nameSnapshot, 'Séance historique');
|
|
expect(history.historySnapshotJson, '{"name":"Séance historique"}');
|
|
});
|
|
|
|
test('Workout template use case rejects active measure overrides', () async {
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.overrideExerciseTargets(
|
|
workoutTemplateProgramId: 'template-program-1',
|
|
snapshotProgramExerciseId: 'exercise-snapshot-1',
|
|
attemptedMeasureOverride: {WorkoutMeasure.reps},
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Workout template use case rejects target overrides for inactive measures',
|
|
() async {
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
programRepository: _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.saveConfigured(
|
|
name: 'Séance tir',
|
|
programs: [
|
|
WorkoutTemplateProgramConfig(
|
|
clientKey: 'program-1',
|
|
programNameSnapshot: 'Programme tir',
|
|
defaultRestSecondsSnapshot: 30,
|
|
programSnapshotJson: jsonEncode({
|
|
'exercises': [
|
|
{
|
|
'id': 'exercise-snapshot-1',
|
|
'exerciseNameSnapshot': 'Lancers francs',
|
|
'setsCount': 3,
|
|
'timeEnabled': true,
|
|
'repsEnabled': false,
|
|
'scoreEnabled': false,
|
|
'targetTimeSeconds': 60,
|
|
'targetReps': null,
|
|
'targetScore': null,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
],
|
|
overrides: const [
|
|
WorkoutTemplateExerciseOverrideConfig(
|
|
workoutTemplateProgramClientKey: 'program-1',
|
|
snapshotProgramExerciseId: 'exercise-snapshot-1',
|
|
targetRepsOverride: 10,
|
|
),
|
|
],
|
|
),
|
|
throwsA(isA<DomainException>()),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('Active session elapsed time is based on persisted timestamps', () {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = ActiveWorkoutSession(
|
|
metadata: _metadata('session-1'),
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: clock.now(),
|
|
lastPersistedAt: clock.now(),
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: '{}',
|
|
);
|
|
|
|
clock.value = clock.value.add(const Duration(seconds: 30));
|
|
final paused = session.pause(clock.now());
|
|
expect(paused.elapsedActiveMs, 30000);
|
|
|
|
clock.value = clock.value.add(const Duration(minutes: 5));
|
|
final resumed = paused.resume(clock.now());
|
|
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 30000);
|
|
|
|
clock.value = clock.value.add(const Duration(seconds: 15));
|
|
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000);
|
|
});
|
|
|
|
test('adjustRestSeconds persists adjusted rest duration', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final repository = _FakeActiveSessionRepository()
|
|
..restStates['rest-1'] = ActiveRestState(
|
|
metadata: _metadata('rest-1'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 0,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now(),
|
|
);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final adjusted = await useCase.adjustRestSeconds(
|
|
restStateId: 'rest-1',
|
|
deltaSeconds: -75,
|
|
);
|
|
|
|
expect(adjusted.adjustedRestSeconds, 0);
|
|
expect(repository.restStates['rest-1']!.adjustedRestSeconds, 0);
|
|
});
|
|
|
|
test('skipRest persists skippedAt timestamp', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final repository = _FakeActiveSessionRepository()
|
|
..restStates['rest-1'] = ActiveRestState(
|
|
metadata: _metadata('rest-1'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 0,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now(),
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 10));
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final skipped = await useCase.skipRest(restStateId: 'rest-1');
|
|
|
|
expect(skipped.skippedAt, clock.now());
|
|
expect(repository.restStates['rest-1']!.skippedAt, clock.now());
|
|
});
|
|
|
|
test('findActiveRest returns latest non-ended non-skipped rest', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final repository = _FakeActiveSessionRepository()
|
|
..restStates['ended'] = ActiveRestState(
|
|
metadata: _metadata('ended'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 0,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now(),
|
|
endedAt: clock.now().add(const Duration(seconds: 60)),
|
|
)
|
|
..restStates['older-active'] = ActiveRestState(
|
|
metadata: _metadata('older-active'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 1,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now().add(const Duration(seconds: 30)),
|
|
)
|
|
..restStates['latest-active'] = ActiveRestState(
|
|
metadata: _metadata('latest-active'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 2,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now().add(const Duration(seconds: 90)),
|
|
)
|
|
..restStates['skipped'] = ActiveRestState(
|
|
metadata: _metadata('skipped'),
|
|
activeWorkoutSessionId: 'session-1',
|
|
afterProgramIndex: 0,
|
|
afterExerciseIndex: 3,
|
|
afterSetIndex: 0,
|
|
plannedRestSeconds: 60,
|
|
adjustedRestSeconds: 60,
|
|
startedAt: clock.now().add(const Duration(seconds: 120)),
|
|
skippedAt: clock.now().add(const Duration(seconds: 125)),
|
|
);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final active = await useCase.findActiveRest(sessionId: 'session-1');
|
|
|
|
expect(active?.metadata.id, 'latest-active');
|
|
});
|
|
|
|
test('upsertSetResultAtPosition does not move session cursor', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 1,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
await useCase.upsertSetResultAtPosition(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
status: SetResultStatus.completed,
|
|
actualReps: 12,
|
|
);
|
|
|
|
expect(repository.session?.currentProgramIndex, 0);
|
|
expect(repository.session?.currentExerciseIndex, 0);
|
|
expect(repository.session?.currentSetIndex, 1);
|
|
expect(repository.results.single.actualReps, 12);
|
|
});
|
|
|
|
test(
|
|
'upsertSetResultAtPosition preserves existing id and createdAt',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 1,
|
|
);
|
|
final existingCreatedAt = DateTime.utc(2026, 7, 17, 10);
|
|
final existing = ActiveSetResult(
|
|
metadata: EntityMetadata(
|
|
id: 'result-1',
|
|
createdAt: existingCreatedAt,
|
|
updatedAt: existingCreatedAt,
|
|
originDeviceId: 'device-1',
|
|
),
|
|
activeWorkoutSessionId: session.metadata.id,
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualReps: 8,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()
|
|
..session = session
|
|
..results.add(existing);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final updated = await useCase.upsertSetResultAtPosition(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
status: SetResultStatus.completed,
|
|
actualReps: 12,
|
|
);
|
|
|
|
expect(updated.metadata.id, 'result-1');
|
|
expect(updated.metadata.createdAt, existingCreatedAt);
|
|
expect(updated.actualReps, 12);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'listSetResults returns pending, skipped and completed statuses',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 2,
|
|
setsCount: 3,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()
|
|
..session = session
|
|
..results.addAll([
|
|
ActiveSetResult(
|
|
metadata: _metadata('completed'),
|
|
activeWorkoutSessionId: session.metadata.id,
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualReps: 10,
|
|
status: SetResultStatus.completed,
|
|
),
|
|
ActiveSetResult(
|
|
metadata: _metadata('skipped'),
|
|
activeWorkoutSessionId: session.metadata.id,
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 1,
|
|
status: SetResultStatus.skipped,
|
|
),
|
|
]);
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
final states = await useCase.listSetResults(session.metadata.id);
|
|
|
|
expect(states.map((state) => state.status), [
|
|
SetPositionStatus.completed,
|
|
SetPositionStatus.skipped,
|
|
SetPositionStatus.pending,
|
|
]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'score stopwatch start stop resume accumulates elapsed duration',
|
|
() async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
final useCase = _activeUseCase(repository, clock);
|
|
|
|
await useCase.startScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 5));
|
|
final stopped = await useCase.stopScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 10));
|
|
await useCase.resumeScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 3));
|
|
final stoppedAgain = await useCase.stopScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(stopped.accumulatedMs, 5000);
|
|
expect(stoppedAgain.accumulatedMs, 8000);
|
|
},
|
|
);
|
|
|
|
test('score stopwatch state survives use case reconstruction', () async {
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
|
final session = _sessionWithSnapshot(
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
);
|
|
final repository = _FakeActiveSessionRepository()..session = session;
|
|
|
|
await _activeUseCase(repository, clock).startScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
clock.value = clock.value.add(const Duration(seconds: 4));
|
|
|
|
final reconstructed = _activeUseCase(repository, clock);
|
|
final stopped = await reconstructed.stopScoreStopwatch(
|
|
sessionId: session.metadata.id,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
);
|
|
|
|
expect(stopped.accumulatedMs, 4000);
|
|
});
|
|
|
|
test('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>()),
|
|
);
|
|
});
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|
|
|
|
ExerciseUseCases _exerciseUseCase(
|
|
_FakeExerciseRepository repository, {
|
|
_FakeProgramRepository? programRepository,
|
|
}) {
|
|
return ExerciseUseCases(
|
|
repository: repository,
|
|
programRepository: programRepository ?? _FakeProgramRepository(),
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
}
|
|
|
|
ProgramExercise _programExercise({
|
|
required String id,
|
|
required String programId,
|
|
required String sourceExerciseId,
|
|
required int position,
|
|
}) {
|
|
return ProgramExercise(
|
|
metadata: _metadata(id),
|
|
programId: programId,
|
|
sourceExerciseId: sourceExerciseId,
|
|
position: position,
|
|
exerciseNameSnapshot: 'Exercise $id',
|
|
availableTimeSnapshot: false,
|
|
availableRepsSnapshot: true,
|
|
availableScoreSnapshot: false,
|
|
setsCount: 3,
|
|
timeEnabled: false,
|
|
repsEnabled: true,
|
|
scoreEnabled: false,
|
|
targetReps: 10,
|
|
);
|
|
}
|
|
|
|
WorkoutTemplateProgram _templateProgram({
|
|
required String id,
|
|
required String workoutTemplateId,
|
|
required String sourceProgramId,
|
|
required int position,
|
|
}) {
|
|
return WorkoutTemplateProgram(
|
|
metadata: _metadata(id),
|
|
workoutTemplateId: workoutTemplateId,
|
|
sourceProgramId: sourceProgramId,
|
|
position: position,
|
|
programNameSnapshot: 'Program $id',
|
|
defaultRestSecondsSnapshot: 60,
|
|
programSnapshotJson: '{"exercises":[]}',
|
|
);
|
|
}
|
|
|
|
final class _FakeExerciseRepository implements ExerciseRepository {
|
|
Exercise? exercise;
|
|
final saved = <Exercise>[];
|
|
|
|
@override
|
|
Future<Exercise?> findById(String id) async {
|
|
return exercise?.metadata.id == id ? exercise : null;
|
|
}
|
|
|
|
@override
|
|
Future<bool> isReferencedByProgram(String id) async => false;
|
|
|
|
@override
|
|
Future<List<Exercise>> listActive() async =>
|
|
exercise == null || exercise!.metadata.deletedAt != null
|
|
? const []
|
|
: [exercise!];
|
|
|
|
@override
|
|
Future<void> save(Exercise exercise) async {
|
|
this.exercise = exercise;
|
|
saved.add(exercise);
|
|
}
|
|
}
|
|
|
|
final class _FakeWorkoutTemplateRepository
|
|
implements WorkoutTemplateRepository {
|
|
final templates = <WorkoutTemplate>[];
|
|
|
|
@override
|
|
Future<WorkoutTemplate?> findById(String id) async {
|
|
for (final template in templates) {
|
|
if (template.metadata.id == id && template.metadata.deletedAt == null) {
|
|
return template;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<List<WorkoutTemplate>> listActive() async {
|
|
return templates
|
|
.where((template) => template.metadata.deletedAt == null)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> replaceComposition(
|
|
WorkoutTemplate template,
|
|
DateTime deletedAt,
|
|
) async {
|
|
await save(template);
|
|
}
|
|
|
|
@override
|
|
Future<void> save(WorkoutTemplate template) async {
|
|
templates.removeWhere((saved) => saved.metadata.id == template.metadata.id);
|
|
templates.add(template);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
|
|
|
@override
|
|
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
|
|
}
|
|
|
|
final class _FakeProgramRepository implements ProgramRepository {
|
|
final programs = <Program>[];
|
|
|
|
@override
|
|
Future<Program?> findById(String id) async {
|
|
for (final program in programs) {
|
|
if (program.metadata.id == id && program.metadata.deletedAt == null) {
|
|
return program;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<List<Program>> listActive() async {
|
|
return programs
|
|
.where((program) => program.metadata.deletedAt == null)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> replaceExercises(Program program, DateTime deletedAt) async {
|
|
await save(program);
|
|
}
|
|
|
|
@override
|
|
Future<void> save(Program program) async {
|
|
programs.removeWhere((saved) => saved.metadata.id == program.metadata.id);
|
|
programs.add(program);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveExercise(ProgramExercise exercise) async {
|
|
final index = programs.indexWhere(
|
|
(program) => program.metadata.id == exercise.programId,
|
|
);
|
|
if (index == -1) {
|
|
return;
|
|
}
|
|
final program = programs[index];
|
|
programs[index] = program.copyWith(
|
|
exercises: [
|
|
...program.exercises.where(
|
|
(saved) => saved.metadata.id != exercise.metadata.id,
|
|
),
|
|
exercise,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
ActiveWorkoutSessionUseCases _activeUseCase(
|
|
_FakeActiveSessionRepository repository,
|
|
_FakeClock clock,
|
|
) {
|
|
return ActiveWorkoutSessionUseCases(
|
|
sessionRepository: repository,
|
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
|
clock: clock,
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
}
|
|
|
|
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|
ActiveWorkoutSession? session;
|
|
final results = <ActiveSetResult>[];
|
|
final restStates = <String, ActiveRestState>{};
|
|
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
|
|
|
|
@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<List<ActiveRestState>> listRestStates(String sessionId) async {
|
|
return restStates.values
|
|
.where((rest) => rest.activeWorkoutSessionId == sessionId)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
|
|
String sessionId,
|
|
) async {
|
|
return scoreStopwatchStates.values
|
|
.where(
|
|
(state) =>
|
|
state.activeWorkoutSessionId == sessionId &&
|
|
state.metadata.deletedAt == null,
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
|
|
return results
|
|
.where((result) => result.activeWorkoutSessionId == sessionId)
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> save(ActiveWorkoutSession session) async {
|
|
this.session = session;
|
|
}
|
|
|
|
@override
|
|
Future<void> saveRestState(ActiveRestState restState) async {
|
|
restStates[restState.metadata.id] = restState;
|
|
}
|
|
|
|
@override
|
|
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
|
|
scoreStopwatchStates[state.metadata.id] = state;
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteScoreStopwatchState({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
required DateTime deletedAt,
|
|
}) async {
|
|
final state = await findScoreStopwatchState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
if (state == null) {
|
|
return;
|
|
}
|
|
scoreStopwatchStates.remove(state.metadata.id);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveSetResult(ActiveSetResult result) async {
|
|
results.removeWhere(
|
|
(existing) =>
|
|
existing.activeWorkoutSessionId == result.activeWorkoutSessionId &&
|
|
existing.programIndex == result.programIndex &&
|
|
existing.exerciseIndex == result.exerciseIndex &&
|
|
existing.setIndex == result.setIndex,
|
|
);
|
|
results.add(result);
|
|
}
|
|
}
|
|
|
|
ActiveWorkoutSession _sessionWithSnapshot({
|
|
required int currentProgramIndex,
|
|
required int currentExerciseIndex,
|
|
required int currentSetIndex,
|
|
int setsCount = 2,
|
|
}) {
|
|
return ActiveWorkoutSession(
|
|
metadata: _metadata('session-1'),
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: DateTime.utc(2026, 7, 17, 12),
|
|
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: currentProgramIndex,
|
|
currentExerciseIndex: currentExerciseIndex,
|
|
currentSetIndex: currentSetIndex,
|
|
resolvedTemplateSnapshotJson: jsonEncode({
|
|
'programs': [
|
|
{
|
|
'id': 'program-snapshot-1',
|
|
'programNameSnapshot': 'Programme',
|
|
'programSnapshotJson': jsonEncode({
|
|
'exercises': [
|
|
{
|
|
'id': 'exercise-snapshot-1',
|
|
'exerciseNameSnapshot': 'Squat',
|
|
'setsCount': setsCount,
|
|
'timeEnabled': false,
|
|
'repsEnabled': true,
|
|
'scoreEnabled': false,
|
|
'targetReps': 10,
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
}
|