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, ); 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( '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, { required int position, }) { return ProgramExercise( metadata: _metadata(id, now), programId: 'program-1', position: position, exerciseNameSnapshot: 'Exercise $position', 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'; } }