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.DriftLocalDataBackupRepository localDataBackupRepository; 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); localDataBackupRepository = local.DriftLocalDataBackupRepository(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> 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('local backup export includes tags and full workout history', () async { final now = DateTime.utc(2026, 7, 22, 10); await exerciseRepository.save( Exercise( metadata: _metadata('backup-exercise', now), name: 'Backup Shoot', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, tags: const ['match'], ), ); await historyRepository.save( WorkoutHistory( metadata: _metadata('backup-history', now), nameSnapshot: 'Backup Session', startedAt: now, endedAt: now.add(const Duration(minutes: 10)), totalActiveMs: 600000, completed: true, historySnapshotJson: '{"name":"Backup Session"}', results: [ _historySetResult( id: 'backup-result', historyId: 'backup-history', sourceExerciseId: 'backup-exercise', setIndex: 0, startedAt: now, actualReps: 12, ), ], stepResults: [ _historyStepResult( id: 'backup-step-result', historyId: 'backup-history', sourceExerciseId: 'backup-exercise', startedAt: now, ), ], ), ); final snapshot = await localDataBackupRepository.readExportSnapshot( DateTime.utc(2026, 7, 22, 12), ); final document = jsonDecode(utf8.decode(const LocalBackupCodec().encode(snapshot))) as Map; final data = document['data'] as Map; final exercises = data['exercises'] as List; final histories = data['workoutHistories'] as List; final history = histories.single as Map; expect(document['kind'], 'gametime.localBackup'); expect(exercises.single, containsPair('tags', ['match'])); expect(history['results'], hasLength(1)); expect(history['stepResults'], hasLength(1)); }); test('local backup merge applies LWW by type and stable id', () async { final localTime = DateTime.utc(2026, 7, 22, 10); await exerciseRepository.save( Exercise( metadata: _metadata('merge-existing', localTime), name: 'Local older', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 8, ), ); await exerciseRepository.save( Exercise( metadata: _metadata( 'merge-local-newer', localTime.add(const Duration(hours: 3)), ), name: 'Local newer', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 8, ), ); final snapshot = LocalDataExportSnapshot( exportedAt: DateTime.utc(2026, 7, 22, 12), appSchemaVersion: 19, originDeviceId: 'device-backup', mediaAssets: const [], exercises: [ _backupExerciseResource( id: 'merge-existing', name: 'Backup newer', updatedAt: localTime.add(const Duration(hours: 2)), ), _backupExerciseResource( id: 'merge-local-newer', name: 'Backup older', updatedAt: localTime.add(const Duration(hours: 1)), ), _backupExerciseResource( id: 'merge-new', name: 'Backup inserted', updatedAt: localTime.add(const Duration(hours: 1)), ), ], programs: const [], workoutTemplates: const [], workoutHistories: const [], ); final result = await localDataBackupRepository.applyImportSnapshot( snapshot: snapshot, mode: LocalBackupImportMode.merge, importedAt: DateTime.utc(2026, 7, 22, 14), ); expect(result.insertedCount, 1); expect(result.updatedCount, 1); expect(result.ignoredOlderCount, 1); expect( (await exerciseRepository.findById('merge-existing'))!.name, 'Backup newer', ); expect( (await exerciseRepository.findById('merge-local-newer'))!.name, 'Local newer', ); expect( (await exerciseRepository.findById('merge-new'))!.name, 'Backup inserted', ); }); test( 'local backup replaceAll soft deletes absent data and imports file', () async { final now = DateTime.utc(2026, 7, 22, 10); await exerciseRepository.save( Exercise( metadata: _metadata('replace-old', now), name: 'Old', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 8, ), ); final snapshot = LocalDataExportSnapshot( exportedAt: DateTime.utc(2026, 7, 22, 12), appSchemaVersion: 19, originDeviceId: 'device-backup', mediaAssets: const [], exercises: [ _backupExerciseResource( id: 'replace-new', name: 'New', updatedAt: now.add(const Duration(hours: 1)), ), ], programs: const [], workoutTemplates: const [], workoutHistories: const [], ); final result = await localDataBackupRepository.applyImportSnapshot( snapshot: snapshot, mode: LocalBackupImportMode.replaceAll, importedAt: DateTime.utc(2026, 7, 22, 14), ); expect(result.deletedByReplaceCount, 1); expect(await exerciseRepository.listActive(), hasLength(1)); expect((await exerciseRepository.findById('replace-new'))!.name, 'New'); final tombstones = await (database.select(database.changeLogEntries)..where( (table) => table.entityId.equals('replace-old') & table.operation.equals('softDelete'), )) .get(); expect(tombstones, hasLength(1)); }, ); test('local backup import is blocked by open active workout', () async { final now = DateTime.utc(2026, 7, 22, 10); await activeRepository.save( ActiveWorkoutSession( metadata: _metadata('active-import-block', now), status: ActiveWorkoutStatus.running, startedAt: now, lastPersistedAt: now, elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: '{"programs":[]}', ), ); await expectLater( localDataBackupRepository.applyImportSnapshot( snapshot: LocalDataExportSnapshot( exportedAt: now, appSchemaVersion: 19, originDeviceId: 'device-backup', mediaAssets: const [], exercises: const [], programs: const [], workoutTemplates: const [], workoutHistories: const [], ), mode: LocalBackupImportMode.merge, importedAt: now.add(const Duration(hours: 1)), ), throwsA( isA().having( (error) => error.error, 'error', LocalBackupValidationError.activeWorkoutInProgress, ), ), ); }); test( 'local backup round-trip merges exported data into a fresh database', () async { final now = DateTime.utc(2026, 7, 22, 10); await exerciseRepository.save( Exercise( metadata: _metadata('roundtrip-exercise', now), name: 'Roundtrip Shoot', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, tags: const ['match'], ), ); await historyRepository.save( WorkoutHistory( metadata: _metadata('roundtrip-history', now), nameSnapshot: 'Roundtrip Session', startedAt: now, endedAt: now.add(const Duration(minutes: 10)), totalActiveMs: 600000, completed: true, historySnapshotJson: '{"name":"Roundtrip Session"}', results: [ _historySetResult( id: 'roundtrip-result', historyId: 'roundtrip-history', sourceExerciseId: 'roundtrip-exercise', setIndex: 0, startedAt: now, actualReps: 12, ), ], stepResults: [ _historyStepResult( id: 'roundtrip-step-result', historyId: 'roundtrip-history', sourceExerciseId: 'roundtrip-exercise', startedAt: now, ), ], ), ); final snapshot = await localDataBackupRepository.readExportSnapshot( DateTime.utc(2026, 7, 22, 12), ); final bytes = const LocalBackupCodec().encode(snapshot); final decoded = const LocalBackupCodec().decode(bytes); final targetDatabase = local.AppDatabase(NativeDatabase.memory()); final targetExerciseRepository = local.DriftExerciseRepository( targetDatabase, ); final targetHistoryRepository = local.DriftWorkoutHistoryRepository( targetDatabase, ); final targetBackupRepository = local.DriftLocalDataBackupRepository( targetDatabase, ); addTearDown(targetDatabase.close); await targetExerciseRepository.save( Exercise( metadata: _metadata('target-existing', now), name: 'Target existing', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 6, ), ); final result = await targetBackupRepository.applyImportSnapshot( snapshot: decoded, mode: LocalBackupImportMode.merge, importedAt: DateTime.utc(2026, 7, 22, 14), ); expect(result.insertedCount, 2); final restoredExercise = await targetExerciseRepository.findById( 'roundtrip-exercise', ); expect(restoredExercise!.name, 'Roundtrip Shoot'); expect(restoredExercise.tags, ['match']); final restoredHistory = await targetHistoryRepository.findById( 'roundtrip-history', ); expect(restoredHistory!.results, hasLength(1)); expect(restoredHistory.stepResults, hasLength(1)); expect( await targetExerciseRepository.findById('target-existing'), isNotNull, ); }, ); test( 'local backup round-trip replaceAll restores exported data and purges the rest', () async { final now = DateTime.utc(2026, 7, 22, 10); await exerciseRepository.save( Exercise( metadata: _metadata('roundtrip-replace-exercise', now), name: 'Kept', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, ), ); final snapshot = await localDataBackupRepository.readExportSnapshot( DateTime.utc(2026, 7, 22, 12), ); final bytes = const LocalBackupCodec().encode(snapshot); final decoded = const LocalBackupCodec().decode(bytes); final targetDatabase = local.AppDatabase(NativeDatabase.memory()); final targetExerciseRepository = local.DriftExerciseRepository( targetDatabase, ); final targetBackupRepository = local.DriftLocalDataBackupRepository( targetDatabase, ); addTearDown(targetDatabase.close); await targetExerciseRepository.save( Exercise( metadata: _metadata('target-to-purge', now), name: 'Should disappear', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 6, ), ); final result = await targetBackupRepository.applyImportSnapshot( snapshot: decoded, mode: LocalBackupImportMode.replaceAll, importedAt: DateTime.utc(2026, 7, 22, 14), ); expect(result.deletedByReplaceCount, 1); expect( await targetExerciseRepository.listActive(), isNot( contains( isA().having( (e) => e.metadata.id, 'id', 'target-to-purge', ), ), ), ); expect( (await targetExerciseRepository.findById( 'target-to-purge', ))!.metadata.deletedAt, isNotNull, ); expect( (await targetExerciseRepository.findById( 'roundtrip-replace-exercise', ))!.name, 'Kept', ); final tombstones = await (targetDatabase.select(targetDatabase.changeLogEntries)..where( (table) => table.entityId.equals('target-to-purge') & table.operation.equals('softDelete'), )) .get(); expect(tombstones, hasLength(1)); }, ); test( 'local backup import rolls back all mutations when applying a resource fails', () async { final now = DateTime.utc(2026, 7, 22, 10); await exerciseRepository.save( Exercise( metadata: _metadata('rollback-existing', now), name: 'Existing', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 10, ), ); final snapshot = LocalDataExportSnapshot( exportedAt: DateTime.utc(2026, 7, 22, 12), appSchemaVersion: 19, originDeviceId: 'device-backup', mediaAssets: const [], exercises: [ _backupExerciseResource( id: 'rollback-ok', name: 'Should not persist', updatedAt: now.add(const Duration(hours: 1)), ), ], programs: const [], workoutTemplates: const [], // totalActiveMs has the wrong type, which makes the cast throw while // applying this resource, after the exercise above already inserted. workoutHistories: [ LocalBackupResource( id: 'rollback-broken-history', updatedAt: now.add(const Duration(hours: 1)), payload: const { 'id': 'rollback-broken-history', 'totalActiveMs': 'not-a-number', }, ), ], ); await expectLater( localDataBackupRepository.applyImportSnapshot( snapshot: snapshot, mode: LocalBackupImportMode.merge, importedAt: DateTime.utc(2026, 7, 22, 14), ), throwsA(anything), ); expect(await exerciseRepository.findById('rollback-ok'), isNull); expect( (await exerciseRepository.findById('rollback-existing'))!.name, 'Existing', ); }, ); 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; expect(snapshot['programId'], program.metadata.id); expect(snapshot['name'], program.name); expect(snapshot['exercises'], isA>()); expect(snapshot['exercises'] as List, 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( 'active set result persists duration and stopwatch score separately', () async { final now = DateTime.utc(2026, 7, 17, 12); await activeRepository.save( ActiveWorkoutSession( metadata: _metadata('session-chrono', now), status: ActiveWorkoutStatus.running, startedAt: now, lastPersistedAt: now, elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: _resolvedSnapshot(), ), ); await activeRepository.saveSetResult( ActiveSetResult( metadata: _metadata('active-result-chrono', now), activeWorkoutSessionId: 'session-chrono', programSnapshotId: 'program-snapshot-1', exerciseSnapshotId: 'exercise-snapshot-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, actualTimeMs: 30000, actualScoreTimeMs: 12000, scoreInputModeSnapshot: ScoreInputMode.stopwatch, completedAt: now, ), ); final afterAppKillRepository = local.DriftActiveSessionRepository( database, ); final restored = await afterAppKillRepository.listSetResults( 'session-chrono', ); expect(restored, hasLength(1)); expect(restored.single.actualTimeMs, 30000); expect(restored.single.actualScoreTimeMs, 12000); expect(restored.single.scoreInputModeSnapshot, ScoreInputMode.stopwatch); }, ); 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, ); } LocalBackupResource _backupExerciseResource({ required String id, required String name, required DateTime updatedAt, }) { return LocalBackupResource( id: id, updatedAt: updatedAt, payload: { 'id': id, 'metadata': { 'id': id, 'createdAt': updatedAt.toUtc().toIso8601String(), 'updatedAt': updatedAt.toUtc().toIso8601String(), 'deletedAt': null, 'schemaVersion': 1, 'syncState': 'synced', 'localRevision': 0, 'originDeviceId': 'device-backup', }, 'name': name, 'hasTimeMeasure': false, 'hasRepsMeasure': true, 'hasScoreMeasure': false, 'defaultTargetReps': 10, 'tags': const ['backup'], 'steps': const [], }, ); } WorkoutHistory _history({ required String id, required DateTime startedAt, WorkoutHistorySetResult? result, List? 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!], ); } WorkoutHistoryStepResult _historyStepResult({ required String id, required String historyId, required String sourceExerciseId, required DateTime startedAt, }) { return WorkoutHistoryStepResult( metadata: _metadata(id, startedAt), workoutHistoryId: historyId, programSnapshotId: 'program-snapshot', exerciseSnapshotId: 'exercise-snapshot-$sourceExerciseId', programIndex: 0, exerciseIndex: 0, setIndex: 0, passageIndex: 0, stepIndex: 0, stepSnapshotId: 'step-snapshot', stepNameSnapshot: 'Step', stepTypeSnapshot: ExerciseStepType.reps, targetValueSnapshot: 10, hasScoreSnapshot: false, status: SetResultStatus.completed, startedAt: startedAt, completedAt: startedAt.add(const Duration(seconds: 10)), actualReps: 10, sourceExerciseIdSnapshot: sourceExerciseId, ); } 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 hasAnyCompletedHistoryForExercise(String exerciseId) async { return true; } @override Future findLatestSetPerformance({ required String exerciseId, required ActivePerformanceMeasures activeMeasures, required int currentSetIndex, }) async { return null; } @override Future 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, ); } }