diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index 3868f67..0e55912 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -22,10 +22,33 @@ class HistoryScreen extends StatelessWidget { // Watch HistoryManager so the list rebuilds when hcSyncedAt changes. final historyManager = context.watch(); final provider = context.read(); + final settings = context.watch(); final sessions = historyManager.sessions; + final hasUnsynced = settings.healthConnectEnabled && + sessions.any((s) => s.hcSyncedAt == null); + return Scaffold( - appBar: AppBar(title: const Text('Workout History')), + appBar: AppBar( + title: const Text('Workout History'), + actions: [ + if (hasUnsynced) + IconButton( + icon: const Icon(Icons.monitor_heart_outlined, color: _hcColor), + tooltip: 'Sync all to Health Connect', + onPressed: () { + historyManager.syncAllUnsynced(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Syncing all unsynced workouts…'), + backgroundColor: AppTheme.cardColor, + duration: Duration(seconds: 2), + ), + ); + }, + ), + ], + ), body: sessions.isEmpty ? _buildEmptyState(context) : _buildSessionList(context, sessions, provider, historyManager), diff --git a/workout-logger/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart index 95529f1..8327d16 100644 --- a/workout-logger/lib/services/health_connect_service.dart +++ b/workout-logger/lib/services/health_connect_service.dart @@ -167,17 +167,19 @@ class HealthConnectService implements IHealthConnectService { }) .toList(); - // Fall back to evenly-spaced distribution whenever clamped timestamps are - // not fully unique. Duplicate timestamps arise when: - // • All sets share the same instant (legacy data / unit-test stubs). - // • Two or more sets were logged within the same DateTime resolution tick - // (common on devices where DateTime.now() resolution is ~1 ms). - // • One or more timestamps were clamped to the same boundary value. - // In any of these cases the real-timestamp path would produce overlapping or - // zero-duration segments, which ExerciseSessionRecord's constructor rejects - // with an ArgumentError, silently aborting the sync. + // Fall back to evenly-spaced distribution whenever the clamped timestamps + // would produce an invalid segment layout. This happens when: + // • Timestamps are not fully unique (legacy data, sub-ms resolution, or + // multiple sets clamped to the same boundary). + // • The last clamped timestamp equals sessionEnd, which makes the final + // segment zero-duration (start == end == sessionEnd) — the common case + // where exactly one set was logged a few seconds after the stored + // duration ended and was clamped to sessionEnd. + // Zero-duration / overlapping segments cause ExerciseSessionRecord's + // constructor to throw an ArgumentError, silently aborting the sync. final uniqueTimestamps = clampedSets.map((s) => s.$3).toSet(); - if (uniqueTimestamps.length < clampedSets.length) { + if (uniqueTimestamps.length < clampedSets.length || + clampedSets.last.$3 == sessionEnd) { final totalMs = sessionEnd.difference(sessionStart).inMilliseconds; final slotMs = totalMs ~/ clampedSets.length; return List.generate(clampedSets.length, (i) { diff --git a/workout-logger/lib/services/managers/history_manager.dart b/workout-logger/lib/services/managers/history_manager.dart index 90d239e..1b90d96 100644 --- a/workout-logger/lib/services/managers/history_manager.dart +++ b/workout-logger/lib/services/managers/history_manager.dart @@ -82,6 +82,20 @@ class HistoryManager extends ChangeNotifier { ); } + /// Trigger a Health Connect sync for every session that has not yet been synced. + /// + /// Fires all syncs concurrently (fire-and-forget). Each successful sync + /// stamps hcSyncedAt and persists via the normal _onHcSynced path. + /// No-op when [healthSyncManager] was not provided. + void syncAllUnsynced() { + if (_healthSync == null) return; + for (final session in _sessions) { + if (session.hcSyncedAt == null) { + _healthSync.syncSession(session, onSynced: _onHcSynced); + } + } + } + // Called by HealthSyncManager on successful sync. // Merges only hcSyncedAt into the current in-memory session so that any // edits made between sync being triggered and this callback firing are not diff --git a/workout-logger/lib/services/strategies/target_calculator.dart b/workout-logger/lib/services/strategies/target_calculator.dart index d7d8aea..477d95f 100644 --- a/workout-logger/lib/services/strategies/target_calculator.dart +++ b/workout-logger/lib/services/strategies/target_calculator.dart @@ -55,12 +55,9 @@ class WeightTargetCalculator implements TargetCalculatorStrategy { double calculate(String exerciseId, List sessions) { double bestValue = 0; - for (var log in _getExerciseLogsForExercise(exerciseId, sessions)) { - final maxWeight = log.sets - .map((s) => s.weight) - .reduce((a, b) => a > b ? a : b); - if (maxWeight > bestValue) { - bestValue = maxWeight; + for (final log in _getExerciseLogsForExercise(exerciseId, sessions)) { + for (final set in log.sets) { + if (set.weight > bestValue) bestValue = set.weight; } } diff --git a/workout-logger/test/active_workout_manager_test.dart b/workout-logger/test/active_workout_manager_test.dart new file mode 100644 index 0000000..18e88d9 --- /dev/null +++ b/workout-logger/test/active_workout_manager_test.dart @@ -0,0 +1,229 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/managers/active_workout_manager.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + late MockStorageService mockStorage; + late ActiveWorkoutManager manager; + + setUp(() { + mockStorage = MockStorageService(); + manager = ActiveWorkoutManager(mockStorage); + }); + + group('ActiveWorkoutManager - startWorkout', () { + test('starts with exerciseIds and sets initial state', () { + manager.startWorkout(exerciseIds: ['ex1', 'ex2', 'ex3']); + + expect(manager.hasActiveWorkout, isTrue); + expect(manager.totalExercises, 3); + expect(manager.currentExerciseIndex, 0); + expect(manager.currentExerciseId, 'ex1'); + expect(manager.isFirstExercise, isTrue); + expect(manager.isLastExercise, isFalse); + expect(manager.workoutStartTime, isNotNull); + }); + + test('starts with a routine and uses its exerciseIds', () { + final routine = Routine( + id: 'r1', + name: 'Push Day', + exerciseIds: ['ex1', 'ex2'], + ); + + manager.startWorkout(routine: routine); + + expect(manager.hasActiveWorkout, isTrue); + expect(manager.activeRoutine, same(routine)); + expect(manager.totalExercises, 2); + expect(manager.currentExerciseId, 'ex1'); + }); + + test('throws StateError if workout already in progress', () { + manager.startWorkout(exerciseIds: ['ex1']); + expect( + () => manager.startWorkout(exerciseIds: ['ex2']), + throwsA(isA()), + ); + }); + + test('throws StateError with no exercises', () { + expect( + () => manager.startWorkout(exerciseIds: []), + throwsA(isA()), + ); + }); + }); + + group('ActiveWorkoutManager - addSet / removeLastSet', () { + setUp(() => manager.startWorkout(exerciseIds: ['ex1'])); + + test('addSet appends to current exercise log', () { + final set = WorkoutSet(weight: 100, reps: 8); + manager.addSet(set); + + expect(manager.currentExerciseLog!.sets.length, 1); + expect(manager.currentExerciseLog!.sets.first.weight, 100); + expect(manager.currentExerciseLog!.sets.first.reps, 8); + }); + + test('addSet accumulates multiple sets', () { + manager.addSet(WorkoutSet(weight: 80, reps: 12)); + manager.addSet(WorkoutSet(weight: 85, reps: 10)); + manager.addSet(WorkoutSet(weight: 90, reps: 8)); + + expect(manager.currentExerciseLog!.sets.length, 3); + }); + + test('removeLastSet removes the last added set', () { + manager.addSet(WorkoutSet(weight: 80, reps: 12)); + manager.addSet(WorkoutSet(weight: 85, reps: 10)); + manager.removeLastSet(); + + expect(manager.currentExerciseLog!.sets.length, 1); + expect(manager.currentExerciseLog!.sets.first.weight, 80); + }); + + test('removeLastSet is a no-op when no sets logged', () { + manager.removeLastSet(); + expect(manager.currentExerciseLog!.sets, isEmpty); + }); + }); + + group('ActiveWorkoutManager - updateCurrentExerciseNotes', () { + setUp(() => manager.startWorkout(exerciseIds: ['ex1'])); + + test('sets notes on current exercise', () { + manager.updateCurrentExerciseNotes('Focus on form'); + expect(manager.currentExerciseLog!.notes, 'Focus on form'); + }); + + test('clears notes when null is passed', () { + manager.updateCurrentExerciseNotes('Some note'); + manager.updateCurrentExerciseNotes(null); + expect(manager.currentExerciseLog!.notes, isNull); + }); + }); + + group('ActiveWorkoutManager - exercise navigation', () { + setUp(() => manager.startWorkout(exerciseIds: ['ex1', 'ex2', 'ex3'])); + + test('nextExercise advances index and returns true', () { + final moved = manager.nextExercise(); + + expect(moved, isTrue); + expect(manager.currentExerciseIndex, 1); + expect(manager.currentExerciseId, 'ex2'); + expect(manager.isFirstExercise, isFalse); + }); + + test('nextExercise returns false at last exercise', () { + manager.nextExercise(); + manager.nextExercise(); + final moved = manager.nextExercise(); + + expect(moved, isFalse); + expect(manager.currentExerciseIndex, 2); + expect(manager.isLastExercise, isTrue); + }); + + test('previousExercise returns false at first exercise', () { + final moved = manager.previousExercise(); + expect(moved, isFalse); + expect(manager.currentExerciseIndex, 0); + }); + + test('previousExercise moves back and returns true', () { + manager.nextExercise(); + final moved = manager.previousExercise(); + + expect(moved, isTrue); + expect(manager.currentExerciseIndex, 0); + expect(manager.isFirstExercise, isTrue); + }); + + test('goToExercise jumps to specified index', () { + manager.goToExercise(2); + expect(manager.currentExerciseIndex, 2); + expect(manager.currentExerciseId, 'ex3'); + expect(manager.isLastExercise, isTrue); + }); + + test('single-exercise workout: isFirst and isLast both true', () { + final mgr = ActiveWorkoutManager(mockStorage); + mgr.startWorkout(exerciseIds: ['solo']); + + expect(mgr.isFirstExercise, isTrue); + expect(mgr.isLastExercise, isTrue); + }); + }); + + group('ActiveWorkoutManager - finishWorkout', () { + test('saves session to storage and clears state', () async { + manager.startWorkout(exerciseIds: ['ex1', 'ex2']); + manager.addSet(WorkoutSet(weight: 100, reps: 5)); + manager.nextExercise(); + manager.addSet(WorkoutSet(weight: 60, reps: 12)); + + final session = await manager.finishWorkout(notes: 'Good session'); + + expect(manager.hasActiveWorkout, isFalse); + expect(manager.currentExerciseLogs, isEmpty); + expect(session.exercises.length, 2); + expect(session.notes, 'Good session'); + expect(mockStorage.sessions, isNotEmpty); + expect(mockStorage.sessions.first.id, session.id); + }); + + test('filters out exercises with no sets', () async { + manager.startWorkout(exerciseIds: ['ex1', 'ex2']); + // Only log sets on ex1; ex2 has none + manager.addSet(WorkoutSet(weight: 80, reps: 10)); + + final session = await manager.finishWorkout(); + + expect(session.exercises.length, 1); + expect(session.exercises.first.exerciseId, 'ex1'); + }); + + test('calls onWorkoutSaved callback with the session', () async { + WorkoutSession? received; + final mgr = ActiveWorkoutManager( + mockStorage, + onWorkoutSaved: (s) => received = s, + ); + mgr.startWorkout(exerciseIds: ['ex1']); + mgr.addSet(WorkoutSet(weight: 50, reps: 10)); + final session = await mgr.finishWorkout(); + + expect(received, isNotNull); + expect(received!.id, session.id); + }); + + test('records routine id when started with a routine', () async { + final routine = Routine( + id: 'r42', + name: 'Leg Day', + exerciseIds: ['ex1'], + ); + manager.startWorkout(routine: routine); + manager.addSet(WorkoutSet(weight: 120, reps: 6)); + final session = await manager.finishWorkout(); + + expect(session.routineId, 'r42'); + }); + }); + + group('ActiveWorkoutManager - cancelWorkout', () { + test('clears state without saving', () { + manager.startWorkout(exerciseIds: ['ex1']); + manager.addSet(WorkoutSet(weight: 80, reps: 10)); + manager.cancelWorkout(); + + expect(manager.hasActiveWorkout, isFalse); + expect(manager.currentExerciseLogs, isEmpty); + expect(mockStorage.sessions, isEmpty); + }); + }); +} diff --git a/workout-logger/test/analytics_manager_test.dart b/workout-logger/test/analytics_manager_test.dart new file mode 100644 index 0000000..a07368f --- /dev/null +++ b/workout-logger/test/analytics_manager_test.dart @@ -0,0 +1,395 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/managers/analytics_manager.dart'; +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; + +WorkoutSession _session({ + required String id, + required String exerciseId, + double weight = 100, + int reps = 10, + int sets = 3, + required DateTime date, +}) { + return WorkoutSession( + id: id, + date: date, + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: exerciseId, + sets: List.generate(sets, (_) => WorkoutSet(weight: weight, reps: reps)), + ), + ], + ); +} + +Exercise _exercise({ + required String id, + required String muscleGroupId, + String category = 'compound', +}) { + return Exercise( + id: id, + name: id, + category: category, + muscleActivations: [ + MuscleActivation(muscleGroupId: muscleGroupId, activationPercentage: 100), + ], + ); +} + +void main() { + late MockStorageService mockStorage; + late MockMLService mockML; + late AnalyticsManager manager; + + setUp(() { + mockStorage = MockStorageService(); + mockML = MockMLService(); + manager = AnalyticsManager(mockStorage, mockML); + }); + + group('AnalyticsManager - buildSessionIndex', () { + test('indexes logs by exerciseId newest-first', () { + final sessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 80, date: DateTime(2024, 1, 1)), + _session(id: 's2', exerciseId: 'ex1', weight: 100, date: DateTime(2024, 1, 10)), + ]; + + manager.buildSessionIndex(sessions); + + // getVolumeProgression uses the index (oldest-first for progression) + final progression = manager.getVolumeProgression('ex1', sessions); + expect(progression.length, 2); + expect(progression.first.date, DateTime(2024, 1, 1)); + expect(progression.last.date, DateTime(2024, 1, 10)); + }); + + test('excludes logs with no sets from index', () { + final emptyLog = WorkoutSession( + id: 's_empty', + date: DateTime(2024, 1, 5), + duration: 10, + exercises: [ExerciseLog(exerciseId: 'ex1', sets: [])], + ); + final sessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 80, date: DateTime(2024, 1, 1)), + emptyLog, + ]; + + manager.buildSessionIndex(sessions); + final progression = manager.getVolumeProgression('ex1', sessions); + + expect(progression.length, 1); + }); + }); + + group('AnalyticsManager - updateGrowthModel', () { + test('trains model when two or more data points exist', () async { + final sessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 80, date: DateTime(2024, 1, 1)), + _session(id: 's2', exerciseId: 'ex1', weight: 90, date: DateTime(2024, 1, 8)), + ]; + + await manager.updateGrowthModel('ex1', sessions); + + expect(manager.getGrowthModel('ex1'), isNotNull); + expect(mockML.trainGrowthModelCallCount, 1); + expect(mockML.extractDataPointsCallCount, 1); + }); + + test('removes stale model when fewer than two data points', () async { + // Seed a model first + final twoSessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 80, date: DateTime(2024, 1, 1)), + _session(id: 's2', exerciseId: 'ex1', weight: 90, date: DateTime(2024, 1, 8)), + ]; + await manager.updateGrowthModel('ex1', twoSessions); + expect(manager.getGrowthModel('ex1'), isNotNull); + + // Now only one session — should clear the model + final oneSession = [ + _session(id: 's1', exerciseId: 'ex1', weight: 80, date: DateTime(2024, 1, 1)), + ]; + await manager.updateGrowthModel('ex1', oneSession); + + expect(manager.getGrowthModel('ex1'), isNull); + }); + + test('fires onGrowthModelUpdated callback', () async { + String? updatedId; + GrowthModel? updatedModel; + final mgr = AnalyticsManager( + mockStorage, + mockML, + onGrowthModelUpdated: (id, model) { + updatedId = id; + updatedModel = model; + }, + ); + + final sessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 80, date: DateTime(2024, 1, 1)), + _session(id: 's2', exerciseId: 'ex1', weight: 90, date: DateTime(2024, 1, 8)), + ]; + await mgr.updateGrowthModel('ex1', sessions); + + expect(updatedId, 'ex1'); + expect(updatedModel, isNotNull); + }); + }); + + group('AnalyticsManager - trainAllGrowthModels', () { + test('trains a model for every exercise present in sessions', () async { + final sessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 80, date: DateTime(2024, 1, 1)), + _session(id: 's2', exerciseId: 'ex1', weight: 90, date: DateTime(2024, 1, 8)), + WorkoutSession( + id: 's3', + date: DateTime(2024, 1, 5), + duration: 30, + exercises: [ + ExerciseLog( + exerciseId: 'ex2', + sets: [WorkoutSet(weight: 60, reps: 12)], + ), + ExerciseLog( + exerciseId: 'ex2', + sets: [WorkoutSet(weight: 65, reps: 10)], + ), + ], + ), + WorkoutSession( + id: 's4', + date: DateTime(2024, 1, 12), + duration: 30, + exercises: [ + ExerciseLog( + exerciseId: 'ex2', + sets: [WorkoutSet(weight: 70, reps: 8)], + ), + ], + ), + ]; + + await manager.trainAllGrowthModels(sessions); + + // ex1 has 2 sessions → model trained + expect(manager.getGrowthModel('ex1'), isNotNull); + // ex2 has 2 sessions → model trained + expect(manager.getGrowthModel('ex2'), isNotNull); + }); + }); + + group('AnalyticsManager - getRecommendations', () { + test('returns mock recommendations for exercise with history', () { + final sessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 80, date: DateTime(2024, 1, 1)), + ]; + manager.buildSessionIndex(sessions); + + final recs = manager.getRecommendations('ex1', sessions); + + expect(recs, isNotEmpty); + expect(mockML.recommendSetsCallCount, 1); + }); + + test('returns default recommendations when no history exists', () { + final sessions = []; + manager.buildSessionIndex(sessions); + + final recs = manager.getRecommendations('ex1', sessions); + + expect(recs.length, 3); + expect(recs.every((r) => r.confidence == 'low'), isTrue); + }); + + test('falls back to scan when index is stale', () { + final sessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 80, date: DateTime(2024, 1, 1)), + ]; + // Build index on a different list instance (stale) + manager.buildSessionIndex([...sessions]); + + // Pass a new list — forces fallback scan + final freshSessions = List.from(sessions); + final recs = manager.getRecommendations('ex1', freshSessions); + + expect(recs, isNotEmpty); + }); + }); + + group('AnalyticsManager - getVolumeProgression', () { + test('returns progression oldest-first via index', () { + final sessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 50, sets: 3, date: DateTime(2024, 1, 1)), + _session(id: 's2', exerciseId: 'ex1', weight: 60, sets: 3, date: DateTime(2024, 1, 8)), + _session(id: 's3', exerciseId: 'ex1', weight: 70, sets: 3, date: DateTime(2024, 1, 15)), + ]; + manager.buildSessionIndex(sessions); + + final progression = manager.getVolumeProgression('ex1', sessions); + + expect(progression.length, 3); + expect(progression[0].date, DateTime(2024, 1, 1)); + expect(progression[1].date, DateTime(2024, 1, 8)); + expect(progression[2].date, DateTime(2024, 1, 15)); + }); + + test('volume values match session totals', () { + final sessions = [ + _session( + id: 's1', + exerciseId: 'ex1', + weight: 100, + reps: 10, + sets: 3, + date: DateTime(2024, 1, 1), + ), + ]; + manager.buildSessionIndex(sessions); + + final progression = manager.getVolumeProgression('ex1', sessions); + + expect(progression.first.volume, 3000.0); // 100 * 10 * 3 + }); + + test('returns empty list when exercise has no sessions', () { + final sessions = [ + _session(id: 's1', exerciseId: 'ex2', weight: 80, date: DateTime(2024, 1, 1)), + ]; + manager.buildSessionIndex(sessions); + + expect(manager.getVolumeProgression('ex1', sessions), isEmpty); + }); + }); + + group('AnalyticsManager - getWeeklyVolumeByMuscle', () { + test('sums volume for sessions within last 7 days', () { + final now = DateTime(2024, 2, 10); + final sessions = [ + _session( + id: 's1', + exerciseId: 'ex1', + weight: 100, + reps: 10, + sets: 1, + date: DateTime(2024, 2, 8), + ), // within 7 days + _session( + id: 's2', + exerciseId: 'ex1', + weight: 100, + reps: 10, + sets: 1, + date: DateTime(2024, 2, 1), + ), // older than 7 days + ]; + final exercises = [_exercise(id: 'ex1', muscleGroupId: 'chest')]; + + final result = manager.getWeeklyVolumeByMuscle( + sessions, + exercises, + now: now, + ); + + expect(result['chest'], 1000.0); // only s1: 100*10*1=1000 + }); + + test('distributes volume by muscle activation percentage', () { + final now = DateTime(2024, 2, 10); + final session = WorkoutSession( + id: 's1', + date: DateTime(2024, 2, 9), + duration: 30, + exercises: [ + ExerciseLog( + exerciseId: 'bench', + sets: [WorkoutSet(weight: 100, reps: 10)], + ), + ], + ); + final exercise = Exercise( + id: 'bench', + name: 'Bench Press', + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 70), + MuscleActivation(muscleGroupId: 'triceps', activationPercentage: 30), + ], + ); + + final result = manager.getWeeklyVolumeByMuscle( + [session], + [exercise], + now: now, + ); + + expect(result['chest'], closeTo(700.0, 0.01)); + expect(result['triceps'], closeTo(300.0, 0.01)); + }); + + test('returns empty map when no sessions in window', () { + final now = DateTime(2024, 2, 10); + final sessions = [ + _session( + id: 's1', + exerciseId: 'ex1', + weight: 100, + reps: 10, + sets: 1, + date: DateTime(2024, 1, 1), + ), + ]; + final exercises = [_exercise(id: 'ex1', muscleGroupId: 'chest')]; + + final result = manager.getWeeklyVolumeByMuscle( + sessions, + exercises, + now: now, + ); + + expect(result, isEmpty); + }); + + test('uses provided exerciseMap for O(1) lookup', () { + final now = DateTime(2024, 2, 10); + final sessions = [ + _session( + id: 's1', + exerciseId: 'ex1', + weight: 50, + reps: 5, + sets: 2, + date: DateTime(2024, 2, 9), + ), + ]; + final exercises = [_exercise(id: 'ex1', muscleGroupId: 'back')]; + final exerciseMap = {for (final e in exercises) e.id: e}; + + final result = manager.getWeeklyVolumeByMuscle( + sessions, + exercises, + now: now, + exerciseMap: exerciseMap, + ); + + expect(result['back'], 500.0); // 50*5*2=500 + }); + }); + + group('AnalyticsManager - getQuickStats', () { + test('returns stats map from storage', () async { + mockStorage.addMockSession( + _session(id: 's1', exerciseId: 'ex1', date: DateTime(2024, 1, 15)), + ); + + final stats = await manager.getQuickStats(); + + expect(stats, isA>()); + expect(stats['totalWorkouts'], 1); + }); + }); +} diff --git a/workout-logger/test/exercise_manager_test.dart b/workout-logger/test/exercise_manager_test.dart new file mode 100644 index 0000000..52726e1 --- /dev/null +++ b/workout-logger/test/exercise_manager_test.dart @@ -0,0 +1,232 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/data/exercise_database.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/managers/exercise_manager.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + late MockStorageService mockStorage; + late ExerciseManager manager; + + setUp(() { + mockStorage = MockStorageService(); + manager = ExerciseManager(mockStorage); + }); + + group('ExerciseManager - loadExercises', () { + test('loads built-in exercises and reflects correct counts', () async { + await manager.loadExercises(); + + final builtInCount = ExerciseDatabase.getAll().length; + expect(manager.allExercises.length, builtInCount); + expect(manager.builtInExercises.length, builtInCount); + expect(manager.customExercises, isEmpty); + }); + + test('merges built-in and custom exercises', () async { + mockStorage.addMockCustomExercise( + Exercise( + id: 'custom_001', + name: 'Cable Lateral Raise', + muscleActivations: [ + MuscleActivation(muscleGroupId: 'shoulders', activationPercentage: 100), + ], + category: 'isolation', + isCustom: true, + ), + ); + + await manager.loadExercises(); + + expect(manager.customExercises.length, 1); + expect(manager.allExercises.length, ExerciseDatabase.getAll().length + 1); + }); + }); + + group('ExerciseManager - getExercise / getExerciseName', () { + setUp(() async => await manager.loadExercises()); + + test('getExercise returns exercise by id via O(1) index', () { + final first = ExerciseDatabase.getAll().first; + final found = manager.getExercise(first.id); + expect(found, isNotNull); + expect(found!.id, first.id); + }); + + test('getExercise returns null for unknown id', () { + expect(manager.getExercise('does_not_exist'), isNull); + }); + + test('getExerciseName returns name for known id', () { + final first = ExerciseDatabase.getAll().first; + expect(manager.getExerciseName(first.id), first.name); + }); + + test('getExerciseName returns fallback for unknown id', () { + expect(manager.getExerciseName('nope'), 'Unknown Exercise'); + }); + }); + + group('ExerciseManager - addCustomExercise', () { + setUp(() async => await manager.loadExercises()); + + test('adds custom exercise with correct fields', () async { + final exercise = await manager.addCustomExercise( + name: 'Dragon Flag', + category: 'compound', + primaryMuscleGroupId: 'core', + ); + + expect(exercise.name, 'Dragon Flag'); + expect(exercise.category, 'compound'); + expect(exercise.isCustom, isTrue); + expect(exercise.id, startsWith('custom_')); + expect(exercise.muscleActivations.first.muscleGroupId, 'core'); + expect(exercise.muscleActivations.first.activationPercentage, 100); + }); + + test('normalizes category to lowercase', () async { + final exercise = await manager.addCustomExercise( + name: 'Lat Pulldown', + category: 'Isolation', + primaryMuscleGroupId: 'back', + ); + expect(exercise.category, 'isolation'); + }); + + test('trims whitespace from name', () async { + final exercise = await manager.addCustomExercise( + name: ' Cable Row ', + category: 'compound', + primaryMuscleGroupId: 'back', + ); + expect(exercise.name, 'Cable Row'); + }); + + test('appears in allExercises and customExercises after add', () async { + final before = manager.allExercises.length; + await manager.addCustomExercise( + name: 'Face Pull', + category: 'isolation', + primaryMuscleGroupId: 'shoulders', + ); + expect(manager.allExercises.length, before + 1); + expect(manager.customExercises.length, 1); + expect(manager.customExercises.first.name, 'Face Pull'); + }); + + test('is findable via getExercise immediately after add', () async { + final exercise = await manager.addCustomExercise( + name: 'Neck Curl', + category: 'isolation', + primaryMuscleGroupId: 'neck', + ); + expect(manager.getExercise(exercise.id), isNotNull); + }); + + test('throws ArgumentError for empty name', () async { + await expectLater( + manager.addCustomExercise( + name: '', + category: 'compound', + primaryMuscleGroupId: 'chest', + ), + throwsArgumentError, + ); + }); + + test('throws ArgumentError for invalid category', () async { + await expectLater( + manager.addCustomExercise( + name: 'Test', + category: 'cardio', + primaryMuscleGroupId: 'legs', + ), + throwsArgumentError, + ); + }); + }); + + group('ExerciseManager - deleteCustomExercise', () { + setUp(() async => await manager.loadExercises()); + + test('removes custom exercise from memory and storage', () async { + final exercise = await manager.addCustomExercise( + name: 'Cable Crunch', + category: 'isolation', + primaryMuscleGroupId: 'core', + ); + + final deleted = await manager.deleteCustomExercise(exercise.id); + + expect(deleted, isTrue); + expect(manager.getExercise(exercise.id), isNull); + expect(manager.customExercises.any((e) => e.id == exercise.id), isFalse); + }); + + test('returns false for built-in exercise', () async { + final builtIn = ExerciseDatabase.getAll().first; + final deleted = await manager.deleteCustomExercise(builtIn.id); + expect(deleted, isFalse); + }); + + test('returns false when canDelete callback returns false', () async { + final exercise = await manager.addCustomExercise( + name: 'Blocked Exercise', + category: 'isolation', + primaryMuscleGroupId: 'chest', + ); + + final deleted = await manager.deleteCustomExercise( + exercise.id, + canDelete: (_) => false, + ); + + expect(deleted, isFalse); + expect(manager.getExercise(exercise.id), isNotNull); + }); + }); + + group('ExerciseManager - filtering and search', () { + setUp(() async => await manager.loadExercises()); + + test('getExercisesByMuscleGroup returns matching exercises', () { + final chestExercises = manager.getExercisesByMuscleGroup('chest'); + expect(chestExercises, isNotEmpty); + for (final e in chestExercises) { + expect( + e.muscleActivations.any((m) => m.muscleGroupId == 'chest'), + isTrue, + ); + } + }); + + test('getExercisesByCategory returns matching exercises (case-insensitive)', + () { + final compound = manager.getExercisesByCategory('Compound'); + expect(compound, isNotEmpty); + for (final e in compound) { + expect(e.category.toLowerCase(), 'compound'); + } + }); + + test('searchExercises returns matches by name substring', () { + final results = manager.searchExercises('press'); + expect(results, isNotEmpty); + for (final e in results) { + expect(e.name.toLowerCase(), contains('press')); + } + }); + + test('searchExercises is case-insensitive', () { + final lower = manager.searchExercises('bench'); + final upper = manager.searchExercises('BENCH'); + expect(lower.map((e) => e.id).toSet(), + equals(upper.map((e) => e.id).toSet())); + }); + + test('searchExercises returns empty list for no matches', () { + expect(manager.searchExercises('xyznonexistent'), isEmpty); + }); + }); +} diff --git a/workout-logger/test/routine_manager_test.dart b/workout-logger/test/routine_manager_test.dart new file mode 100644 index 0000000..2b2d629 --- /dev/null +++ b/workout-logger/test/routine_manager_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/managers/routine_manager.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + late MockStorageService mockStorage; + late RoutineManager manager; + + setUp(() { + mockStorage = MockStorageService(); + manager = RoutineManager(mockStorage); + }); + + group('RoutineManager - loadRoutines', () { + test('populates routines from storage', () async { + mockStorage.addMockRoutine( + Routine(id: 'r1', name: 'Push', exerciseIds: ['ex1']), + ); + mockStorage.addMockRoutine( + Routine(id: 'r2', name: 'Pull', exerciseIds: ['ex2']), + ); + + await manager.loadRoutines(); + + expect(manager.routines.length, 2); + expect(manager.totalRoutines, 2); + expect(manager.routines.map((r) => r.id), containsAll(['r1', 'r2'])); + }); + + test('starts empty when storage has no routines', () async { + await manager.loadRoutines(); + expect(manager.routines, isEmpty); + }); + }); + + group('RoutineManager - createRoutine', () { + test('creates routine with unique id and persists it', () async { + final routine = await manager.createRoutine( + 'Push Day', + ['bench_press', 'overhead_press'], + ); + + expect(routine.name, 'Push Day'); + expect(routine.exerciseIds, ['bench_press', 'overhead_press']); + expect(routine.id, isNotEmpty); + expect(manager.routines.length, 1); + expect(manager.routines.first, same(routine)); + expect(mockStorage.routines.any((r) => r.id == routine.id), isTrue); + }); + + test('creates multiple routines with distinct ids', () async { + final r1 = await manager.createRoutine('Push', ['ex1']); + final r2 = await manager.createRoutine('Pull', ['ex2']); + + expect(r1.id, isNot(equals(r2.id))); + expect(manager.totalRoutines, 2); + }); + }); + + group('RoutineManager - getRoutine', () { + test('returns routine by id', () async { + final created = await manager.createRoutine('Legs', ['squat']); + final found = manager.getRoutine(created.id); + + expect(found, isNotNull); + expect(found!.name, 'Legs'); + }); + + test('returns null for unknown id', () { + expect(manager.getRoutine('nonexistent'), isNull); + }); + }); + + group('RoutineManager - updateRoutine', () { + test('replaces existing routine in memory and storage', () async { + final original = await manager.createRoutine('Push', ['ex1']); + final updated = Routine( + id: original.id, + name: 'Push Day V2', + exerciseIds: ['ex1', 'ex2'], + ); + + await manager.updateRoutine(updated); + + expect(manager.routines.length, 1); + expect(manager.routines.first.name, 'Push Day V2'); + expect(manager.routines.first.exerciseIds, ['ex1', 'ex2']); + final stored = mockStorage.routines.firstWhere((r) => r.id == original.id); + expect(stored.name, 'Push Day V2'); + }); + + test('adds routine if id not found in memory', () async { + final orphan = Routine(id: 'orphan', name: 'New', exerciseIds: []); + await manager.updateRoutine(orphan); + + expect(manager.routines.length, 1); + expect(manager.routines.first.id, 'orphan'); + expect(mockStorage.routines.any((r) => r.id == 'orphan'), isTrue); + }); + }); + + group('RoutineManager - deleteRoutine', () { + test('removes routine from memory and storage', () async { + final r = await manager.createRoutine('Push', ['ex1']); + await manager.deleteRoutine(r.id); + + expect(manager.routines, isEmpty); + expect(mockStorage.routines.any((x) => x.id == r.id), isFalse); + }); + }); + + group('RoutineManager - exercise membership queries', () { + test('isExerciseUsedInRoutines returns true when exercise is in a routine', + () async { + await manager.createRoutine('Push', ['bench', 'ohp']); + expect(manager.isExerciseUsedInRoutines('bench'), isTrue); + }); + + test('isExerciseUsedInRoutines returns false when not used', () async { + await manager.createRoutine('Push', ['bench']); + expect(manager.isExerciseUsedInRoutines('squat'), isFalse); + }); + + test('getRoutinesWithExercise returns matching routines', () async { + await manager.createRoutine('Push', ['bench', 'ohp']); + await manager.createRoutine('Full Body', ['bench', 'squat']); + await manager.createRoutine('Legs', ['squat', 'rdl']); + + final result = manager.getRoutinesWithExercise('bench'); + expect(result.length, 2); + expect(result.map((r) => r.name), containsAll(['Push', 'Full Body'])); + }); + + test('getRoutinesWithExercise returns empty when none match', () async { + await manager.createRoutine('Push', ['bench']); + expect(manager.getRoutinesWithExercise('squat'), isEmpty); + }); + }); +} diff --git a/workout-logger/test/target_calculator_test.dart b/workout-logger/test/target_calculator_test.dart new file mode 100644 index 0000000..ee549de --- /dev/null +++ b/workout-logger/test/target_calculator_test.dart @@ -0,0 +1,206 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/strategies/target_calculator.dart'; + +WorkoutSession _session( + String id, + String exerciseId, { + required List sets, + DateTime? date, +}) { + return WorkoutSession( + id: id, + date: date ?? DateTime(2024, 1, 1), + duration: 30, + exercises: [ExerciseLog(exerciseId: exerciseId, sets: sets)], + ); +} + +void main() { + tearDown(() => TargetCalculatorFactory.reset()); + + group('RepsTargetCalculator', () { + final calculator = RepsTargetCalculator(); + + test('returns 0 when no sessions', () { + expect(calculator.calculate('ex1', []), 0); + }); + + test('returns maximum reps across all sets and sessions', () { + final sessions = [ + _session('s1', 'ex1', sets: [ + WorkoutSet(weight: 80, reps: 8), + WorkoutSet(weight: 80, reps: 10), + ]), + _session('s2', 'ex1', sets: [ + WorkoutSet(weight: 80, reps: 12), + WorkoutSet(weight: 80, reps: 6), + ]), + ]; + + expect(calculator.calculate('ex1', sessions), 12); + }); + + test('ignores sessions for other exercises', () { + final sessions = [ + _session('s1', 'ex1', sets: [WorkoutSet(weight: 80, reps: 10)]), + _session('s2', 'ex2', sets: [WorkoutSet(weight: 80, reps: 20)]), + ]; + + expect(calculator.calculate('ex1', sessions), 10); + }); + }); + + group('WeightTargetCalculator', () { + final calculator = WeightTargetCalculator(); + + test('returns 0 when no sessions', () { + expect(calculator.calculate('ex1', []), 0); + }); + + test('returns 0 for session with empty sets', () { + final sessions = [ + _session('s1', 'ex1', sets: []), + ]; + expect(calculator.calculate('ex1', sessions), 0); + }); + + test('returns maximum weight across all sets and sessions', () { + final sessions = [ + _session('s1', 'ex1', sets: [ + WorkoutSet(weight: 80, reps: 5), + WorkoutSet(weight: 90, reps: 3), + ]), + _session('s2', 'ex1', sets: [ + WorkoutSet(weight: 100, reps: 1), + ]), + ]; + + expect(calculator.calculate('ex1', sessions), 100); + }); + + test('ignores sessions for other exercises', () { + final sessions = [ + _session('s1', 'ex1', sets: [WorkoutSet(weight: 80, reps: 5)]), + _session('s2', 'ex2', sets: [WorkoutSet(weight: 200, reps: 1)]), + ]; + + expect(calculator.calculate('ex1', sessions), 80); + }); + }); + + group('VolumeTargetCalculator', () { + final calculator = VolumeTargetCalculator(); + + test('returns 0 when no sessions', () { + expect(calculator.calculate('ex1', []), 0); + }); + + test('returns maximum session volume across sessions', () { + final sessions = [ + _session('s1', 'ex1', sets: [ + WorkoutSet(weight: 100, reps: 10), // 1000 + WorkoutSet(weight: 100, reps: 10), // 1000 → total 2000 + ]), + _session('s2', 'ex1', sets: [ + WorkoutSet(weight: 80, reps: 12), // 960 + WorkoutSet(weight: 80, reps: 12), // 960 + WorkoutSet(weight: 80, reps: 12), // 960 → total 2880 + ]), + ]; + + expect(calculator.calculate('ex1', sessions), 2880); + }); + + test('ignores sessions for other exercises', () { + final sessions = [ + _session('s1', 'ex1', sets: [WorkoutSet(weight: 100, reps: 10)]), // 1000 + _session('s2', 'ex2', sets: [WorkoutSet(weight: 200, reps: 20)]), // 4000 — must be ignored + ]; + + expect(calculator.calculate('ex1', sessions), 1000); + }); + }); + + group('TargetCalculatorFactory', () { + test('supportedTypes includes reps, weight, volume', () { + expect( + TargetCalculatorFactory.supportedTypes, + containsAll(['reps', 'weight', 'volume']), + ); + }); + + test('getCalculator returns correct strategy for each type', () { + expect(TargetCalculatorFactory.getCalculator('reps'), + isA()); + expect(TargetCalculatorFactory.getCalculator('weight'), + isA()); + expect(TargetCalculatorFactory.getCalculator('volume'), + isA()); + }); + + test('getCalculator is case-insensitive', () { + expect(TargetCalculatorFactory.getCalculator('REPS'), isNotNull); + expect(TargetCalculatorFactory.getCalculator('Weight'), isNotNull); + }); + + test('getCalculator returns null for unknown type', () { + expect(TargetCalculatorFactory.getCalculator('duration'), isNull); + }); + + test('calculateCurrentValue delegates to the right calculator', () { + final sessions = [ + _session('s1', 'ex1', sets: [WorkoutSet(weight: 100, reps: 8)]), + ]; + + expect( + TargetCalculatorFactory.calculateCurrentValue('ex1', 'weight', sessions), + 100, + ); + expect( + TargetCalculatorFactory.calculateCurrentValue('ex1', 'reps', sessions), + 8, + ); + expect( + TargetCalculatorFactory.calculateCurrentValue('ex1', 'volume', sessions), + 800, // 100 * 8 + ); + }); + + test('calculateCurrentValue throws ArgumentError for unsupported type', () { + expect( + () => TargetCalculatorFactory.calculateCurrentValue('ex1', 'speed', []), + throwsArgumentError, + ); + }); + + test('registerCalculator adds a new strategy', () { + const custom = _AlwaysReturns42(); + TargetCalculatorFactory.registerCalculator('custom', custom); + + final result = TargetCalculatorFactory.calculateCurrentValue( + 'ex1', + 'custom', + [], + ); + + expect(result, 42); + expect(TargetCalculatorFactory.supportedTypes, contains('custom')); + }); + + test('reset restores default strategies only', () { + TargetCalculatorFactory.registerCalculator('extra', const _AlwaysReturns42()); + TargetCalculatorFactory.reset(); + + expect(TargetCalculatorFactory.getCalculator('extra'), isNull); + expect(TargetCalculatorFactory.supportedTypes.length, 3); + }); + }); +} + +class _AlwaysReturns42 implements TargetCalculatorStrategy { + const _AlwaysReturns42(); + + @override + double calculate(String exerciseId, List sessions) => 42; +} diff --git a/workout-logger/test/target_manager_test.dart b/workout-logger/test/target_manager_test.dart new file mode 100644 index 0000000..6aac4ec --- /dev/null +++ b/workout-logger/test/target_manager_test.dart @@ -0,0 +1,303 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/managers/target_manager.dart'; +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; + +WorkoutSession _session( + String id, + String exerciseId, { + double weight = 100, + int reps = 10, + int sets = 3, + DateTime? date, +}) { + return WorkoutSession( + id: id, + date: date ?? DateTime(2024, 1, 1), + duration: 30, + exercises: [ + ExerciseLog( + exerciseId: exerciseId, + sets: List.generate( + sets, + (_) => WorkoutSet(weight: weight, reps: reps), + ), + ), + ], + ); +} + +void main() { + late MockStorageService mockStorage; + late MockMLService mockML; + late TargetManager manager; + + setUp(() { + mockStorage = MockStorageService(); + mockML = MockMLService(); + manager = TargetManager(mockStorage, mockML); + }); + + group('TargetManager - loadTargets', () { + test('populates targets from storage', () async { + mockStorage.addMockTarget( + Target( + id: 't1', + exerciseId: 'ex1', + targetType: 'weight', + targetValue: 100, + currentValue: 80, + ), + ); + + await manager.loadTargets(); + + expect(manager.targets.length, 1); + expect(manager.totalTargets, 1); + expect(manager.targets.first.id, 't1'); + }); + }); + + group('TargetManager - growth models', () { + test('updateGrowthModel stores and getGrowthModel retrieves it', () { + final model = GrowthModel( + slope: 2.5, + intercept: 50, + r2: 0.9, + lastTrained: DateTime(2024), + ); + + manager.updateGrowthModel('ex1', model); + + expect(manager.getGrowthModel('ex1'), same(model)); + }); + + test('getGrowthModel returns null for unknown exercise', () { + expect(manager.getGrowthModel('unknown'), isNull); + }); + }); + + group('TargetManager - createTarget', () { + final sessions = [_session('s1', 'ex1', weight: 80, reps: 10, sets: 3)]; + + test('creates weight target with correct current value', () async { + final target = await manager.createTarget( + exerciseId: 'ex1', + type: 'weight', + targetValue: 120, + sessions: sessions, + ); + + expect(target.exerciseId, 'ex1'); + expect(target.targetType, 'weight'); + expect(target.targetValue, 120); + expect(target.currentValue, 80); // max weight logged + expect(target.isCompleted, isFalse); + expect(target.id, isNotEmpty); + }); + + test('creates reps target with correct current value', () async { + final target = await manager.createTarget( + exerciseId: 'ex1', + type: 'reps', + targetValue: 15, + sessions: sessions, + ); + + expect(target.currentValue, 10); // max reps logged + expect(target.isCompleted, isFalse); + }); + + test('creates volume target with correct current value', () async { + // volume = weight * reps per set; best session log volume = 80*10*3 = 2400 + final target = await manager.createTarget( + exerciseId: 'ex1', + type: 'volume', + targetValue: 5000, + sessions: sessions, + ); + + expect(target.currentValue, 2400); + expect(target.isCompleted, isFalse); + }); + + test('marks target as completed when current value meets target', () async { + final target = await manager.createTarget( + exerciseId: 'ex1', + type: 'weight', + targetValue: 70, // already exceeded by 80kg + sessions: sessions, + ); + + expect(target.isCompleted, isTrue); + }); + + test('uses growth model for estimated completion date', () async { + final model = GrowthModel( + slope: 2, + intercept: 60, + r2: 0.85, + lastTrained: DateTime(2024), + ); + manager.updateGrowthModel('ex1', model); + mockML.mockPrediction = DateTime(2024, 6, 1); + + final target = await manager.createTarget( + exerciseId: 'ex1', + type: 'weight', + targetValue: 120, + sessions: sessions, + ); + + expect(target.estimatedCompletionDate, DateTime(2024, 6, 1)); + expect(mockML.predictTargetCompletionCallCount, 1); + }); + + test('persists target to storage', () async { + final target = await manager.createTarget( + exerciseId: 'ex1', + type: 'weight', + targetValue: 100, + sessions: sessions, + ); + + expect(mockStorage.targets.any((t) => t.id == target.id), isTrue); + }); + + test('target appears in manager.targets after creation', () async { + await manager.createTarget( + exerciseId: 'ex1', + type: 'reps', + targetValue: 20, + sessions: sessions, + ); + + expect(manager.targets.length, 1); + }); + + test('throws ArgumentError for unsupported type', () async { + await expectLater( + manager.createTarget( + exerciseId: 'ex1', + type: 'duration', + targetValue: 60, + sessions: sessions, + ), + throwsArgumentError, + ); + }); + }); + + group('TargetManager - activeTargets / completedTargets', () { + test('separates active and completed targets correctly', () async { + final sessions = [_session('s1', 'ex1', weight: 80, reps: 10)]; + await manager.createTarget( + exerciseId: 'ex1', + type: 'weight', + targetValue: 120, // not yet completed + sessions: sessions, + ); + await manager.createTarget( + exerciseId: 'ex1', + type: 'weight', + targetValue: 50, // already completed + sessions: sessions, + ); + + expect(manager.activeTargets.length, 1); + expect(manager.completedTargets.length, 1); + }); + }); + + group('TargetManager - recalculateTargets', () { + test('updates currentValue and isCompleted based on new sessions', () async { + final initialSessions = [ + _session('s1', 'ex1', weight: 80, reps: 10), + ]; + await manager.createTarget( + exerciseId: 'ex1', + type: 'weight', + targetValue: 100, + sessions: initialSessions, + ); + + expect(manager.targets.first.currentValue, 80); + expect(manager.targets.first.isCompleted, isFalse); + + // Simulate a stronger session + final newSessions = [ + _session('s1', 'ex1', weight: 80, reps: 10), + _session('s2', 'ex1', weight: 105, reps: 5), + ]; + + await manager.recalculateTargets({'ex1'}, newSessions); + + expect(manager.targets.first.currentValue, 105); + expect(manager.targets.first.isCompleted, isTrue); + }); + }); + + group('TargetManager - deleteTarget', () { + test('removes target from memory and storage', () async { + final sessions = [_session('s1', 'ex1', weight: 80, reps: 10)]; + final target = await manager.createTarget( + exerciseId: 'ex1', + type: 'reps', + targetValue: 15, + sessions: sessions, + ); + + await manager.deleteTarget(target.id); + + expect(manager.targets, isEmpty); + expect(mockStorage.targets.any((t) => t.id == target.id), isFalse); + }); + }); + + group('TargetManager - isExerciseUsedInTargets', () { + test('returns true when exercise has a target', () async { + final sessions = [_session('s1', 'ex1', weight: 80, reps: 10)]; + await manager.createTarget( + exerciseId: 'ex1', + type: 'weight', + targetValue: 100, + sessions: sessions, + ); + + expect(manager.isExerciseUsedInTargets('ex1'), isTrue); + }); + + test('returns false when exercise has no target', () { + expect(manager.isExerciseUsedInTargets('ex_unused'), isFalse); + }); + }); + + group('TargetManager - getTargetsForExercise', () { + test('returns all targets for a specific exercise', () async { + final sessions = [_session('s1', 'ex1', weight: 80, reps: 10)]; + await manager.createTarget( + exerciseId: 'ex1', + type: 'weight', + targetValue: 100, + sessions: sessions, + ); + await manager.createTarget( + exerciseId: 'ex1', + type: 'reps', + targetValue: 15, + sessions: sessions, + ); + await manager.createTarget( + exerciseId: 'ex2', + type: 'weight', + targetValue: 60, + sessions: [_session('s2', 'ex2', weight: 50, reps: 8)], + ); + + final ex1Targets = manager.getTargetsForExercise('ex1'); + expect(ex1Targets.length, 2); + expect(ex1Targets.every((t) => t.exerciseId == 'ex1'), isTrue); + }); + }); +}