Files
GameTime/test/application/use_cases_test.dart
Blomios bdd7877ea5 feat(exercice): icône d'exercice (ticket #38)
Étend le modèle Drift (tables.dart, app_database.dart/.g.dart), les
entités/use cases/repositories et les écrans bibliothèque d'exercices
et programme pour associer une icône à chaque exercice. flutter
analyze propre, 72/72 tests verts, build APK debug validé. Dernier
ticket du backlog.

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

1302 lines
38 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('Exercise image gallery rejects a sixth image', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const [
'media-1',
'media-2',
'media-3',
'media-4',
'media-5',
],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
await expectLater(
useCase.addImage(exerciseId: 'exercise-1', mediaAssetId: 'media-6'),
throwsA(isA<DomainException>()),
);
});
test('Exercise image gallery reorders images', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
final updated = await useCase.reorderImages(
exerciseId: 'exercise-1',
orderedMediaIds: const ['media-3', 'media-1', 'media-2'],
);
expect(updated.imageMediaIds, ['media-3', 'media-1', 'media-2']);
expect(repository.saved.single.imageMediaIds, updated.imageMediaIds);
});
test('Exercise image gallery removes an image', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
final updated = await useCase.removeImage(
exerciseId: 'exercise-1',
mediaAssetId: 'media-2',
);
expect(updated.imageMediaIds, ['media-1', 'media-3']);
expect(updated.imageMediaId, 'media-1');
});
test('Exercise icon can be set to an image from the gallery', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const ['media-1', 'media-2'],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
final updated = await useCase.setIcon(
exerciseId: 'exercise-1',
mediaAssetId: 'media-2',
);
expect(updated.iconMediaId, 'media-2');
expect(repository.saved.single.iconMediaId, 'media-2');
});
test('Exercise icon rejects images outside the gallery', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const ['media-1'],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
await expectLater(
useCase.setIcon(exerciseId: 'exercise-1', mediaAssetId: 'media-2'),
throwsA(isA<DomainException>()),
);
expect(repository.saved, isEmpty);
});
test('Exercise icon is reset when its image is removed', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const ['media-1', 'media-2'],
iconMediaId: 'media-2',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
final updated = await useCase.removeImage(
exerciseId: 'exercise-1',
mediaAssetId: 'media-2',
);
expect(updated.imageMediaIds, ['media-1']);
expect(updated.iconMediaId, isNull);
expect(repository.saved.single.iconMediaId, isNull);
});
test('program exercise snapshot keeps the full image gallery', () async {
final exerciseRepository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
videoMediaId: 'video-1',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final programRepository = _FakeProgramRepository()
..programs.add(
Program(
metadata: _metadata('program-1'),
name: 'Jambes',
defaultRestSeconds: 60,
),
);
final useCase = ProgramUseCases(
programRepository: programRepository,
exerciseRepository: exerciseRepository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final snapshot = await useCase.addExercise(
programId: 'program-1',
exerciseId: 'exercise-1',
position: 0,
setsCount: 3,
enabledMeasures: const {WorkoutMeasure.reps},
);
expect(snapshot.exerciseImageMediaIdSnapshot, 'media-1');
expect(snapshot.exerciseImageMediaIdsSnapshot, [
'media-1',
'media-2',
'media-3',
]);
expect(snapshot.exerciseVideoMediaIdSnapshot, 'video-1');
expect(snapshot.toSnapshotJson()['imageMediaIdsSnapshot'], [
'media-1',
'media-2',
'media-3',
]);
expect(
programRepository
.programs
.single
.exercises
.single
.exerciseImageMediaIdsSnapshot,
snapshot.exerciseImageMediaIdsSnapshot,
);
});
test('delete exercise 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,
},
],
}),
},
],
}),
);
}