import 'dart:convert'; import 'package:drift/drift.dart' as drift; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; import 'package:gametime/domain/domain.dart'; import 'package:gametime/infrastructure/local/local.dart' as local; void main() { late local.AppDatabase database; late local.DriftExerciseRepository exerciseRepository; late local.DriftProgramRepository programRepository; late local.DriftActiveSessionRepository activeRepository; late local.DriftWorkoutTemplateRepository templateRepository; late local.DriftWorkoutHistoryRepository historyRepository; setUp(() { database = local.AppDatabase(NativeDatabase.memory()); exerciseRepository = local.DriftExerciseRepository(database); programRepository = local.DriftProgramRepository(database); activeRepository = local.DriftActiveSessionRepository(database); templateRepository = local.DriftWorkoutTemplateRepository(database); historyRepository = local.DriftWorkoutHistoryRepository(database); }); 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( '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); }); test( 'program exercise step snapshot is preserved when source exercise changes', () async { final now = DateTime.utc(2026, 7, 17, 12); final source = Exercise( metadata: _metadata('exercise-source-1', now), name: 'Complexe original', hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, defaultTargetReps: 1, steps: [ ExerciseStep( id: 'step-original-1', position: 0, name: 'Phase originale A', type: ExerciseStepType.time, defaultTargetValue: 30, ), ExerciseStep( id: 'step-original-2', position: 1, name: 'Phase originale B', type: ExerciseStepType.reps, defaultTargetValue: 12, ), ], ); await exerciseRepository.save(source); await programRepository.save( Program( metadata: _metadata('program-steps-1', now), name: 'Programme étapes', defaultRestSeconds: 60, ), ); final useCase = ProgramUseCases( programRepository: programRepository, exerciseRepository: exerciseRepository, templateRepository: templateRepository, clock: _FakeClock(now), ids: _FakeIds(), originDeviceId: 'device-1', ); final snapshot = await useCase.addExercise( programId: 'program-steps-1', exerciseId: source.metadata.id, position: 0, setsCount: 1, enabledMeasures: const {WorkoutMeasure.reps}, ); await exerciseRepository.save( source.copyWith( metadata: _metadata( source.metadata.id, now.add(const Duration(minutes: 1)), 1, ), steps: [ ExerciseStep( id: 'step-updated-1', position: 0, name: 'Phase modifiée', type: ExerciseStepType.time, defaultTargetValue: 45, ), ], ), ); final restoredProgram = await programRepository.findById( 'program-steps-1', ); final restoredSnapshot = restoredProgram!.exercises.single; final snapshotJson = snapshot.toSnapshotJson(); expect(restoredSnapshot.exerciseStepsSnapshot, hasLength(2)); expect( restoredSnapshot.exerciseStepsSnapshot.first.name, 'Phase originale A', ); expect( restoredSnapshot.exerciseStepsSnapshot.last.type, ExerciseStepType.reps, ); expect( (snapshotJson['exerciseStepsSnapshot'] as List).map( (step) => (step as Map)['name'], ), ['Phase originale A', 'Phase originale B'], ); }, ); test( 'removing a program exercise writes a soft delete change log entry', () async { final now = DateTime.utc(2026, 7, 17, 12); final first = _programExercise('program-exercise-1', now, position: 0); final second = _programExercise('program-exercise-2', now, position: 1); await programRepository.save( Program( metadata: _metadata('program-1', now), name: 'Jambes', defaultRestSeconds: 60, exercises: [first, second], ), ); final deletedAt = now.add(const Duration(minutes: 1)); await programRepository.replaceExercises( Program( metadata: _metadata('program-1', deletedAt, 1), name: 'Jambes', defaultRestSeconds: 60, exercises: [first], ), deletedAt, ); final deletedRow = await (database.select( database.programExercises, )..where((table) => table.id.equals('program-exercise-2'))).getSingle(); final change = await (database.select(database.changeLogEntries)..where( (table) => table.entityType.equals('ProgramExercise') & table.entityId.equals('program-exercise-2') & table.operation.equals('softDelete'), )) .getSingleOrNull(); expect(deletedRow.deletedAt?.toUtc(), deletedAt); expect(deletedRow.syncState, 'deleted'); expect(deletedRow.localRevision, 1); expect(change, isNotNull); expect(change!.localRevision, 1); }, ); test( 'running session elapsed time survives repository reconstruction', () async { final persistedAt = DateTime.utc(2026, 7, 17, 12); final session = ActiveWorkoutSession( metadata: _metadata('session-1', persistedAt), sourceWorkoutTemplateId: null, status: ActiveWorkoutStatus.running, startedAt: persistedAt.subtract(const Duration(seconds: 30)), lastPersistedAt: persistedAt, elapsedActiveMs: 30000, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: _resolvedSnapshot(), ); await activeRepository.save(session); final afterAppKillRepository = local.DriftActiveSessionRepository( database, ); final afterAppKillUseCases = ActiveWorkoutSessionUseCases( sessionRepository: afterAppKillRepository, templateRepository: templateRepository, clock: _FakeClock(persistedAt.add(const Duration(seconds: 20))), ids: _FakeIds(), originDeviceId: 'device-1', ); final restored = await afterAppKillRepository.findOpen(); expect(restored, isNotNull); expect(afterAppKillUseCases.elapsedActiveMilliseconds(restored!), 50000); }, ); test( 'closing a session stores autonomous history rows with set snapshots', () async { final now = DateTime.utc(2026, 7, 17, 12); final template = WorkoutTemplate( metadata: _metadata('template-1', now), name: 'Séance jambes', ); await templateRepository.save(template); final session = ActiveWorkoutSession( metadata: _metadata('session-1', now), sourceWorkoutTemplateId: template.metadata.id, status: ActiveWorkoutStatus.running, startedAt: now.subtract(const Duration(minutes: 10)), lastPersistedAt: now, elapsedActiveMs: 600000, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: _resolvedSnapshot(), ); await activeRepository.save(session); await activeRepository.saveSetResult( ActiveSetResult( metadata: _metadata('active-result-1', now), activeWorkoutSessionId: session.metadata.id, programSnapshotId: 'program-snapshot-1', exerciseSnapshotId: 'exercise-snapshot-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, actualTimeMs: 45000, actualReps: 10, actualScore: 80, scoreLabelSnapshot: 'Charge', scoreUnitSnapshot: 'kg', completedAt: now, ), ); final closeUseCase = CloseWorkoutSessionUseCase( sessionRepository: activeRepository, historyRepository: historyRepository, clock: _FakeClock(now.add(const Duration(seconds: 5))), ids: _FakeIds(), originDeviceId: 'device-1', ); final history = await closeUseCase.close( sessionId: session.metadata.id, nameSnapshot: 'Séance jambes', completed: true, ); await templateRepository.save( WorkoutTemplate( metadata: template.metadata.markDeleted(now), name: template.name, ), ); final restored = await historyRepository.findById(history.metadata.id); expect(restored, isNotNull); expect(restored!.nameSnapshot, 'Séance jambes'); expect(restored.results, hasLength(1)); expect(restored.results.single.programNameSnapshot, 'Programme jambes'); expect(restored.results.single.exerciseNameSnapshot, 'Squat'); expect(restored.results.single.actualTimeMs, 45000); expect(restored.results.single.actualReps, 10); expect(restored.results.single.actualScore, 80); expect(restored.results.single.scoreUnitSnapshot, 'kg'); }, ); } String _resolvedSnapshot() { return jsonEncode({ 'name': 'Séance jambes', 'programs': [ { 'id': 'program-snapshot-1', 'programNameSnapshot': 'Programme jambes', 'programSnapshotJson': jsonEncode({ 'exercises': [ { 'id': 'exercise-snapshot-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, ); } 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, ); } final class _FakeClock implements Clock { const _FakeClock(this.value); final DateTime value; @override DateTime now() => value; } final class _FakeIds implements IdGenerator { var _next = 0; @override String newId() { _next += 1; return 'id-$_next'; } }