diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index f446db4..cd7cdb1 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -458,11 +458,17 @@ final class WorkoutTemplateUseCases { } final templateOverrides = []; for (final input in overrides) { + final programInput = programs.firstWhere( + (program) => program.clientKey == input.workoutTemplateProgramClientKey, + orElse: () => + throw const DomainException('Workout template program not found.'), + ); final workoutTemplateProgramId = programIdsByClientKey[input.workoutTemplateProgramClientKey]; if (workoutTemplateProgramId == null) { throw const DomainException('Workout template program not found.'); } + _validateOverrideTargets(programInput.programSnapshotJson, input); templateOverrides.add( WorkoutTemplateExerciseOverride( metadata: @@ -833,6 +839,14 @@ final class CloseWorkoutSessionUseCase { final now = clock.now(); final results = await sessionRepository.listSetResults(sessionId); final historyId = ids.newId(); + final historyResults = _historyResultsFromActiveResults( + historyId: historyId, + results: results, + resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, + now: now, + ids: ids, + originDeviceId: originDeviceId, + ); final history = WorkoutHistory( metadata: EntityMetadata( id: historyId, @@ -867,6 +881,7 @@ final class CloseWorkoutSessionUseCase { ) .toList(), }), + results: historyResults, ); await historyRepository.save(history); return history; @@ -886,6 +901,142 @@ final class WorkoutHistoryUseCases { Future delete(String id) => repository.delete(id, clock.now()); } +void _validateOverrideTargets( + String programSnapshotJson, + WorkoutTemplateExerciseOverrideConfig input, +) { + final snapshot = jsonDecode(programSnapshotJson) as Map; + final exercises = (snapshot['exercises'] as List? ?? const []) + .cast>(); + final exercise = exercises.cast?>().firstWhere( + (item) => + item?['id'] == input.snapshotProgramExerciseId || + item?['snapshotProgramExerciseId'] == input.snapshotProgramExerciseId, + orElse: () => null, + ); + if (exercise == null) { + throw const DomainException('Snapshot exercise not found.'); + } + if (input.targetTimeSecondsOverride != null && + exercise['timeEnabled'] != true) { + throw const DomainException('Cannot override inactive time target.'); + } + if (input.targetRepsOverride != null && exercise['repsEnabled'] != true) { + throw const DomainException('Cannot override inactive reps target.'); + } + if (input.targetScoreOverride != null && exercise['scoreEnabled'] != true) { + throw const DomainException('Cannot override inactive score target.'); + } +} + +List _historyResultsFromActiveResults({ + required String historyId, + required List results, + required String resolvedTemplateSnapshotJson, + required DateTime now, + required IdGenerator ids, + required String originDeviceId, +}) { + final snapshots = _exerciseSnapshotsById(resolvedTemplateSnapshotJson); + return results.map((result) { + final snapshot = snapshots[result.exerciseSnapshotId]; + return WorkoutHistorySetResult( + metadata: _newMetadata(ids, originDeviceId, now), + workoutHistoryId: historyId, + programSnapshotId: result.programSnapshotId, + exerciseSnapshotId: result.exerciseSnapshotId, + programIndex: result.programIndex, + exerciseIndex: result.exerciseIndex, + setIndex: result.setIndex, + programNameSnapshot: + snapshot?.programNameSnapshot ?? result.programSnapshotId, + exerciseNameSnapshot: + snapshot?.exerciseNameSnapshot ?? result.exerciseSnapshotId, + timeEnabledSnapshot: snapshot?.timeEnabled ?? result.actualTimeMs != null, + repsEnabledSnapshot: snapshot?.repsEnabled ?? result.actualReps != null, + scoreEnabledSnapshot: + snapshot?.scoreEnabled ?? result.actualScore != null, + targetTimeSecondsSnapshot: snapshot?.targetTimeSeconds, + targetRepsSnapshot: snapshot?.targetReps, + targetScoreSnapshot: snapshot?.targetScore, + actualTimeMs: result.actualTimeMs, + actualReps: result.actualReps, + actualScore: result.actualScore, + scoreLabelSnapshot: + result.scoreLabelSnapshot ?? snapshot?.scoreLabelSnapshot, + scoreUnitSnapshot: + result.scoreUnitSnapshot ?? snapshot?.scoreUnitSnapshot, + startedAt: result.startedAt, + completedAt: result.completedAt, + ); + }).toList(); +} + +Map _exerciseSnapshotsById( + String resolvedTemplateSnapshotJson, +) { + final decoded = + jsonDecode(resolvedTemplateSnapshotJson) as Map; + final programs = (decoded['programs'] as List? ?? const []); + final snapshots = {}; + for (final program in programs.cast>()) { + final programName = program['programNameSnapshot'] as String? ?? ''; + final programSnapshotJson = program['programSnapshotJson'] as String?; + if (programSnapshotJson == null) { + continue; + } + final programSnapshot = + jsonDecode(programSnapshotJson) as Map; + final exercises = + (programSnapshot['exercises'] as List? ?? const []); + for (final exercise in exercises.cast>()) { + final id = exercise['id'] as String?; + if (id == null) { + continue; + } + snapshots[id] = _ResolvedExerciseSnapshot( + programNameSnapshot: programName, + exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id, + timeEnabled: exercise['timeEnabled'] == true, + repsEnabled: exercise['repsEnabled'] == true, + scoreEnabled: exercise['scoreEnabled'] == true, + targetTimeSeconds: exercise['targetTimeSeconds'] as int?, + targetReps: exercise['targetReps'] as int?, + targetScore: (exercise['targetScore'] as num?)?.toDouble(), + scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, + scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, + ); + } + } + return snapshots; +} + +final class _ResolvedExerciseSnapshot { + const _ResolvedExerciseSnapshot({ + required this.programNameSnapshot, + required this.exerciseNameSnapshot, + required this.timeEnabled, + required this.repsEnabled, + required this.scoreEnabled, + this.targetTimeSeconds, + this.targetReps, + this.targetScore, + this.scoreLabelSnapshot, + this.scoreUnitSnapshot, + }); + + final String programNameSnapshot; + final String exerciseNameSnapshot; + final bool timeEnabled; + final bool repsEnabled; + final bool scoreEnabled; + final int? targetTimeSeconds; + final int? targetReps; + final double? targetScore; + final String? scoreLabelSnapshot; + final String? scoreUnitSnapshot; +} + EntityMetadata _newMetadata( IdGenerator ids, String originDeviceId, diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index ccd64b3..627dd6b 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -530,7 +530,7 @@ domain.EntityMetadata _metadataFromRow(dynamic row) { ); } -List> _metadataValues(domain.EntityMetadata metadata) => [ +List _metadataValues(domain.EntityMetadata metadata) => [ Value(metadata.id), Value(metadata.createdAt), Value(metadata.updatedAt), diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 07bbd58..cc923f3 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; import 'package:gametime/domain/domain.dart'; @@ -35,6 +37,55 @@ void main() { ); }); + test( + 'Workout template use case rejects target overrides for inactive measures', + () async { + final useCase = WorkoutTemplateUseCases( + templateRepository: _FakeWorkoutTemplateRepository(), + programRepository: _FakeProgramRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + await expectLater( + useCase.saveConfigured( + name: 'Séance tir', + programs: [ + WorkoutTemplateProgramConfig( + clientKey: 'program-1', + programNameSnapshot: 'Programme tir', + defaultRestSecondsSnapshot: 30, + programSnapshotJson: jsonEncode({ + 'exercises': [ + { + 'id': 'exercise-snapshot-1', + 'exerciseNameSnapshot': 'Lancers francs', + 'setsCount': 3, + 'timeEnabled': true, + 'repsEnabled': false, + 'scoreEnabled': false, + 'targetTimeSeconds': 60, + 'targetReps': null, + 'targetScore': null, + }, + ], + }), + ), + ], + overrides: const [ + WorkoutTemplateExerciseOverrideConfig( + workoutTemplateProgramClientKey: 'program-1', + snapshotProgramExerciseId: 'exercise-snapshot-1', + targetRepsOverride: 10, + ), + ], + ), + throwsA(isA()), + ); + }, + ); + test('Active session elapsed time is based on persisted timestamps', () { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = ActiveWorkoutSession( diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart new file mode 100644 index 0000000..be44f69 --- /dev/null +++ b/test/infrastructure/drift_repositories_test.dart @@ -0,0 +1,196 @@ +import 'dart:convert'; + +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.DriftActiveSessionRepository activeRepository; + late local.DriftWorkoutTemplateRepository templateRepository; + late local.DriftWorkoutHistoryRepository historyRepository; + + setUp(() { + database = local.AppDatabase(NativeDatabase.memory()); + activeRepository = local.DriftActiveSessionRepository(database); + templateRepository = local.DriftWorkoutTemplateRepository(database); + historyRepository = local.DriftWorkoutHistoryRepository(database); + }); + + tearDown(() async { + await database.close(); + }); + + 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) { + return EntityMetadata( + id: id, + createdAt: now, + updatedAt: now, + originDeviceId: 'device-1', + ); +} + +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'; + } +}