import 'dart:convert'; import 'dart:io'; 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.DriftMediaAssetRepository mediaAssetRepository; 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.DriftWorkoutTelemetryRepository telemetryRepository; late local.DriftExercisePerformanceReferenceRepository performanceReferenceRepository; late local.DriftEntitlementSnapshotRepository entitlementSnapshotRepository; setUp(() { database = local.AppDatabase(NativeDatabase.memory()); mediaAssetRepository = local.DriftMediaAssetRepository(database); 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); telemetryRepository = local.DriftWorkoutTelemetryRepository(database); performanceReferenceRepository = local.DriftExercisePerformanceReferenceRepository(database); entitlementSnapshotRepository = local.DriftEntitlementSnapshotRepository( 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('exercises'), contains('business_types_json')); expect( await columnNames('program_exercises'), contains('health_services_exercise_type_strategy_snapshot_json'), ); expect(await columnNames('programs'), contains('tags_json')); expect(await columnNames('workout_templates'), contains('tags_json')); expect( await columnNames('workout_history'), contains('min_heart_rate_bpm'), ); expect( await columnNames('workout_history'), contains('total_distance_meters'), ); expect( await columnNames('workout_history'), contains('total_calories_kcal'), ); expect(await columnNames('workout_telemetry_samples'), contains('id')); expect( await columnNames('workout_telemetry_aggregates'), contains('sample_count'), ); expect(await columnNames('entitlement_snapshots'), contains('entitlement')); expect(database.schemaVersion, 27); }); test('entitlement snapshot is persisted locally for offline reads', () async { final free = EntitlementSnapshot.free( remainingEditableSlots: 2, validatedAt: DateTime.utc(2026, 8, 22, 8), ); await entitlementSnapshotRepository.save(free); final restoredFree = await entitlementSnapshotRepository.read(); expect(restoredFree?.entitlement, EntitlementTier.free); expect(restoredFree?.canSync, isFalse); expect(restoredFree?.remainingEditableSlots, 2); expect(restoredFree?.validatedAt, DateTime.utc(2026, 8, 22, 8)); final pro = EntitlementSnapshot.pro( validatedAt: DateTime.utc(2026, 8, 22, 9), ); await entitlementSnapshotRepository.save(pro); final restoredPro = await entitlementSnapshotRepository.read(); expect(restoredPro?.entitlement, EntitlementTier.pro); expect(restoredPro?.canSync, isTrue); expect(restoredPro?.remainingEditableSlots, isNull); expect(restoredPro?.validatedAt, DateTime.utc(2026, 8, 22, 9)); }); test('exercise business types persist with category fallback', () async { final now = DateTime.utc(2026, 7, 30, 12); final exercise = Exercise( metadata: _metadata('exercise-business-types', now), name: 'Dribble intense', hasTimeMeasure: true, hasRepsMeasure: false, hasScoreMeasure: false, category: ExerciseCategory.shoot, businessTypes: const [ BusinessExerciseType.dribble, BusinessExerciseType.highIntensity, BusinessExerciseType.dribble, ], ); await exerciseRepository.save(exercise); final restoredExercise = await exerciseRepository.findById( exercise.metadata.id, ); expect(restoredExercise?.businessTypes, [ BusinessExerciseType.dribble, BusinessExerciseType.highIntensity, ]); expect(restoredExercise?.effectiveBusinessTypes, [ BusinessExerciseType.dribble, BusinessExerciseType.highIntensity, ]); expect(restoredExercise?.healthServicesExerciseTypeStrategy, [ HealthServicesExerciseType.running, HealthServicesExerciseType.highIntensityIntervalTraining, HealthServicesExerciseType.workout, ]); final program = Program( metadata: _metadata('program-business-types', now), name: 'Programme types', defaultRestSeconds: 30, exercises: [ ProgramExercise.snapshotFromExercise( metadata: _metadata('program-exercise-business-types', now), programId: 'program-business-types', exercise: restoredExercise!, position: 0, setsCount: 1, enabledMeasures: const {WorkoutMeasure.time}, ), ], ); await programRepository.save(program); final restoredProgram = await programRepository.findById( program.metadata.id, ); expect( restoredProgram ?.exercises .single .healthServicesExerciseTypeStrategySnapshot, [ HealthServicesExerciseType.running, HealthServicesExerciseType.highIntensityIntervalTraining, HealthServicesExerciseType.workout, ], ); expect( restoredProgram?.exercises.single .toSnapshotJson()['healthServicesExerciseTypeStrategy'], ['RUNNING', 'HIGH_INTENSITY_INTERVAL_TRAINING', 'WORKOUT'], ); final legacyExercise = Exercise( metadata: _metadata('exercise-business-types-legacy', now), name: 'Shoot', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, category: ExerciseCategory.shoot, ); await exerciseRepository.save(legacyExercise); final restoredLegacy = await exerciseRepository.findById( legacyExercise.metadata.id, ); expect(restoredLegacy?.businessTypes, isEmpty); expect(restoredLegacy?.effectiveBusinessTypes, [ BusinessExerciseType.shoot, ]); expect(restoredLegacy?.healthServicesExerciseTypeStrategy, [ HealthServicesExerciseType.highIntensityIntervalTraining, HealthServicesExerciseType.running, HealthServicesExerciseType.walking, HealthServicesExerciseType.workout, ]); }); test( 'telemetry repository persists samples and replaces aggregates', () async { final first = WorkoutTelemetrySample( id: 'sample-1', sessionId: 'session-1', capturedAt: DateTime.utc(2026, 7, 28, 10), programIndex: 0, exerciseIndex: 0, setIndex: 0, stepIndex: 0, heartRateBpm: 120, distanceMeters: 500, caloriesKcal: 42, ); final second = WorkoutTelemetrySample( id: 'sample-2', sessionId: 'session-1', capturedAt: DateTime.utc(2026, 7, 28, 10, 1), programIndex: 0, exerciseIndex: 1, heartRateBpm: 150, distanceMeters: 620, caloriesKcal: 48, ); final replacement = WorkoutTelemetrySample( id: 'sample-1', sessionId: 'session-1', capturedAt: DateTime.utc(2026, 7, 28, 10, 0, 10), programIndex: 0, exerciseIndex: 0, setIndex: 0, stepIndex: 0, heartRateBpm: 130, distanceMeters: 530, caloriesKcal: 45, ); final olderReplacement = WorkoutTelemetrySample( id: 'sample-1', sessionId: 'session-1', capturedAt: DateTime.utc(2026, 7, 28, 10, 0, 5), programIndex: 0, exerciseIndex: 0, setIndex: 0, stepIndex: 0, heartRateBpm: 125, distanceMeters: 510, caloriesKcal: 43, ); expect(await telemetryRepository.saveSample(first), isTrue); expect(await telemetryRepository.saveSample(first), isFalse); expect(await telemetryRepository.saveSample(replacement), isTrue); expect(await telemetryRepository.saveSample(olderReplacement), isFalse); expect(await telemetryRepository.saveSample(second), isTrue); final samples = await telemetryRepository.listSamples('session-1'); expect(samples.map((sample) => sample.id), ['sample-1', 'sample-2']); expect(samples.first.heartRateBpm, 130); final stepSamples = await telemetryRepository.listSamplesForScope( sessionId: 'session-1', scope: WorkoutTelemetryAggregateScope.step, programIndex: 0, exerciseIndex: 0, setIndex: 0, stepIndex: 0, ); expect(stepSamples, hasLength(1)); expect(stepSamples.single.distanceMeters, 530); await telemetryRepository.replaceAggregatesForSession( sessionId: 'session-1', aggregates: [ WorkoutTelemetryAggregate( sessionId: 'session-1', scope: WorkoutTelemetryAggregateScope.session, sampleCount: 2, minHeartRateBpm: 120, averageHeartRateBpm: 135, maxHeartRateBpm: 150, totalDistanceMeters: 620, totalCaloriesKcal: 48, ), ], ); final aggregate = await telemetryRepository.findAggregate( sessionId: 'session-1', scope: WorkoutTelemetryAggregateScope.session, ); expect(aggregate?.sampleCount, 2); expect(aggregate?.minHeartRateBpm, 120); expect(aggregate?.averageHeartRateBpm, 135); expect(aggregate?.maxHeartRateBpm, 150); expect(aggregate?.totalDistanceMeters, 620); expect(aggregate?.totalCaloriesKcal, 48); await telemetryRepository.replaceAggregatesForSession( sessionId: 'session-1', aggregates: const [], ); expect(await telemetryRepository.listAggregates('session-1'), isEmpty); }, ); test('share inbox and pending actions persist workout packs', () async { final now = DateTime.utc(2026, 7, 28, 10); final inboxRepository = local.DriftShareInboxRepository(database); final pendingRepository = local.DriftPendingShareActionRepository(database); await inboxRepository.upsert( ShareInboxItem( shareId: 'share-pack-1', senderUserId: 'sender-1', resourceType: ShareResourceType.pack, payloadJson: jsonEncode({ 'kind': 'pack', 'name': 'Pack reprise', 'workouts': const [], }), status: ShareInboxStatus.pending, createdAt: now, ), ); await pendingRepository.add( PendingShareAction( id: 'pending-pack-1', actionType: PendingShareActionType.send, resourceType: ShareResourceType.pack, payloadJson: jsonEncode({ 'kind': 'pack', 'name': 'Pack reprise', 'workouts': const [], }), recipientEmailsJson: jsonEncode(['coach@example.com']), createdAt: now, ), ); final inboxItem = await inboxRepository.findByShareId('share-pack-1'); final pendingActions = await pendingRepository.listPending(); expect(inboxItem, isNotNull); expect(inboxItem!.resourceType, ShareResourceType.pack); expect(pendingActions.single.resourceType, ShareResourceType.pack); }); test('migration 22 to 24 preserves share rows and accepts packs', () async { final file = File( '${Directory.systemTemp.path}/gametime_schema24_${DateTime.now().microsecondsSinceEpoch}.sqlite', ); addTearDown(() async { if (await file.exists()) { await file.delete(); } }); final seedDatabase = local.AppDatabase(NativeDatabase(file)); await seedDatabase.customSelect('SELECT 1').getSingle(); await seedDatabase.customStatement('DROP TABLE share_inbox_items'); await seedDatabase.customStatement('DROP TABLE pending_share_actions'); await seedDatabase.customStatement(''' CREATE TABLE share_inbox_items ( share_id TEXT NOT NULL PRIMARY KEY, sender_user_id TEXT NOT NULL, resource_type TEXT NOT NULL CHECK ( resource_type IN ('program', 'workoutTemplate') ), payload_json TEXT NOT NULL, status TEXT NOT NULL CHECK ( status IN ('pending', 'accepted', 'declined', 'revoked') ), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, responded_at INTEGER ) '''); await seedDatabase.customStatement(''' CREATE TABLE pending_share_actions ( id TEXT NOT NULL PRIMARY KEY, action_type TEXT NOT NULL CHECK ( action_type IN ('send', 'accept', 'decline', 'revoke') ), share_id TEXT, resource_type TEXT CHECK ( resource_type IS NULL OR resource_type IN ('program', 'workoutTemplate') ), payload_json TEXT, recipient_emails_json TEXT, created_at INTEGER NOT NULL, last_attempt_at INTEGER, attempt_count INTEGER NOT NULL CHECK (attempt_count >= 0), status TEXT NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed')) ) '''); await seedDatabase.customStatement( 'INSERT INTO share_inbox_items VALUES ' '''('share-program-1', 'sender-1', 'program', '{"name":"P"}', ''' "'pending', 1785225600000, 1785225600000, NULL)", ); await seedDatabase.customStatement( 'INSERT INTO pending_share_actions VALUES ' '''('pending-program-1', 'send', NULL, 'program', '{"name":"P"}', ''' ''''["coach@example.com"]', 1785225600000, NULL, 0, 'pending')''', ); await seedDatabase.customStatement('PRAGMA user_version = 22'); await seedDatabase.close(); final migratedDatabase = local.AppDatabase(NativeDatabase(file)); addTearDown(migratedDatabase.close); final inboxRepository = local.DriftShareInboxRepository(migratedDatabase); final pendingRepository = local.DriftPendingShareActionRepository( migratedDatabase, ); final now = DateTime.utc(2026, 7, 28, 11); await inboxRepository.upsert( ShareInboxItem( shareId: 'share-pack-1', senderUserId: 'sender-2', resourceType: ShareResourceType.pack, payloadJson: jsonEncode({ 'kind': 'pack', 'name': 'Pack reprise', 'workouts': const [], }), status: ShareInboxStatus.pending, createdAt: now, ), ); await pendingRepository.add( PendingShareAction( id: 'pending-pack-1', actionType: PendingShareActionType.send, resourceType: ShareResourceType.pack, payloadJson: jsonEncode({ 'kind': 'pack', 'name': 'Pack reprise', 'workouts': const [], }), recipientEmailsJson: jsonEncode(['coach@example.com']), createdAt: now, ), ); final version = await migratedDatabase .customSelect('PRAGMA user_version') .getSingle(); final inboxItems = await inboxRepository.listAll(); final pendingActions = await pendingRepository.listPending(); expect(version.data['user_version'], 26); expect(inboxItems.map((item) => item.shareId), contains('share-program-1')); expect(inboxItems.map((item) => item.shareId), contains('share-pack-1')); expect( pendingActions.map((action) => action.id), containsAll(['pending-program-1', 'pending-pack-1']), ); }); test( 'workout template saveAll rolls back every template on failure', () async { final now = DateTime.utc(2026, 7, 28, 12); final valid = WorkoutTemplate( metadata: _metadata('template-valid', now), name: 'Séance valide', ); final invalid = WorkoutTemplate( metadata: _metadata('template-invalid', now), name: 'Séance invalide', programs: [ WorkoutTemplateProgram( metadata: _metadata('template-invalid-program-1', now), workoutTemplateId: 'template-invalid', position: 0, programNameSnapshot: 'Programme A', defaultRestSecondsSnapshot: 60, programSnapshotJson: jsonEncode({'exercises': const []}), ), WorkoutTemplateProgram( metadata: _metadata('template-invalid-program-2', now), workoutTemplateId: 'template-invalid', position: 0, programNameSnapshot: 'Programme B', defaultRestSecondsSnapshot: 60, programSnapshotJson: jsonEncode({'exercises': const []}), ), ], ); await expectLater( templateRepository.saveAll([valid, invalid]), throwsA(isA()), ); expect(await templateRepository.findById(valid.metadata.id), isNull); expect(await templateRepository.findById(invalid.metadata.id), isNull); }, ); 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 payload includes exercise business types', () async { final now = DateTime.utc(2026, 7, 30, 13); final exercise = Exercise( metadata: _metadata('exercise-sync-business-types', now), name: 'Drive', hasTimeMeasure: true, hasRepsMeasure: false, hasScoreMeasure: false, ); await exerciseRepository.save(exercise); final updatedExercise = exercise.copyWith( metadata: _metadata( 'exercise-sync-business-types', now.add(const Duration(seconds: 1)), 1, ), businessTypes: const [ BusinessExerciseType.dribble, BusinessExerciseType.finishing, ], ); await exerciseRepository.save(updatedExercise); await programRepository.save( Program( metadata: _metadata('program-sync-business-types', now), name: 'Program business types', defaultRestSeconds: 45, exercises: [ ProgramExercise.snapshotFromExercise( metadata: _metadata('program-exercise-sync-business-types', now), programId: 'program-sync-business-types', exercise: updatedExercise, position: 0, setsCount: 1, enabledMeasures: const {WorkoutMeasure.time}, ), ], ), ); final changes = await syncChangeRepository.listPendingChanges(); final payloadsById = { for (final change in changes) change.item.clientId: change.item.payload, }; expect(payloadsById['exercise-sync-business-types']!['businessTypes'], [ 'dribble', 'finishing', ]); final programExercises = payloadsById['program-sync-business-types']!['exercises'] as List; expect(programExercises.single['healthServicesExerciseTypeStrategy'], [ 'RUNNING', 'HIGH_INTENSITY_INTERVAL_TRAINING', 'WORKOUT', ]); }); test('local sync payload includes full workout history aggregate', () async { final now = DateTime.utc(2026, 7, 22, 10, 45); await historyRepository.save( _history( id: 'sync-history-full', startedAt: now, result: _historySetResult( id: 'sync-history-set-result', historyId: 'sync-history-full', sourceExerciseId: 'exercise-sync-full', setIndex: 0, startedAt: now, actualScore: 12, ), stepResults: [ _historyStepResult( id: 'sync-history-step-result', historyId: 'sync-history-full', sourceExerciseId: 'exercise-sync-full', startedAt: now, ), ], minHeartRateBpm: 90, averageHeartRateBpm: 120, maxHeartRateBpm: 150, totalDistanceMeters: 42, totalCaloriesKcal: 12, historySnapshotJson: jsonEncode({ 'name': 'sync-history-full', 'telemetrySamples': [ { 'id': 'telemetry:session-sync:0', 'sessionId': 'session-sync', 'capturedAt': now.toUtc().toIso8601String(), 'programIndex': 0, 'exerciseIndex': 0, 'setIndex': 0, 'stepIndex': 0, 'heartRateBpm': 120, 'distanceMeters': 42, 'caloriesKcal': 12, }, ], }), ), ); final changes = await syncChangeRepository.listPendingChanges(); final payload = changes .singleWhere((change) => change.item.clientId == 'sync-history-full') .item .payload; expect(payload['minHeartRateBpm'], 90); expect(payload['averageHeartRateBpm'], 120); expect(payload['maxHeartRateBpm'], 150); expect(payload['totalDistanceMeters'], 42); expect(payload['totalCaloriesKcal'], 12); expect(payload['results'], hasLength(1)); expect(payload['stepResults'], hasLength(1)); expect(payload['telemetrySamples'], hasLength(1)); }); test('local sync pull restores exercise images and steps', () async { final now = DateTime.utc(2026, 7, 22, 10, 50); await mediaAssetRepository.save( MediaAsset( metadata: _metadata('remote-image', now), kind: MediaKind.image, localUri: 'file:///remote-image.png', ), ); final applied = await syncChangeRepository.applyRemoteItem( RemoteSyncedItem( resourceType: SyncResourceType.exercise, clientId: 'remote-exercise-with-children', serverId: 'server-exercise-with-children', schemaVersion: 1, clientUpdatedAt: now, serverUpdatedAt: now, deletedAt: null, payload: { 'id': 'remote-exercise-with-children', 'name': 'Remote exercise', 'imageMediaIds': const ['remote-image'], 'iconMediaId': 'remote-image', 'hasTimeMeasure': false, 'hasRepsMeasure': true, 'hasScoreMeasure': true, 'scoreInputMode': 'manual', 'scoreLabel': 'Paniers', 'scoreUnit': 'pts', 'steps': [ _exerciseStep( id: 'remote-step', position: 0, name: 'Tir main droite', type: ExerciseStepType.reps, defaultTargetValue: 10, hasScore: true, scoreLabel: 'Paniers', scoreUnit: 'pts', linkedToSeriesScore: true, ).toSnapshotJson(), ], }, ), ); final exercise = await exerciseRepository.findById( 'remote-exercise-with-children', ); expect(applied, isTrue); expect(exercise!.imageMediaIds, ['remote-image']); expect(exercise.steps, hasLength(1)); expect(exercise.steps.single.linkedToSeriesScore, isTrue); }); test('local sync pull restores full workout history aggregate', () async { final now = DateTime.utc(2026, 7, 22, 10, 55); final payload = { 'metadata': { 'id': 'remote-history-full', 'createdAt': now.toUtc().toIso8601String(), 'updatedAt': now.toUtc().toIso8601String(), 'schemaVersion': 1, 'syncState': 'synced', 'localRevision': 0, 'originDeviceId': 'device-remote', }, 'id': 'remote-history-full', 'nameSnapshot': 'Remote history', 'startedAt': now.toUtc().toIso8601String(), 'endedAt': now.add(const Duration(minutes: 5)).toUtc().toIso8601String(), 'totalActiveMs': 300000, 'completed': true, 'historySnapshotJson': '{"name":"remote-history-full"}', 'telemetrySamples': [ { 'id': 'telemetry:remote-session:0', 'sessionId': 'remote-session', 'capturedAt': now.toUtc().toIso8601String(), 'programIndex': 0, 'exerciseIndex': 0, 'setIndex': 0, 'stepIndex': 0, 'heartRateBpm': 125, 'distanceMeters': 84, 'caloriesKcal': 24, }, ], 'minHeartRateBpm': 95, 'averageHeartRateBpm': 125, 'maxHeartRateBpm': 155, 'totalDistanceMeters': 84, 'totalCaloriesKcal': 24, 'results': [ { 'id': 'remote-history-set-result', 'workoutHistoryId': 'remote-history-full', 'programSnapshotId': 'program-snapshot', 'exerciseSnapshotId': 'exercise-snapshot-remote-exercise', 'programIndex': 0, 'exerciseIndex': 0, 'setIndex': 0, 'programNameSnapshot': 'Program', 'exerciseNameSnapshot': 'Exercise', 'timeEnabledSnapshot': false, 'repsEnabledSnapshot': false, 'scoreEnabledSnapshot': true, 'scoreInputModeSnapshot': 'stopwatch', 'actualScoreTimeMs': 12000, 'sourceExerciseIdSnapshot': 'remote-exercise', 'completedAt': now .add(const Duration(minutes: 1)) .toUtc() .toIso8601String(), 'status': 'completed', }, ], 'stepResults': [ { 'id': 'remote-history-step-result', 'workoutHistoryId': 'remote-history-full', 'programSnapshotId': 'program-snapshot', 'exerciseSnapshotId': 'exercise-snapshot-remote-exercise', 'programIndex': 0, 'exerciseIndex': 0, 'setIndex': 0, 'passageIndex': 0, 'stepIndex': 0, 'stepSnapshotId': 'step-snapshot', 'stepNameSnapshot': 'Step', 'stepTypeSnapshot': 'reps', 'targetValueSnapshot': 10, 'hasScoreSnapshot': false, 'status': 'completed', 'startedAt': now.toUtc().toIso8601String(), 'completedAt': now .add(const Duration(seconds: 10)) .toUtc() .toIso8601String(), 'actualReps': 10, 'sourceExerciseIdSnapshot': 'remote-exercise', }, ], }; final applied = await syncChangeRepository.applyRemoteItem( RemoteSyncedItem( resourceType: SyncResourceType.workoutHistory, clientId: 'remote-history-full', serverId: 'server-history-full', schemaVersion: 1, clientUpdatedAt: now, serverUpdatedAt: now, deletedAt: null, payload: payload, ), ); final restored = await historyRepository.findById('remote-history-full'); expect(applied, isTrue); expect(restored!.minHeartRateBpm, 95); expect(restored.averageHeartRateBpm, 125); expect(restored.maxHeartRateBpm, 155); expect(restored.totalDistanceMeters, 84); expect(restored.totalCaloriesKcal, 24); expect(restored.results.single.actualScoreTimeMs, 12000); expect(restored.stepResults.single.actualReps, 10); final telemetrySamples = await telemetryRepository.listSamples( 'remote-session', ); final telemetryAggregate = await telemetryRepository.findAggregate( sessionId: 'remote-session', scope: WorkoutTelemetryAggregateScope.session, ); expect(telemetrySamples, hasLength(1)); expect(telemetrySamples.single.heartRateBpm, 125); expect(telemetryAggregate!.totalDistanceMeters, 84); }); 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 sync pull keeps program loadable when source exercise is absent', () async { final now = DateTime.utc(2026, 7, 23, 11); final applied = await syncChangeRepository.applyRemoteItem( RemoteSyncedItem( resourceType: SyncResourceType.program, clientId: 'remote-program-missing-exercise', serverId: 'server-program-missing-exercise', schemaVersion: 1, clientUpdatedAt: now, serverUpdatedAt: now, deletedAt: null, payload: const { 'id': 'remote-program-missing-exercise', 'name': 'Remote program', 'defaultRestSeconds': 30, 'exercises': [ { 'id': 'remote-program-exercise-missing-source', 'sourceExerciseId': 'remote-exercise-deleted', 'position': 0, 'exerciseNameSnapshot': 'Remote deleted exercise', 'availableTimeSnapshot': false, 'availableRepsSnapshot': true, 'availableScoreSnapshot': false, 'setsCount': 2, 'timeEnabled': false, 'repsEnabled': true, 'scoreEnabled': false, 'targetReps': 15, }, ], }, ), ); final program = await programRepository.findById( 'remote-program-missing-exercise', ); expect(applied, isTrue); expect(program, isNotNull); expect(program!.exercises, hasLength(1)); expect(program.exercises.single.sourceExerciseId, isNull); expect( program.exercises.single.exerciseNameSnapshot, 'Remote deleted exercise', ); expect(program.exercises.single.targetReps, 15); }, ); 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(), 2); 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.every( (exercise) => exercise.category == ExerciseCategory.uncategorized, ), isTrue, ); expect( exercises.every((exercise) => exercise.businessTypes.isNotEmpty), isTrue, ); expect(exercises.every((exercise) => exercise.tags.isNotEmpty), isTrue); expect(programs.single.tags, ['fondations', 'basket']); expect(templates.single.tags, ['fondations', 'séance']); expect( exercises.map((exercise) => exercise.metadata.id), everyElement(startsWith('starter-v2-')), ); 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(), 2); 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('program remains loadable after source exercise deletion', () async { final now = DateTime.utc(2026, 7, 23, 9); final exercise = Exercise( metadata: _metadata('exercise-deleted-source', now), name: 'Tirs en course', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 8, ); await exerciseRepository.save(exercise); await programRepository.save( Program( metadata: _metadata('program-deleted-source', now), name: 'Programme source supprimée', defaultRestSeconds: 30, exercises: [ ProgramExercise.snapshotFromExercise( metadata: _metadata('program-exercise-deleted-source', now), programId: 'program-deleted-source', exercise: exercise, position: 0, setsCount: 3, enabledMeasures: const {WorkoutMeasure.reps}, ), ], ), ); await exerciseRepository.save( exercise.copyWith( metadata: exercise.metadata.markDeleted( now.add(const Duration(minutes: 1)), ), ), ); final programs = await programRepository.listActive(); expect(programs, hasLength(1)); expect(programs.single.name, 'Programme source supprimée'); expect( programs.single.exercises.single.sourceExerciseId, exercise.metadata.id, ); expect( programs.single.exercises.single.exerciseNameSnapshot, 'Tirs en course', ); expect(programs.single.exercises.single.targetReps, 8); }); test( 'program list tolerates invalid snapshots after source exercise deletion', () async { final now = DateTime.utc(2026, 7, 30, 18); final exercise = Exercise( metadata: _metadata('exercise-deleted-invalid-snapshot', now), name: 'Concours de tirs', hasTimeMeasure: false, hasRepsMeasure: false, hasScoreMeasure: true, scoreLabel: 'Score', scoreUnit: 'pts', ); await exerciseRepository.save(exercise); await programRepository.save( Program( metadata: _metadata('program-invalid-snapshot', now), name: 'Programme snapshot legacy', defaultRestSeconds: 30, exercises: [ ProgramExercise.snapshotFromExercise( metadata: _metadata('program-exercise-invalid-snapshot', now), programId: 'program-invalid-snapshot', exercise: exercise, position: 0, setsCount: 2, enabledMeasures: const {WorkoutMeasure.score}, ), ], ), ); await exerciseRepository.save( exercise.copyWith( metadata: exercise.metadata.markDeleted( now.add(const Duration(minutes: 1)), ), ), ); await database.customStatement('PRAGMA ignore_check_constraints = ON'); await database.customStatement( 'UPDATE program_exercises ' 'SET available_reps_snapshot = 1, available_score_snapshot = 0, ' 'score_label_snapshot = NULL, score_unit_snapshot = NULL ' "WHERE id = 'program-exercise-invalid-snapshot'", ); await database.customStatement('PRAGMA ignore_check_constraints = OFF'); final programs = await programRepository.listActive(); expect(programs, hasLength(1)); final restored = programs.single.exercises.single; expect(restored.exerciseNameSnapshot, 'Concours de tirs'); expect(restored.sourceExerciseId, exercise.metadata.id); expect(restored.repsEnabled, isTrue); expect(restored.scoreEnabled, isFalse); }, ); 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 { final referencedMediaIds = { ...roundTripCase.exercise.imageMediaIds, ...[ roundTripCase.exercise.iconMediaId, roundTripCase.exercise.videoMediaId, ].nonNulls, }; for (final mediaId in referencedMediaIds) { await mediaAssetRepository.save( MediaAsset( metadata: _metadata( mediaId, roundTripCase.exercise.metadata.createdAt, ), kind: mediaId.startsWith('video-') ? MediaKind.video : MediaKind.image, localUri: 'file:///$mediaId', ), ); } 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( 'active score states are soft deleted with softDelete change log entries', () async { final now = DateTime.utc(2026, 7, 17, 12); await activeRepository.save( ActiveWorkoutSession( metadata: _metadata('session-score-delete', now), status: ActiveWorkoutStatus.running, startedAt: now, lastPersistedAt: now, elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: _resolvedSnapshot(), ), ); await activeRepository.saveScoreStopwatchState( ActiveScoreStopwatchState( metadata: _metadata('score-stopwatch-delete', now), activeWorkoutSessionId: 'session-score-delete', programIndex: 0, exerciseIndex: 0, setIndex: 0, status: ActiveScoreStopwatchStatus.stopped, startedAt: now, accumulatedMs: 12000, stoppedAt: now.add(const Duration(seconds: 12)), ), ); await activeRepository.saveManualScoreState( ActiveManualScoreState( metadata: _metadata('manual-score-delete', now), activeWorkoutSessionId: 'session-score-delete', programIndex: 0, exerciseIndex: 0, setIndex: 0, value: 3, updatedAt: now, ), ); final deletedAt = now.add(const Duration(minutes: 1)); await activeRepository.deleteScoreStopwatchState( sessionId: 'session-score-delete', programIndex: 0, exerciseIndex: 0, setIndex: 0, deletedAt: deletedAt, ); await activeRepository.deleteManualScoreState( sessionId: 'session-score-delete', programIndex: 0, exerciseIndex: 0, setIndex: 0, deletedAt: deletedAt, ); final stopwatchRow = await (database.select(database.activeScoreStopwatchStates) ..where((table) => table.id.equals('score-stopwatch-delete'))) .getSingle(); final manualRow = await (database.select( database.activeManualScoreStates, )..where((table) => table.id.equals('manual-score-delete'))).getSingle(); final changes = await (database.select(database.changeLogEntries)..where( (table) => table.entityId.isIn([ 'score-stopwatch-delete', 'manual-score-delete', ]) & table.localRevision.equals(1), )) .get(); expect(stopwatchRow.deletedAt?.toUtc(), deletedAt); expect(stopwatchRow.syncState, 'deleted'); expect(manualRow.deletedAt?.toUtc(), deletedAt); expect(manualRow.syncState, 'deleted'); expect(changes.map((change) => change.operation), [ 'softDelete', 'softDelete', ]); }, ); test( 'active score states keep stored identity when upserted by position', () async { final now = DateTime.utc(2026, 7, 17, 12); await activeRepository.save( ActiveWorkoutSession( metadata: _metadata('session-score-upsert', now), status: ActiveWorkoutStatus.running, startedAt: now, lastPersistedAt: now, elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: _resolvedSnapshot(), ), ); await activeRepository.saveScoreStopwatchState( ActiveScoreStopwatchState( metadata: _metadata('score-stopwatch-original', now), activeWorkoutSessionId: 'session-score-upsert', programIndex: 0, exerciseIndex: 0, setIndex: 0, status: ActiveScoreStopwatchStatus.running, startedAt: now, accumulatedMs: 0, ), ); await activeRepository.saveManualScoreState( ActiveManualScoreState( metadata: _metadata('manual-score-original', now), activeWorkoutSessionId: 'session-score-upsert', programIndex: 0, exerciseIndex: 0, setIndex: 0, value: 1, updatedAt: now, ), ); final updatedAt = now.add(const Duration(minutes: 1)); await activeRepository.saveScoreStopwatchState( ActiveScoreStopwatchState( metadata: _metadata('score-stopwatch-new-id', updatedAt, 1), activeWorkoutSessionId: 'session-score-upsert', programIndex: 0, exerciseIndex: 0, setIndex: 0, status: ActiveScoreStopwatchStatus.paused, startedAt: updatedAt, accumulatedMs: 30000, stoppedAt: updatedAt, ), ); await activeRepository.saveManualScoreState( ActiveManualScoreState( metadata: _metadata('manual-score-new-id', updatedAt, 1), activeWorkoutSessionId: 'session-score-upsert', programIndex: 0, exerciseIndex: 0, setIndex: 0, value: 4, updatedAt: updatedAt, ), ); final stopwatchRow = await (database.select(database.activeScoreStopwatchStates) ..where((table) => table.id.equals('score-stopwatch-original'))) .getSingle(); final manualRow = await (database.select(database.activeManualScoreStates) ..where((table) => table.id.equals('manual-score-original'))) .getSingle(); final changes = await (database.select(database.changeLogEntries)..where( (table) => table.entityId.isIn([ 'score-stopwatch-original', 'manual-score-original', ]) & table.operation.equals('update'), )) .get(); expect(stopwatchRow.createdAt.toUtc(), now); expect(stopwatchRow.status, 'paused'); expect(stopwatchRow.accumulatedMs, 30000); expect(manualRow.createdAt.toUtc(), now); expect(manualRow.value, 4); expect(changes.map((change) => change.localRevision), [1, 1]); }, ); test( 'active exercise step progress keeps stored identity when upserted by position', () async { final now = DateTime.utc(2026, 7, 17, 12); await activeRepository.save( ActiveWorkoutSession( metadata: _metadata('session-step-progress-upsert', now), status: ActiveWorkoutStatus.running, startedAt: now, lastPersistedAt: now, elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: _resolvedSnapshot(), ), ); await activeRepository.saveExerciseStepProgressState( ActiveExerciseStepProgressState( metadata: _metadata('step-progress-original', now), activeWorkoutSessionId: 'session-step-progress-upsert', programIndex: 0, exerciseIndex: 0, setIndex: 0, currentPassageIndex: 0, currentStepIndex: 0, currentStepSnapshotId: 'step-snapshot-1', status: ActiveExerciseStepProgressStatus.runningTimer, startedAt: now, accumulatedMs: 0, lastTransitionAt: now, ), ); final updatedAt = now.add(const Duration(minutes: 1)); await activeRepository.saveExerciseStepProgressState( ActiveExerciseStepProgressState( metadata: _metadata('step-progress-new-id', updatedAt, 1), activeWorkoutSessionId: 'session-step-progress-upsert', programIndex: 0, exerciseIndex: 0, setIndex: 0, currentPassageIndex: 0, currentStepIndex: 1, currentStepSnapshotId: 'step-snapshot-2', status: ActiveExerciseStepProgressStatus.stoppedTimer, accumulatedMs: 30000, lastTransitionAt: updatedAt, ), ); final rows = await database .select(database.activeExerciseStepProgressStates) .get(); final row = await (database.select(database.activeExerciseStepProgressStates) ..where((table) => table.id.equals('step-progress-original'))) .getSingle(); final changes = await (database.select(database.changeLogEntries)..where( (table) => table.entityId.equals('step-progress-original') & table.operation.equals('update'), )) .get(); expect(rows, hasLength(1)); expect(row.createdAt.toUtc(), now); expect(row.currentStepIndex, 1); expect(row.currentStepSnapshotId, 'step-snapshot-2'); expect(row.status, 'stoppedTimer'); expect(row.accumulatedMs, 30000); expect(changes.map((change) => change.localRevision), [1]); }, ); test( 'QA core offline journey persists exercise to progression through local repositories', () async { final now = DateTime.utc(2026, 8, 21, 9); final clock = _FakeClock(now); final ids = _FakeIds(); final exerciseUseCases = ExerciseUseCases( repository: exerciseRepository, programRepository: programRepository, clock: clock, ids: ids, originDeviceId: 'qa-core-offline', ); final programUseCases = ProgramUseCases( programRepository: programRepository, exerciseRepository: exerciseRepository, templateRepository: templateRepository, clock: clock, ids: ids, originDeviceId: 'qa-core-offline', ); final templateUseCases = WorkoutTemplateUseCases( templateRepository: templateRepository, programRepository: programRepository, clock: clock, ids: ids, originDeviceId: 'qa-core-offline', ); final sessionUseCases = ActiveWorkoutSessionUseCases( sessionRepository: activeRepository, templateRepository: templateRepository, clock: clock, ids: ids, originDeviceId: 'qa-core-offline', ); final closeUseCase = CloseWorkoutSessionUseCase( sessionRepository: activeRepository, historyRepository: historyRepository, clock: clock, ids: ids, originDeviceId: 'qa-core-offline', ); final exercise = await exerciseUseCases.create( name: 'QA drift offline squat', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 8, tags: const ['qa', 'offline'], ); final program = await programUseCases.saveConfigured( name: 'QA drift offline program', defaultRestSeconds: 30, tags: const ['qa', 'offline'], exercises: [ ProgramExerciseConfig( sourceExerciseId: exercise.metadata.id, exerciseNameSnapshot: exercise.name, availableTimeSnapshot: exercise.hasTimeMeasure, availableRepsSnapshot: exercise.hasRepsMeasure, availableScoreSnapshot: exercise.hasScoreMeasure, setsCount: 1, enabledMeasures: const {WorkoutMeasure.reps}, targetReps: 8, ), ], ); final template = await templateUseCases.saveConfigured( name: 'QA drift offline template', tags: const ['qa', 'offline'], programs: [ WorkoutTemplateProgramConfig( clientKey: 'program-1', sourceProgramId: program.metadata.id, programNameSnapshot: program.name, defaultRestSecondsSnapshot: program.defaultRestSeconds, programSnapshotJson: _programSnapshotJson(program), ), ], overrides: const [], ); final session = await sessionUseCases.startFromTemplate( template.metadata.id, ); await sessionUseCases.recordCurrentSetResult( sessionId: session.metadata.id, programSnapshotId: template.programs.single.metadata.id, exerciseSnapshotId: program.exercises.single.metadata.id, programIndex: 0, exerciseIndex: 0, setIndex: 0, actualReps: 9, scoreInputModeSnapshot: ScoreInputMode.manual, ); clock.value = now.add(const Duration(minutes: 6)); await sessionUseCases.complete(session.metadata.id); final history = await closeUseCase.close( sessionId: session.metadata.id, nameSnapshot: template.name, completed: true, ); final restoredExercise = await exerciseRepository.findById( exercise.metadata.id, ); final restoredProgram = await programRepository.findById( program.metadata.id, ); final restoredTemplate = await templateRepository.findById( template.metadata.id, ); final restoredHistory = await historyRepository.findById( history.metadata.id, ); final progressionStats = await progressionStatsRepository.readGlobalStats( ProgressionDateRange( startedAt: now.subtract(const Duration(days: 1)), endedAt: now.add(const Duration(days: 1)), ), ); final progressionSeries = await progressionStatsRepository .readExerciseSeries( range: ProgressionDateRange( startedAt: now.subtract(const Duration(days: 1)), endedAt: now.add(const Duration(days: 1)), ), exerciseKey: exercise.metadata.id, measure: ProgressionMeasure.reps, ); expect(restoredExercise, isNotNull); expect(restoredProgram, isNotNull); expect(restoredTemplate, isNotNull); expect(restoredHistory, isNotNull); expect(restoredHistory!.completed, isTrue); expect(restoredHistory.results, hasLength(1)); expect(restoredHistory.results.single.actualReps, 9); expect( restoredHistory.results.single.sourceExerciseIdSnapshot, exercise.metadata.id, ); expect(progressionStats.completedSessionCount, 1); expect(progressionStats.hasAnyCompletedHistory, isTrue); expect(progressionSeries.hasAnyCompletedExerciseResult, isTrue); expect(progressionSeries.points.single.rawValue, 9); expect( progressionSeries.points.single.workoutHistoryId, history.metadata.id, ); }, ); 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, List stepResults = const [], bool completed = true, int totalActiveMs = 300000, int? minHeartRateBpm, double? averageHeartRateBpm, int? maxHeartRateBpm, double? totalDistanceMeters, double? totalCaloriesKcal, String? historySnapshotJson, }) { return WorkoutHistory( metadata: _metadata(id, startedAt), nameSnapshot: id, startedAt: startedAt, endedAt: startedAt.add(const Duration(minutes: 5)), totalActiveMs: totalActiveMs, completed: completed, historySnapshotJson: historySnapshotJson ?? '{"name":"$id"}', results: results ?? [result!], stepResults: stepResults, minHeartRateBpm: minHeartRateBpm, averageHeartRateBpm: averageHeartRateBpm, maxHeartRateBpm: maxHeartRateBpm, totalDistanceMeters: totalDistanceMeters, totalCaloriesKcal: totalCaloriesKcal, ); } 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, bool linkedToSeriesScore = false, }) { return ExerciseStep( id: id, position: position, name: name, type: type, defaultTargetValue: defaultTargetValue, hasScore: hasScore, scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, linkedToSeriesScore: linkedToSeriesScore, ); } 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 { _FakeClock(this.value); DateTime value; @override DateTime now() => value; } String _programSnapshotJson(Program program) { return WorkoutTemplateProgram.snapshotFromProgram( metadata: _metadata( 'snapshot-${program.metadata.id}', program.metadata.updatedAt, ), workoutTemplateId: 'template-preview', program: program, position: 0, ).programSnapshotJson; } 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, ); } }