Files
GameTime/test/infrastructure/drift_repositories_test.dart
Blomios add5025dd2 feat(bibliotheque): tags et duplication profonde
Ticket #83 B1-B3 : tags JSON dénormalisés, migration Drift v19, mappings sync, helpers de filtre/suggestions, duplication profonde Program/WorkoutTemplate, tests associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:01:31 +02:00

2021 lines
64 KiB
Dart

import 'dart:convert';
import 'package:drift/drift.dart' as drift;
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;
void main() {
late local.AppDatabase database;
late local.DriftExerciseRepository exerciseRepository;
late local.DriftProgramRepository programRepository;
late local.DriftActiveSessionRepository activeRepository;
late local.DriftWorkoutTemplateRepository templateRepository;
late local.DriftWorkoutHistoryRepository historyRepository;
late local.DriftProgressionStatsRepository progressionStatsRepository;
late local.DriftLocalSyncChangeRepository syncChangeRepository;
late local.DriftExercisePerformanceReferenceRepository
performanceReferenceRepository;
setUp(() {
database = local.AppDatabase(NativeDatabase.memory());
exerciseRepository = local.DriftExerciseRepository(database);
programRepository = local.DriftProgramRepository(database);
activeRepository = local.DriftActiveSessionRepository(database);
templateRepository = local.DriftWorkoutTemplateRepository(database);
historyRepository = local.DriftWorkoutHistoryRepository(database);
progressionStatsRepository = local.DriftProgressionStatsRepository(
database,
);
syncChangeRepository = local.DriftLocalSyncChangeRepository(database);
performanceReferenceRepository =
local.DriftExercisePerformanceReferenceRepository(database);
});
tearDown(() async {
await database.close();
});
test('exercise mutations are written to change log', () async {
final now = DateTime.utc(2026, 7, 17, 12);
final exercise = Exercise(
metadata: _metadata('exercise-1', now),
name: 'Squat',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
await exerciseRepository.save(exercise);
await exerciseRepository.save(
exercise.copyWith(
metadata: _metadata(
'exercise-1',
now.add(const Duration(seconds: 1)),
1,
),
name: 'Front squat',
),
);
await exerciseRepository.save(
exercise
.copyWith(
metadata: _metadata(
'exercise-1',
now.add(const Duration(seconds: 2)),
2,
),
name: 'Front squat',
)
.archive(now.add(const Duration(seconds: 2))),
);
final changes =
await (database.select(database.changeLogEntries)
..where((table) => table.entityId.equals('exercise-1'))
..orderBy([
(table) => drift.OrderingTerm.asc(table.localRevision),
]))
.get();
expect(changes.map((change) => change.operation), [
'insert',
'update',
'update',
]);
expect(changes.every((change) => change.entityType == 'Exercise'), isTrue);
});
test('exercise repository accepts zero manual default score', () async {
final exercise = Exercise(
metadata: _metadata('exercise-score-zero', DateTime.utc(2026, 7, 17, 12)),
name: 'Score nul',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreLabel: 'Score',
scoreUnit: 'pts',
defaultTargetScore: 0,
);
await exerciseRepository.save(exercise);
final restored = await exerciseRepository.findById(exercise.metadata.id);
expect(restored, isNotNull);
expect(restored!.defaultTargetScore, 0);
});
test('taggable tables expose tags json columns on fresh schema', () async {
Future<List<String>> columnNames(String tableName) async {
final rows = await database
.customSelect('PRAGMA table_info($tableName)')
.get();
return rows.map((row) => row.data['name'] as String).toList();
}
expect(await columnNames('exercises'), contains('tags_json'));
expect(await columnNames('programs'), contains('tags_json'));
expect(await columnNames('workout_templates'), contains('tags_json'));
expect(database.schemaVersion, 19);
});
test('repositories save and load normalized tags', () async {
final now = DateTime.utc(2026, 7, 22, 10);
await exerciseRepository.save(
Exercise(
metadata: _metadata('exercise-tags', now),
name: 'Shoot',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
tags: const [' Match ', 'Extérieur'],
),
);
await programRepository.save(
Program(
metadata: _metadata('program-tags', now),
name: 'Program',
defaultRestSeconds: 45,
tags: const ['Intense'],
),
);
await templateRepository.save(
WorkoutTemplate(
metadata: _metadata('template-tags', now),
name: 'Template',
tags: const ['Routine'],
),
);
final exercise = await exerciseRepository.findById('exercise-tags');
final program = await programRepository.findById('program-tags');
final template = await templateRepository.findById('template-tags');
expect(exercise!.tags, ['match', 'extérieur']);
expect(program!.tags, ['intense']);
expect(template!.tags, ['routine']);
});
test('local sync payload includes tags for taggable resources', () async {
final now = DateTime.utc(2026, 7, 22, 10, 30);
await exerciseRepository.save(
Exercise(
metadata: _metadata('exercise-sync-tags', now),
name: 'Shoot',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
tags: const ['match'],
),
);
await programRepository.save(
Program(
metadata: _metadata('program-sync-tags', now),
name: 'Program',
defaultRestSeconds: 45,
tags: const ['intense'],
),
);
await templateRepository.save(
WorkoutTemplate(
metadata: _metadata('template-sync-tags', now),
name: 'Template',
tags: const ['routine'],
),
);
final changes = await syncChangeRepository.listPendingChanges();
final payloadsById = {
for (final change in changes) change.item.clientId: change.item.payload,
};
expect(payloadsById['exercise-sync-tags']!['tags'], ['match']);
expect(payloadsById['program-sync-tags']!['tags'], ['intense']);
expect(payloadsById['template-sync-tags']!['tags'], ['routine']);
});
test('local sync pull defaults missing tags to empty lists', () async {
final now = DateTime.utc(2026, 7, 22, 11);
await syncChangeRepository.applyRemoteItem(
RemoteSyncedItem(
resourceType: SyncResourceType.exercise,
clientId: 'remote-exercise-no-tags',
serverId: 'server-exercise-no-tags',
schemaVersion: 1,
clientUpdatedAt: now,
serverUpdatedAt: now,
deletedAt: null,
payload: const {
'id': 'remote-exercise-no-tags',
'name': 'Remote exercise',
'hasTimeMeasure': false,
'hasRepsMeasure': true,
'hasScoreMeasure': false,
},
),
);
await syncChangeRepository.applyRemoteItem(
RemoteSyncedItem(
resourceType: SyncResourceType.program,
clientId: 'remote-program-no-tags',
serverId: 'server-program-no-tags',
schemaVersion: 1,
clientUpdatedAt: now,
serverUpdatedAt: now,
deletedAt: null,
payload: const {
'id': 'remote-program-no-tags',
'name': 'Remote program',
'defaultRestSeconds': 30,
},
),
);
await syncChangeRepository.applyRemoteItem(
RemoteSyncedItem(
resourceType: SyncResourceType.workoutTemplate,
clientId: 'remote-template-no-tags',
serverId: 'server-template-no-tags',
schemaVersion: 1,
clientUpdatedAt: now,
serverUpdatedAt: now,
deletedAt: null,
payload: const {
'id': 'remote-template-no-tags',
'name': 'Remote template',
},
),
);
final exercise = await exerciseRepository.findById(
'remote-exercise-no-tags',
);
final program = await programRepository.findById('remote-program-no-tags');
final template = await templateRepository.findById(
'remote-template-no-tags',
);
expect(exercise!.tags, isEmpty);
expect(program!.tags, isEmpty);
expect(template!.tags, isEmpty);
});
test('program duplication persists copied children through Drift', () async {
final now = DateTime.utc(2026, 7, 22, 11, 15);
await exerciseRepository.save(
Exercise(
metadata: _metadata('exercise-dup-source', now),
name: 'Shoot',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
),
);
await programRepository.replaceExercises(
Program(
metadata: _metadata('program-dup-source', now),
name: 'Programme tirs',
defaultRestSeconds: 30,
isExample: true,
tags: const ['match'],
exercises: [
_programExercise(
'program-exercise-dup-source',
now,
programId: 'program-dup-source',
position: 0,
),
],
),
now,
);
final useCase = ProgramUseCases(
programRepository: programRepository,
exerciseRepository: exerciseRepository,
templateRepository: templateRepository,
clock: _FakeClock(now.add(const Duration(minutes: 1))),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final copy = await useCase.duplicate('program-dup-source');
final restored = await programRepository.findById(copy.metadata.id);
expect(restored, isNotNull);
expect(restored!.name, 'Copie de Programme tirs');
expect(restored.isExample, isFalse);
expect(restored.tags, ['match']);
expect(
restored.exercises.single.metadata.id,
isNot('program-exercise-dup-source'),
);
expect(restored.exercises.single.programId, restored.metadata.id);
expect(restored.exercises.single.exerciseNameSnapshot, 'Exercise 0');
});
test(
'workout template duplication persists remapped overrides through Drift',
() async {
final now = DateTime.utc(2026, 7, 22, 11, 30);
await programRepository.save(
Program(
metadata: _metadata('program-template-source', now),
name: 'Program source',
defaultRestSeconds: 30,
),
);
await templateRepository.replaceComposition(
WorkoutTemplate(
metadata: _metadata('template-dup-source', now),
name: 'Prépa match',
lastStartedAt: now,
isExample: true,
tags: const ['intense'],
programs: [
WorkoutTemplateProgram(
metadata: _metadata('template-program-dup-source', now),
workoutTemplateId: 'template-dup-source',
sourceProgramId: 'program-template-source',
position: 0,
programNameSnapshot: 'Program source',
defaultRestSecondsSnapshot: 30,
programSnapshotJson: '{"exercises":[]}',
),
],
overrides: [
WorkoutTemplateExerciseOverride(
metadata: _metadata('template-override-dup-source', now),
workoutTemplateProgramId: 'template-program-dup-source',
snapshotProgramExerciseId: 'snapshot-exercise',
setsCountOverride: 4,
),
],
),
now,
);
final useCase = WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: _FakeClock(now.add(const Duration(minutes: 1))),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final copy = await useCase.duplicate('template-dup-source');
final restored = await templateRepository.findById(copy.metadata.id);
expect(restored, isNotNull);
expect(restored!.name, 'Copie de Prépa match');
expect(restored.lastStartedAt, isNull);
expect(restored.isExample, isFalse);
expect(restored.tags, ['intense']);
expect(
restored.programs.single.metadata.id,
isNot('template-program-dup-source'),
);
expect(restored.programs.single.workoutTemplateId, restored.metadata.id);
expect(
restored.overrides.single.metadata.id,
isNot('template-override-dup-source'),
);
expect(
restored.overrides.single.workoutTemplateProgramId,
restored.programs.single.metadata.id,
);
expect(
restored.overrides.single.snapshotProgramExerciseId,
'snapshot-exercise',
);
},
);
test('starter seed populates a fresh database once', () async {
final seedRepository = local.DriftStarterSeedRepository(database);
final result = await SeedStarterContentUseCase(
seedStateRepository: seedRepository,
contentRepository: seedRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 21, 8)),
originDeviceId: 'local-device',
).run();
expect(result.status, StarterSeedStatus.inserted);
expect(await seedRepository.readAppliedStarterSeedVersion(), 1);
final exercises = await exerciseRepository.listActive();
final programs = await programRepository.listActive();
final templates = await templateRepository.listActive();
expect(exercises, hasLength(21));
expect(programs, hasLength(1));
expect(templates, hasLength(1));
expect(exercises.every((exercise) => exercise.isExample), isTrue);
expect(programs.single.isExample, isTrue);
expect(templates.single.isExample, isTrue);
expect(exercises.map((exercise) => exercise.category).toSet(), {
ExerciseCategory.shoot,
ExerciseCategory.freeThrows,
ExerciseCategory.dribble,
ExerciseCategory.finishing,
ExerciseCategory.conditioning,
ExerciseCategory.defense,
ExerciseCategory.mobility,
});
final secondRun = await SeedStarterContentUseCase(
seedStateRepository: seedRepository,
contentRepository: seedRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 21, 9)),
originDeviceId: 'local-device',
).run();
expect(secondRun.status, StarterSeedStatus.skippedAlreadyApplied);
expect(await exerciseRepository.listActive(), hasLength(21));
});
test(
'starter seed marks non-empty database without inserting examples',
() async {
final now = DateTime.utc(2026, 7, 21, 8);
await exerciseRepository.save(
Exercise(
metadata: _metadata('user-exercise-1', now),
name: 'Exercice utilisateur',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
),
);
final seedRepository = local.DriftStarterSeedRepository(database);
final result = await SeedStarterContentUseCase(
seedStateRepository: seedRepository,
contentRepository: seedRepository,
clock: _FakeClock(now),
originDeviceId: 'local-device',
).run();
expect(result.status, StarterSeedStatus.skippedNotEmpty);
expect(await seedRepository.readAppliedStarterSeedVersion(), 1);
expect(await exerciseRepository.listActive(), hasLength(1));
expect(await programRepository.listActive(), isEmpty);
expect(await templateRepository.listActive(), isEmpty);
},
);
test('starter seed does not reappear after example deletion', () async {
final seedRepository = local.DriftStarterSeedRepository(database);
await SeedStarterContentUseCase(
seedStateRepository: seedRepository,
contentRepository: seedRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 21, 8)),
originDeviceId: 'local-device',
).run();
final exercise = (await exerciseRepository.listActive()).first;
await exerciseRepository.save(
exercise.copyWith(
metadata: exercise.metadata.markDeleted(DateTime.utc(2026, 7, 21, 9)),
),
);
await SeedStarterContentUseCase(
seedStateRepository: seedRepository,
contentRepository: seedRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 21, 10)),
originDeviceId: 'local-device',
).run();
expect(await exerciseRepository.listActive(), hasLength(20));
expect(await exerciseRepository.findById(exercise.metadata.id), isNotNull);
});
test(
'starter program and workout template snapshot exercise steps',
() async {
final seedRepository = local.DriftStarterSeedRepository(database);
await SeedStarterContentUseCase(
seedStateRepository: seedRepository,
contentRepository: seedRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 21, 8)),
originDeviceId: 'local-device',
).run();
final program = (await programRepository.listActive()).single;
expect(program.name, 'Fondations basket - 45 min');
expect(program.exercises, hasLength(8));
expect(
program.exercises
.map((exercise) => exercise.setsCount)
.reduce((total, count) => total + count),
22,
);
final spotShooting = program.exercises.singleWhere(
(exercise) =>
exercise.exerciseNameSnapshot == 'Spot shooting 5 positions',
);
expect(spotShooting.exerciseStepsSnapshot, hasLength(5));
expect(spotShooting.exerciseStepsSnapshot.map((step) => step.name), [
'Coin droit',
'Aile droite',
'Face cercle',
'Aile gauche',
'Coin gauche',
]);
final template = (await templateRepository.listActive()).single;
expect(template.name, 'Séance exemple - Fondations basket');
expect(template.programs, hasLength(1));
expect(template.programs.single.sourceProgramId, program.metadata.id);
final snapshot =
jsonDecode(template.programs.single.programSnapshotJson)
as Map<String, Object?>;
expect(snapshot['programId'], program.metadata.id);
expect(snapshot['name'], program.name);
expect(snapshot['exercises'], isA<List<Object?>>());
expect(snapshot['exercises'] as List<Object?>, hasLength(8));
},
);
test(
'step chaining settings round-trip through drift repositories',
() async {
final now = DateTime.utc(2026, 7, 17, 12);
final exercise = Exercise(
metadata: _metadata('exercise-chain-1', now),
name: 'Circuit',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
autoStartNextTimedStep: false,
);
await exerciseRepository.save(exercise);
final restoredExercise = await exerciseRepository.findById(
exercise.metadata.id,
);
expect(restoredExercise!.autoStartNextTimedStep, isFalse);
final programExercise = _programExercise(
'program-exercise-chain-1',
now,
programId: 'program-chain-1',
position: 0,
autoStartNextTimedStepSnapshot: false,
autoStartNextTimedStepOverride: true,
);
await programRepository.save(
Program(
metadata: _metadata('program-chain-1', now),
name: 'Programme',
defaultRestSeconds: 60,
exercises: [programExercise],
),
);
final restoredProgram = await programRepository.findById(
'program-chain-1',
);
expect(
restoredProgram!.exercises.single.autoStartNextTimedStepSnapshot,
isFalse,
);
expect(
restoredProgram.exercises.single.autoStartNextTimedStepOverride,
isTrue,
);
final templateProgram = WorkoutTemplateProgram(
metadata: _metadata('template-program-chain-1', now),
workoutTemplateId: 'template-chain-1',
sourceProgramId: 'program-chain-1',
position: 0,
programNameSnapshot: 'Programme',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({
'exercises': [programExercise.toSnapshotJson()],
}),
);
await templateRepository.save(
WorkoutTemplate(
metadata: _metadata('template-chain-1', now),
name: 'Séance',
programs: [templateProgram],
overrides: [
WorkoutTemplateExerciseOverride(
metadata: _metadata('override-chain-1', now),
workoutTemplateProgramId: templateProgram.metadata.id,
snapshotProgramExerciseId: programExercise.metadata.id,
),
WorkoutTemplateExerciseOverride(
metadata: _metadata('override-chain-2', now),
workoutTemplateProgramId: templateProgram.metadata.id,
snapshotProgramExerciseId: 'program-exercise-chain-2',
autoStartNextTimedStepOverride: false,
),
],
),
);
final restoredTemplate = await templateRepository.findById(
'template-chain-1',
);
expect(
restoredTemplate!.overrides
.singleWhere(
(override) => override.metadata.id == 'override-chain-1',
)
.autoStartNextTimedStepOverride,
isNull,
);
expect(
restoredTemplate.overrides
.singleWhere(
(override) => override.metadata.id == 'override-chain-2',
)
.autoStartNextTimedStepOverride,
isFalse,
);
},
);
test('exercise repository round-trips configured steps', () async {
final now = DateTime.utc(2026, 7, 17, 12);
final exercise = Exercise(
metadata: _metadata('exercise-steps-1', now),
name: 'Burpee complexe',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 1,
steps: [
ExerciseStep(
id: 'step-1',
position: 0,
name: 'Planche',
type: ExerciseStepType.time,
defaultTargetValue: 20,
),
ExerciseStep(
id: 'step-2',
position: 1,
name: 'Sauts',
type: ExerciseStepType.reps,
defaultTargetValue: 8,
hasScore: true,
scoreLabel: 'Amplitude',
scoreUnit: 'pts',
defaultTargetScore: 0,
),
ExerciseStep(
id: 'step-3',
position: 2,
name: 'Sprint final',
type: ExerciseStepType.time,
defaultTargetValue: 10,
hasScore: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetScoreTimeMs: 12000,
),
],
);
await exerciseRepository.save(exercise);
final restored = await exerciseRepository.findById(exercise.metadata.id);
expect(restored, isNotNull);
expect(restored!.steps, hasLength(3));
expect(restored.steps.map((step) => step.name), [
'Planche',
'Sauts',
'Sprint final',
]);
expect(restored.steps[1].defaultTargetScore, 0);
expect(restored.steps[2].scoreInputMode, ScoreInputMode.stopwatch);
expect(restored.steps[2].defaultTargetScoreTimeMs, 12000);
});
group('exercise repository round-trips exercise option combinations', () {
final now = DateTime.utc(2026, 7, 17, 12);
final cases = <_ExerciseRoundTripCase>[
_ExerciseRoundTripCase(
label: 'reps without score or steps',
exercise: Exercise(
metadata: _metadata('exercise-combo-reps', now),
name: 'Pompes',
description: 'Simple reps target',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 12,
),
),
_ExerciseRoundTripCase(
label: 'reps without score and with steps',
exercise: Exercise(
metadata: _metadata('exercise-combo-reps-steps', now),
name: 'Complexe poids du corps',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 1,
steps: [
_exerciseStep(
id: 'combo-reps-step-1',
position: 0,
name: 'Pompes',
type: ExerciseStepType.reps,
defaultTargetValue: 10,
),
_exerciseStep(
id: 'combo-reps-step-2',
position: 1,
name: 'Gainage',
type: ExerciseStepType.time,
defaultTargetValue: 30,
),
],
),
),
_ExerciseRoundTripCase(
label: 'manual score without steps',
exercise: Exercise(
metadata: _metadata('exercise-combo-manual-score', now),
name: 'Charge max',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreLabel: 'Charge',
scoreUnit: 'kg',
defaultTargetScore: 0,
),
),
_ExerciseRoundTripCase(
label: 'manual score with unscored steps',
exercise: Exercise(
metadata: _metadata('exercise-combo-manual-score-steps', now),
name: 'Technique haltères',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Charge',
scoreUnit: 'kg',
defaultTargetReps: 8,
defaultTargetScore: 12.5,
steps: [
_exerciseStep(
id: 'combo-manual-score-step-1',
position: 0,
name: 'Installation',
type: ExerciseStepType.time,
defaultTargetValue: 15,
),
_exerciseStep(
id: 'combo-manual-score-step-2',
position: 1,
name: 'Serie',
type: ExerciseStepType.reps,
defaultTargetValue: 8,
),
],
),
),
_ExerciseRoundTripCase(
label: 'manual exercise score with manual scored steps',
exercise: Exercise(
metadata: _metadata('exercise-combo-manual-step-score', now),
name: 'Circuit precision',
hasTimeMeasure: true,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Qualite',
scoreUnit: 'pts',
defaultTargetTimeSeconds: 45,
defaultTargetReps: 10,
defaultTargetScore: 80,
autoStartNextTimedStep: false,
steps: [
_exerciseStep(
id: 'combo-manual-step-score-1',
position: 0,
name: 'Bloc reps',
type: ExerciseStepType.reps,
defaultTargetValue: 10,
hasScore: true,
scoreLabel: 'Amplitude',
scoreUnit: 'pts',
defaultTargetScore: 0,
),
_exerciseStep(
id: 'combo-manual-step-score-2',
position: 1,
name: 'Bloc temps',
type: ExerciseStepType.time,
defaultTargetValue: 35,
hasScore: true,
scoreLabel: 'Tenue',
scoreUnit: 's',
defaultTargetScore: 35,
),
],
),
),
_ExerciseRoundTripCase(
label: 'stopwatch score without steps',
exercise: Exercise(
metadata: _metadata('exercise-combo-stopwatch-score', now),
name: 'Sprint chrono',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetTimeSeconds: 20,
defaultTargetScoreTimeMs: 11500,
),
),
_ExerciseRoundTripCase(
label: 'stopwatch score with stopwatch scored steps',
exercise: Exercise(
metadata: _metadata('exercise-combo-stopwatch-score-steps', now),
name: 'Sprint fractionne',
imageMediaIds: const ['media-start', 'media-finish'],
iconMediaId: 'media-start',
videoMediaId: 'video-demo',
hasTimeMeasure: true,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetTimeSeconds: 60,
defaultTargetReps: 4,
defaultTargetScoreTimeMs: 42000,
steps: [
_exerciseStep(
id: 'combo-stopwatch-step-1',
position: 0,
name: 'Acceleration',
type: ExerciseStepType.time,
defaultTargetValue: 10,
hasScore: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetScoreTimeMs: 9500,
),
_exerciseStep(
id: 'combo-stopwatch-step-2',
position: 1,
name: 'Recuperation active',
type: ExerciseStepType.reps,
defaultTargetValue: 6,
),
],
),
),
];
for (final roundTripCase in cases) {
test(roundTripCase.label, () async {
await exerciseRepository.save(roundTripCase.exercise);
final restored = await exerciseRepository.findById(
roundTripCase.exercise.metadata.id,
);
expect(restored, isNotNull);
_expectExerciseEquals(restored!, roundTripCase.exercise);
});
}
});
test(
'program exercise step snapshot is preserved when source exercise changes',
() async {
final now = DateTime.utc(2026, 7, 17, 12);
final source = Exercise(
metadata: _metadata('exercise-source-1', now),
name: 'Complexe original',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 1,
steps: [
ExerciseStep(
id: 'step-original-1',
position: 0,
name: 'Phase originale A',
type: ExerciseStepType.time,
defaultTargetValue: 30,
),
ExerciseStep(
id: 'step-original-2',
position: 1,
name: 'Phase originale B',
type: ExerciseStepType.reps,
defaultTargetValue: 12,
),
],
);
await exerciseRepository.save(source);
await programRepository.save(
Program(
metadata: _metadata('program-steps-1', now),
name: 'Programme étapes',
defaultRestSeconds: 60,
),
);
final useCase = ProgramUseCases(
programRepository: programRepository,
exerciseRepository: exerciseRepository,
templateRepository: templateRepository,
clock: _FakeClock(now),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final snapshot = await useCase.addExercise(
programId: 'program-steps-1',
exerciseId: source.metadata.id,
position: 0,
setsCount: 1,
enabledMeasures: const {WorkoutMeasure.reps},
);
await exerciseRepository.save(
source.copyWith(
metadata: _metadata(
source.metadata.id,
now.add(const Duration(minutes: 1)),
1,
),
steps: [
ExerciseStep(
id: 'step-updated-1',
position: 0,
name: 'Phase modifiée',
type: ExerciseStepType.time,
defaultTargetValue: 45,
),
],
),
);
final restoredProgram = await programRepository.findById(
'program-steps-1',
);
final restoredSnapshot = restoredProgram!.exercises.single;
final snapshotJson = snapshot.toSnapshotJson();
expect(restoredSnapshot.exerciseStepsSnapshot, hasLength(2));
expect(
restoredSnapshot.exerciseStepsSnapshot.first.name,
'Phase originale A',
);
expect(
restoredSnapshot.exerciseStepsSnapshot.last.type,
ExerciseStepType.reps,
);
expect(
(snapshotJson['exerciseStepsSnapshot'] as List).map(
(step) => (step as Map)['name'],
),
['Phase originale A', 'Phase originale B'],
);
},
);
test(
'removing a program exercise writes a soft delete change log entry',
() async {
final now = DateTime.utc(2026, 7, 17, 12);
final first = _programExercise('program-exercise-1', now, position: 0);
final second = _programExercise('program-exercise-2', now, position: 1);
await programRepository.save(
Program(
metadata: _metadata('program-1', now),
name: 'Jambes',
defaultRestSeconds: 60,
exercises: [first, second],
),
);
final deletedAt = now.add(const Duration(minutes: 1));
await programRepository.replaceExercises(
Program(
metadata: _metadata('program-1', deletedAt, 1),
name: 'Jambes',
defaultRestSeconds: 60,
exercises: [first],
),
deletedAt,
);
final deletedRow = await (database.select(
database.programExercises,
)..where((table) => table.id.equals('program-exercise-2'))).getSingle();
final change =
await (database.select(database.changeLogEntries)..where(
(table) =>
table.entityType.equals('ProgramExercise') &
table.entityId.equals('program-exercise-2') &
table.operation.equals('softDelete'),
))
.getSingleOrNull();
expect(deletedRow.deletedAt?.toUtc(), deletedAt);
expect(deletedRow.syncState, 'deleted');
expect(deletedRow.localRevision, 1);
expect(change, isNotNull);
expect(change!.localRevision, 1);
},
);
test(
'running session elapsed time survives repository reconstruction',
() async {
final persistedAt = DateTime.utc(2026, 7, 17, 12);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1', persistedAt),
sourceWorkoutTemplateId: null,
status: ActiveWorkoutStatus.running,
startedAt: persistedAt.subtract(const Duration(seconds: 30)),
lastPersistedAt: persistedAt,
elapsedActiveMs: 30000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
);
await activeRepository.save(session);
final afterAppKillRepository = local.DriftActiveSessionRepository(
database,
);
final afterAppKillUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: afterAppKillRepository,
templateRepository: templateRepository,
clock: _FakeClock(persistedAt.add(const Duration(seconds: 20))),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final restored = await afterAppKillRepository.findOpen();
expect(restored, isNotNull);
expect(afterAppKillUseCases.elapsedActiveMilliseconds(restored!), 50000);
},
);
test(
'closing a session stores autonomous history rows with set snapshots',
() async {
final now = DateTime.utc(2026, 7, 17, 12);
final template = WorkoutTemplate(
metadata: _metadata('template-1', now),
name: 'Séance jambes',
);
await templateRepository.save(template);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1', now),
sourceWorkoutTemplateId: template.metadata.id,
status: ActiveWorkoutStatus.running,
startedAt: now.subtract(const Duration(minutes: 10)),
lastPersistedAt: now,
elapsedActiveMs: 600000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
);
await activeRepository.save(session);
await activeRepository.saveSetResult(
ActiveSetResult(
metadata: _metadata('active-result-1', now),
activeWorkoutSessionId: session.metadata.id,
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualTimeMs: 45000,
actualReps: 10,
actualScore: 80,
scoreLabelSnapshot: 'Charge',
scoreUnitSnapshot: 'kg',
completedAt: now,
),
);
final closeUseCase = CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: _FakeClock(now.add(const Duration(seconds: 5))),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final history = await closeUseCase.close(
sessionId: session.metadata.id,
nameSnapshot: 'Séance jambes',
completed: true,
);
await templateRepository.save(
WorkoutTemplate(
metadata: template.metadata.markDeleted(now),
name: template.name,
),
);
final restored = await historyRepository.findById(history.metadata.id);
expect(restored, isNotNull);
expect(restored!.nameSnapshot, 'Séance jambes');
expect(restored.results, hasLength(1));
expect(restored.results.single.programNameSnapshot, 'Programme jambes');
expect(restored.results.single.exerciseNameSnapshot, 'Squat');
expect(restored.results.single.actualTimeMs, 45000);
expect(restored.results.single.actualReps, 10);
expect(restored.results.single.actualScore, 80);
expect(restored.results.single.scoreUnitSnapshot, 'kg');
expect(restored.results.single.sourceExerciseIdSnapshot, 'exercise-1');
},
);
test(
'performance reference ignores skipped and null values for latest set',
() async {
final now = DateTime.utc(2026, 7, 22, 10);
await historyRepository.save(
_history(
id: 'history-old',
startedAt: now.subtract(const Duration(days: 2)),
result: _historySetResult(
id: 'result-old',
historyId: 'history-old',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now.subtract(const Duration(days: 2)),
actualReps: 8,
),
),
);
await historyRepository.save(
_history(
id: 'history-new',
startedAt: now,
results: [
_historySetResult(
id: 'result-skipped',
historyId: 'history-new',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now,
status: SetResultStatus.skipped,
),
_historySetResult(
id: 'result-null',
historyId: 'history-new',
sourceExerciseId: 'exercise-1',
setIndex: 1,
startedAt: now,
),
_historySetResult(
id: 'result-value',
historyId: 'history-new',
sourceExerciseId: 'exercise-1',
setIndex: 2,
startedAt: now,
actualReps: 11,
),
],
),
);
final latest = await performanceReferenceRepository
.findLatestSetPerformance(
exerciseId: 'exercise-1',
activeMeasures: const ActivePerformanceMeasures(
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
),
currentSetIndex: 0,
);
expect(latest, isNotNull);
expect(latest!.workoutHistoryId, 'history-new');
expect(latest.setIndex, 2);
expect(latest.actualReps, 11);
},
);
test('performance reference uses same set when available', () async {
final now = DateTime.utc(2026, 7, 22, 11);
await historyRepository.save(
_history(
id: 'history-sets',
startedAt: now,
results: [
_historySetResult(
id: 'result-set-0',
historyId: 'history-sets',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now,
actualReps: 7,
),
_historySetResult(
id: 'result-set-1',
historyId: 'history-sets',
sourceExerciseId: 'exercise-1',
setIndex: 1,
startedAt: now,
actualReps: 9,
),
],
),
);
final latest = await performanceReferenceRepository
.findLatestSetPerformance(
exerciseId: 'exercise-1',
activeMeasures: const ActivePerformanceMeasures(
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
),
currentSetIndex: 0,
);
expect(latest, isNotNull);
expect(latest!.setIndex, 0);
expect(latest.actualReps, 7);
});
test('performance reference finds records by metric rules', () async {
final now = DateTime.utc(2026, 7, 22, 12);
await historyRepository.save(
_history(
id: 'history-records',
startedAt: now,
results: [
_historySetResult(
id: 'result-reps-low',
historyId: 'history-records',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now,
actualTimeMs: 30000,
actualReps: 6,
actualScore: 15,
),
_historySetResult(
id: 'result-reps-high',
historyId: 'history-records',
sourceExerciseId: 'exercise-1',
setIndex: 1,
startedAt: now,
actualTimeMs: 45000,
actualReps: 12,
actualScore: 20,
),
_historySetResult(
id: 'result-stopwatch-slow',
historyId: 'history-records',
sourceExerciseId: 'exercise-1',
setIndex: 2,
startedAt: now,
scoreInputMode: ScoreInputMode.stopwatch,
actualScoreTimeMs: 11000,
),
_historySetResult(
id: 'result-stopwatch-fast',
historyId: 'history-records',
sourceExerciseId: 'exercise-1',
setIndex: 3,
startedAt: now,
scoreInputMode: ScoreInputMode.stopwatch,
actualScoreTimeMs: 9000,
),
],
),
);
final reps = await performanceReferenceRepository.findBestMetricPerformance(
exerciseId: 'exercise-1',
metric: PerformanceMetric.reps,
scoreInputMode: ScoreInputMode.manual,
);
final time = await performanceReferenceRepository.findBestMetricPerformance(
exerciseId: 'exercise-1',
metric: PerformanceMetric.time,
scoreInputMode: ScoreInputMode.manual,
);
final manualScore = await performanceReferenceRepository
.findBestMetricPerformance(
exerciseId: 'exercise-1',
metric: PerformanceMetric.score,
scoreInputMode: ScoreInputMode.manual,
);
final stopwatchScore = await performanceReferenceRepository
.findBestMetricPerformance(
exerciseId: 'exercise-1',
metric: PerformanceMetric.score,
scoreInputMode: ScoreInputMode.stopwatch,
);
expect(reps!.actualReps, 12);
expect(time!.actualTimeMs, 45000);
expect(manualScore!.actualScore, 20);
expect(stopwatchScore!.actualScoreTimeMs, 9000);
});
test('performance reference matches archived source exercise id', () async {
final now = DateTime.utc(2026, 7, 22, 13);
await exerciseRepository.save(
Exercise(
metadata: _metadata('exercise-archived', now),
name: 'Archived drill',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
archivedAt: now,
),
);
await historyRepository.save(
_history(
id: 'history-archived',
startedAt: now,
result: _historySetResult(
id: 'result-archived',
historyId: 'history-archived',
sourceExerciseId: 'exercise-archived',
setIndex: 0,
startedAt: now,
actualReps: 13,
),
),
);
expect(
await performanceReferenceRepository.hasAnyCompletedHistoryForExercise(
'exercise-archived',
),
isTrue,
);
final latest = await performanceReferenceRepository
.findLatestSetPerformance(
exerciseId: 'exercise-archived',
activeMeasures: const ActivePerformanceMeasures(
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
),
currentSetIndex: 0,
);
expect(latest!.actualReps, 13);
});
test(
'progression global stats count active weeks and ignore inactive rows',
() async {
final now = DateTime.utc(2026, 7, 22, 13);
await historyRepository.save(
_history(
id: 'progression-week-1',
startedAt: now.subtract(const Duration(days: 1)),
totalActiveMs: 120000,
result: _historySetResult(
id: 'progression-result-1',
historyId: 'progression-week-1',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now.subtract(const Duration(days: 1)),
actualReps: 10,
),
),
);
await historyRepository.save(
_history(
id: 'progression-week-2',
startedAt: now.subtract(const Duration(days: 8)),
totalActiveMs: 180000,
result: _historySetResult(
id: 'progression-result-2',
historyId: 'progression-week-2',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now.subtract(const Duration(days: 8)),
actualReps: 8,
),
),
);
await historyRepository.save(
_history(
id: 'progression-incomplete',
startedAt: now,
completed: false,
result: _historySetResult(
id: 'progression-result-incomplete',
historyId: 'progression-incomplete',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now,
actualReps: 99,
),
),
);
await historyRepository.save(
_history(
id: 'progression-deleted',
startedAt: now.subtract(const Duration(days: 3)),
totalActiveMs: 900000,
result: _historySetResult(
id: 'progression-result-deleted',
historyId: 'progression-deleted',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now.subtract(const Duration(days: 3)),
actualReps: 20,
),
),
);
await historyRepository.delete('progression-deleted', now);
final stats = await progressionStatsRepository.readGlobalStats(
ProgressionDateRange(
startedAt: now.subtract(const Duration(days: 28)),
endedAt: now,
),
);
expect(stats.completedSessionCount, 2);
expect(stats.totalActiveMs, 300000);
expect(stats.activeWeekStarts, hasLength(2));
expect(stats.hasAnyCompletedHistory, isTrue);
},
);
test(
'progression lists exercises, archive state and measure options',
() async {
final now = DateTime.utc(2026, 7, 22, 14);
await exerciseRepository.save(
Exercise(
metadata: _metadata('exercise-active', now),
name: 'Active drill',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
),
);
await exerciseRepository.save(
Exercise(
metadata: _metadata('exercise-archived', now),
name: 'Archived drill',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
archivedAt: now,
),
);
await historyRepository.save(
_history(
id: 'progression-options',
startedAt: now,
results: [
_historySetResult(
id: 'progression-active-score',
historyId: 'progression-options',
sourceExerciseId: 'exercise-active',
setIndex: 0,
startedAt: now,
actualScore: 12,
),
_historySetResult(
id: 'progression-active-chrono',
historyId: 'progression-options',
sourceExerciseId: 'exercise-active',
setIndex: 1,
startedAt: now,
scoreInputMode: ScoreInputMode.stopwatch,
actualScoreTimeMs: 42000,
),
_historySetResult(
id: 'progression-archived-reps',
historyId: 'progression-options',
sourceExerciseId: 'exercise-archived',
setIndex: 2,
startedAt: now,
actualReps: 15,
),
_historySetResult(
id: 'progression-skipped',
historyId: 'progression-options',
sourceExerciseId: 'exercise-skipped',
setIndex: 3,
startedAt: now,
status: SetResultStatus.skipped,
),
_historySetResult(
id: 'progression-absent-value',
historyId: 'progression-options',
sourceExerciseId: 'exercise-absent',
setIndex: 4,
startedAt: now,
),
],
),
);
final range = ProgressionDateRange(
startedAt: now.subtract(const Duration(days: 1)),
endedAt: now,
);
final options = await progressionStatsRepository.listExerciseOptions(
range,
);
final activeMeasures = await progressionStatsRepository
.listMeasureOptions(range: range, exerciseKey: 'exercise-active');
expect(options.map((option) => option.exerciseKey), [
'exercise-active',
'exercise-archived',
]);
expect(options.first.isArchived, isFalse);
expect(options.last.isArchived, isTrue);
expect(activeMeasures.map((option) => option.measure), [
ProgressionMeasure.manualScore,
ProgressionMeasure.stopwatchScore,
]);
expect(activeMeasures.first.scoreLabel, 'Score');
expect(activeMeasures.first.scoreUnit, 'pts');
expect(activeMeasures.last.lowerIsBetter, isTrue);
},
);
test(
'progression series reports completed exercise rows without graphable value',
() async {
final now = DateTime.utc(2026, 7, 22, 14, 30);
await historyRepository.save(
_history(
id: 'progression-no-graphable',
startedAt: now,
result: _historySetResult(
id: 'progression-no-graphable-result',
historyId: 'progression-no-graphable',
sourceExerciseId: 'exercise-no-graphable',
setIndex: 0,
startedAt: now,
),
),
);
final series = await progressionStatsRepository.readExerciseSeries(
range: ProgressionDateRange(
startedAt: now.subtract(const Duration(days: 1)),
endedAt: now,
),
exerciseKey: 'exercise-no-graphable',
measure: ProgressionMeasure.reps,
);
expect(series.points, isEmpty);
expect(series.hasAnyAllTimeData, isFalse);
expect(series.hasAnyCompletedExerciseResult, isTrue);
},
);
test(
'progression exercise series aggregate each measure by session',
() async {
final now = DateTime.utc(2026, 7, 22, 15);
await historyRepository.save(
_history(
id: 'progression-series-old',
startedAt: now.subtract(const Duration(days: 2)),
results: [
_historySetResult(
id: 'progression-old-score-low',
historyId: 'progression-series-old',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now.subtract(const Duration(days: 2)),
actualScore: 7,
),
_historySetResult(
id: 'progression-old-score-high',
historyId: 'progression-series-old',
sourceExerciseId: 'exercise-1',
setIndex: 1,
startedAt: now.subtract(const Duration(days: 2)),
actualScore: 9,
actualReps: 4,
actualTimeMs: 10000,
),
],
),
);
await historyRepository.save(
_history(
id: 'progression-series-new',
startedAt: now,
results: [
_historySetResult(
id: 'progression-new-score',
historyId: 'progression-series-new',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now,
actualScore: 11,
actualReps: 6,
actualTimeMs: 12000,
),
_historySetResult(
id: 'progression-new-chrono-slow',
historyId: 'progression-series-new',
sourceExerciseId: 'exercise-1',
setIndex: 1,
startedAt: now,
scoreInputMode: ScoreInputMode.stopwatch,
actualScoreTimeMs: 45000,
),
_historySetResult(
id: 'progression-new-chrono-fast',
historyId: 'progression-series-new',
sourceExerciseId: 'exercise-1',
setIndex: 2,
startedAt: now,
scoreInputMode: ScoreInputMode.stopwatch,
actualScoreTimeMs: 42000,
),
],
),
);
final range = ProgressionDateRange(
startedAt: now.subtract(const Duration(days: 7)),
endedAt: now,
);
final score = await progressionStatsRepository.readExerciseSeries(
range: range,
exerciseKey: 'exercise-1',
measure: ProgressionMeasure.manualScore,
);
final chrono = await progressionStatsRepository.readExerciseSeries(
range: range,
exerciseKey: 'exercise-1',
measure: ProgressionMeasure.stopwatchScore,
);
final reps = await progressionStatsRepository.readExerciseSeries(
range: range,
exerciseKey: 'exercise-1',
measure: ProgressionMeasure.reps,
);
final time = await progressionStatsRepository.readExerciseSeries(
range: range,
exerciseKey: 'exercise-1',
measure: ProgressionMeasure.time,
);
expect(score.points.map((point) => point.rawValue), [9.0, 11.0]);
expect(chrono.points.single.rawValue, 42000);
expect(reps.points.map((point) => point.rawValue), [4, 6]);
expect(time.points.map((point) => point.rawValue), [10000, 12000]);
expect(score.points.first.workoutHistoryId, 'progression-series-old');
},
);
test('progression reports all-time data outside selected period', () async {
final now = DateTime.utc(2026, 7, 22, 16);
await historyRepository.save(
_history(
id: 'progression-all-time',
startedAt: now.subtract(const Duration(days: 60)),
result: _historySetResult(
id: 'progression-all-time-score',
historyId: 'progression-all-time',
sourceExerciseId: 'exercise-1',
setIndex: 0,
startedAt: now.subtract(const Duration(days: 60)),
actualScore: 10,
),
),
);
final series = await progressionStatsRepository.readExerciseSeries(
range: ProgressionDateRange(
startedAt: now.subtract(const Duration(days: 7)),
endedAt: now,
),
exerciseKey: 'exercise-1',
measure: ProgressionMeasure.manualScore,
);
expect(series.points, isEmpty);
expect(series.hasAnyAllTimeData, isTrue);
});
test(
'performance reference use case prioritizes record score metric',
() async {
final repository = _FakePerformanceReferenceRepository();
final useCase = ExercisePerformanceReferenceUseCase(
repository: repository,
);
final reference = await useCase.getExercisePerformanceReference(
exerciseId: 'exercise-1',
activeMeasures: const ActivePerformanceMeasures(
timeEnabled: true,
repsEnabled: true,
scoreEnabled: true,
),
currentSetIndex: 0,
);
expect(reference.hasAnyHistoryForExercise, isTrue);
expect(repository.requestedMetric, PerformanceMetric.score);
},
);
}
String _resolvedSnapshot() {
return jsonEncode({
'name': 'Séance jambes',
'programs': [
{
'id': 'program-snapshot-1',
'programNameSnapshot': 'Programme jambes',
'programSnapshotJson': jsonEncode({
'exercises': [
{
'id': 'exercise-snapshot-1',
'sourceExerciseId': 'exercise-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': 1,
'timeEnabled': true,
'repsEnabled': true,
'scoreEnabled': true,
'targetTimeSeconds': 45,
'targetReps': 10,
'targetScore': 80,
'scoreLabelSnapshot': 'Charge',
'scoreUnitSnapshot': 'kg',
'restSecondsOverride': 0,
},
],
}),
},
],
'overrides': const [],
});
}
EntityMetadata _metadata(String id, DateTime now, [int localRevision = 0]) {
return EntityMetadata(
id: id,
createdAt: now,
updatedAt: now,
originDeviceId: 'device-1',
localRevision: localRevision,
);
}
WorkoutHistory _history({
required String id,
required DateTime startedAt,
WorkoutHistorySetResult? result,
List<WorkoutHistorySetResult>? results,
bool completed = true,
int totalActiveMs = 300000,
}) {
return WorkoutHistory(
metadata: _metadata(id, startedAt),
nameSnapshot: id,
startedAt: startedAt,
endedAt: startedAt.add(const Duration(minutes: 5)),
totalActiveMs: totalActiveMs,
completed: completed,
historySnapshotJson: '{"name":"$id"}',
results: results ?? [result!],
);
}
WorkoutHistorySetResult _historySetResult({
required String id,
required String historyId,
required String sourceExerciseId,
required int setIndex,
required DateTime startedAt,
int? actualTimeMs,
int? actualReps,
double? actualScore,
int? actualScoreTimeMs,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
SetResultStatus status = SetResultStatus.completed,
}) {
final scoreEnabled = actualScore != null || actualScoreTimeMs != null;
return WorkoutHistorySetResult(
metadata: _metadata(id, startedAt),
workoutHistoryId: historyId,
programSnapshotId: 'program-snapshot',
exerciseSnapshotId: 'exercise-snapshot-$sourceExerciseId',
programIndex: 0,
exerciseIndex: 0,
setIndex: setIndex,
programNameSnapshot: 'Program',
exerciseNameSnapshot: 'Exercise',
timeEnabledSnapshot: actualTimeMs != null,
repsEnabledSnapshot:
actualReps != null || (actualTimeMs == null && !scoreEnabled),
scoreEnabledSnapshot: scoreEnabled,
actualTimeMs: actualTimeMs,
actualReps: actualReps,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
scoreInputModeSnapshot: scoreInputMode,
scoreLabelSnapshot: scoreEnabled && scoreInputMode == ScoreInputMode.manual
? 'Score'
: null,
scoreUnitSnapshot: scoreEnabled && scoreInputMode == ScoreInputMode.manual
? 'pts'
: null,
sourceExerciseIdSnapshot: sourceExerciseId,
completedAt: status == SetResultStatus.completed
? startedAt.add(const Duration(minutes: 1))
: null,
status: status,
);
}
ProgramExercise _programExercise(
String id,
DateTime now, {
String programId = 'program-1',
required int position,
bool autoStartNextTimedStepSnapshot = true,
bool? autoStartNextTimedStepOverride,
}) {
return ProgramExercise(
metadata: _metadata(id, now),
programId: programId,
position: position,
exerciseNameSnapshot: 'Exercise $position',
autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride: autoStartNextTimedStepOverride,
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: false,
setsCount: 1,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
);
}
ExerciseStep _exerciseStep({
required String id,
required int position,
required String name,
required ExerciseStepType type,
required int defaultTargetValue,
bool hasScore = false,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
String? scoreLabel,
String? scoreUnit,
double? defaultTargetScore,
int? defaultTargetScoreTimeMs,
}) {
return ExerciseStep(
id: id,
position: position,
name: name,
type: type,
defaultTargetValue: defaultTargetValue,
hasScore: hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
);
}
void _expectExerciseEquals(Exercise actual, Exercise expected) {
_expectMetadataEquals(actual.metadata, expected.metadata);
expect(actual.name, expected.name);
expect(actual.description, expected.description);
expect(actual.imageMediaIds, expected.imageMediaIds);
expect(actual.iconMediaId, expected.iconMediaId);
expect(actual.videoMediaId, expected.videoMediaId);
expect(actual.hasTimeMeasure, expected.hasTimeMeasure);
expect(actual.hasRepsMeasure, expected.hasRepsMeasure);
expect(actual.hasScoreMeasure, expected.hasScoreMeasure);
expect(actual.scoreInputMode, expected.scoreInputMode);
expect(actual.scoreLabel, expected.scoreLabel);
expect(actual.scoreUnit, expected.scoreUnit);
expect(actual.defaultTargetTimeSeconds, expected.defaultTargetTimeSeconds);
expect(actual.defaultTargetReps, expected.defaultTargetReps);
expect(actual.defaultTargetScore, expected.defaultTargetScore);
expect(actual.defaultTargetScoreTimeMs, expected.defaultTargetScoreTimeMs);
expect(actual.autoStartNextTimedStep, expected.autoStartNextTimedStep);
expect(actual.archivedAt?.toUtc(), expected.archivedAt?.toUtc());
expect(actual.steps, hasLength(expected.steps.length));
for (var index = 0; index < expected.steps.length; index++) {
_expectExerciseStepEquals(actual.steps[index], expected.steps[index]);
}
}
void _expectExerciseStepEquals(ExerciseStep actual, ExerciseStep expected) {
expect(actual.id, expected.id);
expect(actual.position, expected.position);
expect(actual.name, expected.name);
expect(actual.type, expected.type);
expect(actual.defaultTargetValue, expected.defaultTargetValue);
expect(actual.hasScore, expected.hasScore);
expect(actual.scoreInputMode, expected.scoreInputMode);
expect(actual.scoreLabel, expected.scoreLabel);
expect(actual.scoreUnit, expected.scoreUnit);
expect(actual.defaultTargetScore, expected.defaultTargetScore);
expect(actual.defaultTargetScoreTimeMs, expected.defaultTargetScoreTimeMs);
}
void _expectMetadataEquals(EntityMetadata actual, EntityMetadata expected) {
expect(actual.id, expected.id);
expect(actual.createdAt.toUtc(), expected.createdAt.toUtc());
expect(actual.updatedAt.toUtc(), expected.updatedAt.toUtc());
expect(actual.deletedAt?.toUtc(), expected.deletedAt?.toUtc());
expect(actual.schemaVersion, expected.schemaVersion);
expect(actual.syncState, expected.syncState);
expect(actual.localRevision, expected.localRevision);
expect(actual.originDeviceId, expected.originDeviceId);
expect(actual.futureOwnerProfileId, expected.futureOwnerProfileId);
expect(actual.lastSyncedAt?.toUtc(), expected.lastSyncedAt?.toUtc());
expect(actual.remoteRevision, expected.remoteRevision);
}
final class _ExerciseRoundTripCase {
const _ExerciseRoundTripCase({required this.label, required this.exercise});
final String label;
final Exercise exercise;
}
final class _FakeClock implements Clock {
const _FakeClock(this.value);
final DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}
final class _FakePerformanceReferenceRepository
implements ExercisePerformanceReferenceRepository {
PerformanceMetric? requestedMetric;
@override
Future<bool> hasAnyCompletedHistoryForExercise(String exerciseId) async {
return true;
}
@override
Future<WorkoutHistorySetPerformance?> findLatestSetPerformance({
required String exerciseId,
required ActivePerformanceMeasures activeMeasures,
required int currentSetIndex,
}) async {
return null;
}
@override
Future<WorkoutHistoryMetricPerformance?> findBestMetricPerformance({
required String exerciseId,
required PerformanceMetric metric,
required ScoreInputMode scoreInputMode,
}) async {
requestedMetric = metric;
return WorkoutHistoryMetricPerformance(
workoutHistoryId: 'history-1',
startedAt: DateTime.utc(2026, 7, 22),
setIndex: 0,
exerciseNameSnapshot: 'Exercise',
metric: metric,
scoreInputModeSnapshot: scoreInputMode,
actualScore: 10,
);
}
}