diff --git a/lib/models/exercises/base.dart b/lib/models/exercises/base.dart index bb9d3f1f..535bad73 100644 --- a/lib/models/exercises/base.dart +++ b/lib/models/exercises/base.dart @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:wger/models/exercises/category.dart'; import 'package:wger/models/exercises/comment.dart'; @@ -27,7 +28,7 @@ import 'package:wger/models/exercises/muscle.dart'; part 'base.g.dart'; @JsonSerializable(explicitToJson: true) -class ExerciseBase { +class ExerciseBase extends Equatable { @JsonKey(required: true) final int id; @@ -107,4 +108,16 @@ class ExerciseBase { // Boilerplate factory ExerciseBase.fromJson(Map json) => _$ExerciseBaseFromJson(json); Map toJson() => _$ExerciseBaseToJson(this); + + @override + List get props => [ + id, + uuid, + creationDate, + updateDate, + category, + equipment, + muscles, + musclesSecondary, + ]; } diff --git a/lib/models/exercises/category.g.dart b/lib/models/exercises/category.g.dart index 5083051f..109bac45 100644 --- a/lib/models/exercises/category.g.dart +++ b/lib/models/exercises/category.g.dart @@ -14,7 +14,8 @@ ExerciseCategory _$ExerciseCategoryFromJson(Map json) { ); } -Map _$ExerciseCategoryToJson(ExerciseCategory instance) => { +Map _$ExerciseCategoryToJson(ExerciseCategory instance) => + { 'id': instance.id, 'name': instance.name, }; diff --git a/lib/models/exercises/exercise.dart b/lib/models/exercises/exercise.dart index 76b99de9..d50236cc 100644 --- a/lib/models/exercises/exercise.dart +++ b/lib/models/exercises/exercise.dart @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:wger/models/exercises/base.dart'; import 'package:wger/models/exercises/category.dart'; @@ -28,7 +29,7 @@ import 'package:wger/models/exercises/muscle.dart'; part 'exercise.g.dart'; @JsonSerializable() -class Exercise { +class Exercise extends Equatable { @JsonKey(required: true) final int id; @@ -88,4 +89,16 @@ class Exercise { // Boilerplate factory Exercise.fromJson(Map json) => _$ExerciseFromJson(json); Map toJson() => _$ExerciseToJson(this); + + @override + List get props => [ + id, + baseId, + uuid, + languageId, + creationDate, + name, + description, + base, + ]; } diff --git a/lib/models/exercises/image.g.dart b/lib/models/exercises/image.g.dart index c55b4029..d3fc1a55 100644 --- a/lib/models/exercises/image.g.dart +++ b/lib/models/exercises/image.g.dart @@ -7,7 +7,8 @@ part of 'image.dart'; // ************************************************************************** ExerciseImage _$ExerciseImageFromJson(Map json) { - $checkKeys(json, requiredKeys: const ['id', 'uuid', 'exercise_base', 'image']); + $checkKeys(json, + requiredKeys: const ['id', 'uuid', 'exercise_base', 'image']); return ExerciseImage( id: json['id'] as int, uuid: json['uuid'] as String, @@ -17,7 +18,8 @@ ExerciseImage _$ExerciseImageFromJson(Map json) { ); } -Map _$ExerciseImageToJson(ExerciseImage instance) => { +Map _$ExerciseImageToJson(ExerciseImage instance) => + { 'id': instance.id, 'uuid': instance.uuid, 'exercise_base': instance.exerciseBaseId, diff --git a/lib/providers/exercises.dart b/lib/providers/exercises.dart index 2c068ec6..5f9c5eef 100644 --- a/lib/providers/exercises.dart +++ b/lib/providers/exercises.dart @@ -52,16 +52,21 @@ class ExercisesProvider with ChangeNotifier { static const _languageUrlPath = 'language'; List _exerciseBases = []; - List _exercises = []; List _categories = []; List _muscles = []; List _equipment = []; List _languages = []; + + List _exercises = []; + set exercises(List exercises) { + _exercises = exercises; + } + Filters? _filters; Filters? get filters => _filters; - set filters(Filters? newFilters) { + Future setFilters(Filters? newFilters) async { _filters = newFilters; - this._findByFilters(); + await this.findByFilters(); } List? _filteredExercises = []; @@ -80,32 +85,40 @@ class ExercisesProvider with ChangeNotifier { void _initFilters() { if (_muscles.isEmpty || _equipment.isEmpty || _filters != null) return; - filters = Filters( - exerciseCategories: FilterCategory( - title: 'Muscle Groups', - items: Map.fromEntries( - _categories.map( - (category) => MapEntry(category, false), + this.setFilters( + Filters( + exerciseCategories: FilterCategory( + title: 'Muscle Groups', + items: Map.fromEntries( + _categories.map( + (category) => MapEntry(category, false), + ), ), ), - ), - equipment: FilterCategory( - title: 'Equipment', - items: Map.fromEntries( - _equipment.map( - (singleEquipment) => MapEntry(singleEquipment, false), + equipment: FilterCategory( + title: 'Equipment', + items: Map.fromEntries( + _equipment.map( + (singleEquipment) => MapEntry(singleEquipment, false), + ), ), ), ), ); } - Future _findByFilters() async { + Future findByFilters() async { // Filters not initalized - if (filters == null) filteredExercises = []; + if (filters == null) { + filteredExercises = []; + return; + } // Filters are initialized and nothing is marked - if (filters!.isNothingMarked && filters!.searchTerm.length <= 1) filteredExercises = items; + if (filters!.isNothingMarked && filters!.searchTerm.length <= 1) { + filteredExercises = items; + return; + } filteredExercises = null; @@ -331,6 +344,7 @@ class ExercisesProvider with ChangeNotifier { final exerciseBaseData = await baseProvider.fetch( baseProvider.makeUrl(_exerciseBaseUrlPath, query: {'limit': '1000'}), ); + final exerciseTranslationData = await baseProvider.fetch( baseProvider.makeUrl( _exerciseUrlPath, diff --git a/lib/widgets/exercises/filter_modal.dart b/lib/widgets/exercises/filter_modal.dart index 6c5a724b..26a73466 100644 --- a/lib/widgets/exercises/filter_modal.dart +++ b/lib/widgets/exercises/filter_modal.dart @@ -56,7 +56,8 @@ class _ExerciseFilterModalBodyState extends State { onChanged: (_) { setState(() { filterCategory.items.update(currentEntry.key, (value) => !value); - Provider.of(context, listen: false).filters = filters; + Provider.of(context, listen: false) + .setFilters(filters); }); }, ); diff --git a/lib/widgets/exercises/filter_row.dart b/lib/widgets/exercises/filter_row.dart index abed4a03..649a0b4f 100644 --- a/lib/widgets/exercises/filter_row.dart +++ b/lib/widgets/exercises/filter_row.dart @@ -24,7 +24,8 @@ class _FilterRowState extends State { () { final provider = Provider.of(context, listen: false); if (provider.filters!.searchTerm != _exerciseNameController.text) { - provider.filters = provider.filters!.copyWith(searchTerm: _exerciseNameController.text); + provider + .setFilters(provider.filters!.copyWith(searchTerm: _exerciseNameController.text)); } }, ); diff --git a/test/exercises/exercise_provider_test.dart b/test/exercises/exercise_provider_test.dart index 868c3a1e..92c5dd72 100644 --- a/test/exercises/exercise_provider_test.dart +++ b/test/exercises/exercise_provider_test.dart @@ -1,16 +1,20 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:wger/exceptions/no_such_entry_exception.dart'; import 'package:wger/models/exercises/category.dart'; import 'package:wger/models/exercises/equipment.dart'; +import 'package:wger/models/exercises/exercise.dart'; import 'package:wger/models/exercises/language.dart'; import 'package:wger/models/exercises/muscle.dart'; import 'package:wger/providers/exercises.dart'; import '../fixtures/fixture_reader.dart'; import '../measurements/measurement_provider_test.mocks.dart'; +import '../../test_data/exercises.dart' as data; main() { late MockWgerBaseProvider mockBaseProvider; @@ -20,6 +24,8 @@ main() { String muscleUrl = 'muscle'; String equipmentUrl = 'equipment'; String languageUrl = 'language'; + String exerciseBaseUrl = 'exercise-base'; + String searchExerciseUrl = 'exercise/search'; Uri tCategoryEntriesUri = Uri( scheme: 'http', @@ -45,6 +51,12 @@ main() { path: 'api/v2/' + languageUrl + '/', ); + Uri tSearchByNameUri = Uri( + scheme: 'http', + host: 'localhost', + path: 'api/v2/$searchExerciseUrl/', + ); + final category1 = ExerciseCategory(id: 1, name: 'Arms'); final muscle1 = Muscle(id: 1, name: 'Biceps brachii', isFront: true); final equipment1 = Equipment(id: 1, name: 'Barbell'); @@ -148,4 +160,185 @@ main() { expect(() => provider.findLanguageById(10), throwsA(isA())); }); }); + + group('findByFilters', () { + test('Filters are null', () async { + // arrange + Filters? currentFilters; + + // arrange and act + await provider.setFilters(currentFilters); + + // assert + verifyNever(provider.baseProvider.fetch(tSearchByNameUri)); + expect(provider.filteredExercises, isEmpty); + }); + + group('Filters are not null', () { + late Filters filters; + setUp(() { + SharedPreferences.setMockInitialValues({}); + + filters = Filters( + exerciseCategories: FilterCategory(title: 'Muscle Groups', items: {}), + equipment: FilterCategory(title: 'Equipment', items: {}), + ); + + provider.exercises = data.getExercise(); + }); + + test('Nothing is selected with no search term', () async { + // arrange + Filters currentFilters = filters; + + // act + await provider.setFilters(currentFilters); + + // assert + verifyNever(provider.baseProvider.fetch(tSearchByNameUri)); + expect( + provider.filteredExercises, + data.getExercise(), + ); + }); + + test('A muscle is selected with no search term. Should find results', () async { + // arrange + Filters tFilters = filters.copyWith( + exerciseCategories: filters.exerciseCategories.copyWith(items: {category1: true}), + ); + + // act + await provider.setFilters(tFilters); + + // assert + verifyNever(provider.baseProvider.fetch(tSearchByNameUri)); + expect(provider.filteredExercises, [data.getExercise()[0]]); + }); + + test('A muscle is selected with no search term. Should not find results', () async { + // arragne + Filters tFilters = filters.copyWith( + exerciseCategories: filters.exerciseCategories.copyWith(items: {data.category4: true}), + ); + + // act + await provider.setFilters(tFilters); + + // assert + verifyNever(provider.baseProvider.fetch(tSearchByNameUri)); + expect(provider.filteredExercises, isEmpty); + }); + + test('An equipment is selected with no search term. Should find results', () async { + // arragne + Filters tFilters = filters.copyWith( + equipment: filters.equipment.copyWith(items: {data.equipment1: true}), + ); + + // act + await provider.setFilters(tFilters); + + // assert + verifyNever(provider.baseProvider.fetch(tSearchByNameUri)); + expect(provider.filteredExercises, [data.getExercise()[0]]); + }); + + test('An equipment is selected with no search term. Should not find results', () async { + // arragne + Filters tFilters = filters.copyWith( + equipment: filters.equipment.copyWith(items: {data.equipment3: true}), + ); + + // act + await provider.setFilters(tFilters); + + // assert + verifyNever(provider.baseProvider.fetch(tSearchByNameUri)); + expect(provider.filteredExercises, isEmpty); + }); + + test('A muscle and equipment is selected and there is a match', () async { + // arrange + Filters tFilters = filters.copyWith( + exerciseCategories: filters.exerciseCategories.copyWith(items: {data.category2: true}), + equipment: filters.equipment.copyWith(items: {data.equipment2: true}), + ); + + // act + await provider.setFilters(tFilters); + + // assert + verifyNever(provider.baseProvider.fetch(tSearchByNameUri)); + expect(provider.filteredExercises, [data.getExercise()[1]]); + }); + + test('A muscle and equipment is selected but no match', () async { + // arrange + Filters tFilters = filters.copyWith( + exerciseCategories: filters.exerciseCategories.copyWith(items: {data.category2: true}), + equipment: filters.equipment.copyWith(items: {equipment1: true}), + ); + + // act + await provider.setFilters(tFilters); + + // assert + verifyNever(provider.baseProvider.fetch(tSearchByNameUri)); + expect(provider.filteredExercises, isEmpty); + }); + + group('Search term', () { + late Uri tSearchByNameUri; + setUp(() { + String tSearchTerm = 'press'; + String tSearchLanguage = 'en'; + Map query = {'term': tSearchTerm, 'language': tSearchLanguage}; + tSearchByNameUri = Uri( + scheme: 'http', + host: 'localhost', + path: 'api/v2/$searchExerciseUrl/', + queryParameters: query, + ); + Map tSearchResponse = + jsonDecode(fixture('exercise_search_entries.json')); + + // Mock exercise search + when( + mockBaseProvider.makeUrl( + searchExerciseUrl, + query: {'term': tSearchTerm, 'language': tSearchLanguage}, + ), + ).thenReturn(tSearchByNameUri); + when(mockBaseProvider.fetch(tSearchByNameUri)).thenAnswer((_) async => tSearchResponse); + }); + + test('Should find results from search term', () async { + // arrange + Filters tFilters = filters.copyWith(searchTerm: 'press'); + + // act + await provider.setFilters(tFilters); + + // assert + verify(provider.baseProvider.fetch(tSearchByNameUri)).called(1); + expect(provider.filteredExercises, [data.getExercise()[0], data.getExercise()[1]]); + }); + test('Should find items from selection but should filter them by search term', () async { + // arrange + Filters tFilters = filters.copyWith( + searchTerm: 'press', + exerciseCategories: filters.exerciseCategories.copyWith(items: {data.category3: true}), + ); + + // act + await provider.setFilters(tFilters); + + // assert + verify(provider.baseProvider.fetch(tSearchByNameUri)).called(1); + expect(provider.filteredExercises, isEmpty); + }); + }); + }); + }); } diff --git a/test/fixtures/exercise_search_entries.json b/test/fixtures/exercise_search_entries.json new file mode 100644 index 00000000..e6a03e3d --- /dev/null +++ b/test/fixtures/exercise_search_entries.json @@ -0,0 +1,24 @@ +{ + "suggestions": [ + { + "value": "Bench Press Narrow Grip", + "data": { + "id": 1, + "name": "test exercise 1", + "category": "Arms", + "image": "/media/exercise-images/76/Narrow-grip-bench-press-2.png", + "image_thumbnail": "/media/exercise-images/76/Narrow-grip-bench-press-2.png.30x30_q85_crop-smart.png" + } + }, + { + "value": "Close-grip Bench Press", + "data": { + "id": 2, + "name": "test exercise 2", + "category": "Arms", + "image": null, + "image_thumbnail": null + } + } + ] +} \ No newline at end of file diff --git a/test/gym_mode_screen_test.mocks.dart b/test/gym_mode_screen_test.mocks.dart index 89755532..75e83093 100644 --- a/test/gym_mode_screen_test.mocks.dart +++ b/test/gym_mode_screen_test.mocks.dart @@ -52,6 +52,14 @@ class MockExercisesProvider extends _i1.Mock implements _i9.ExercisesProvider { (super.noSuchMethod(Invocation.getter(#baseProvider), returnValue: _FakeWgerBaseProvider_0()) as _i2.WgerBaseProvider); @override + set exercises(List<_i3.Exercise>? exercises) => + super.noSuchMethod(Invocation.setter(#exercises, exercises), + returnValueForMissingStub: null); + @override + set filteredExercises(List<_i3.Exercise>? newfilteredExercises) => super + .noSuchMethod(Invocation.setter(#filteredExercises, newfilteredExercises), + returnValueForMissingStub: null); + @override List<_i3.Exercise> get items => (super.noSuchMethod(Invocation.getter(#items), returnValue: <_i3.Exercise>[]) as List<_i3.Exercise>); @override @@ -63,12 +71,15 @@ class MockExercisesProvider extends _i1.Mock implements _i9.ExercisesProvider { (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) as bool); @override - void initFilters() => super.noSuchMethod(Invocation.method(#initFilters, []), - returnValueForMissingStub: null); + _i10.Future setFilters(_i9.Filters? newFilters) => (super.noSuchMethod( + Invocation.method(#setFilters, [newFilters]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i10.Future); @override - List<_i3.Exercise> findByFilters() => - (super.noSuchMethod(Invocation.method(#findByFilters, []), - returnValue: <_i3.Exercise>[]) as List<_i3.Exercise>); + _i10.Future findByFilters() => (super.noSuchMethod( + Invocation.method(#findByFilters, []), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i10.Future); @override List<_i3.Exercise> findByCategory(_i4.ExerciseCategory? category) => (super.noSuchMethod(Invocation.method(#findByCategory, [category]), @@ -130,12 +141,12 @@ class MockExercisesProvider extends _i1.Mock implements _i9.ExercisesProvider { returnValue: Future.value(), returnValueForMissingStub: Future.value()) as _i10.Future); @override - _i10.Future> searchExercise(String? name, + _i10.Future> searchExercise(String? name, [String? languageCode = r'en']) => (super.noSuchMethod( Invocation.method(#searchExercise, [name, languageCode]), - returnValue: Future>.value([])) - as _i10.Future>); + returnValue: Future>.value(<_i3.Exercise>[])) + as _i10.Future>); @override String toString() => super.toString(); @override diff --git a/test/workout_set_form_test.mocks.dart b/test/workout_set_form_test.mocks.dart index e8dec906..bcde4813 100644 --- a/test/workout_set_form_test.mocks.dart +++ b/test/workout_set_form_test.mocks.dart @@ -52,6 +52,14 @@ class MockExercisesProvider extends _i1.Mock implements _i9.ExercisesProvider { (super.noSuchMethod(Invocation.getter(#baseProvider), returnValue: _FakeWgerBaseProvider_0()) as _i2.WgerBaseProvider); @override + set exercises(List<_i3.Exercise>? exercises) => + super.noSuchMethod(Invocation.setter(#exercises, exercises), + returnValueForMissingStub: null); + @override + set filteredExercises(List<_i3.Exercise>? newfilteredExercises) => super + .noSuchMethod(Invocation.setter(#filteredExercises, newfilteredExercises), + returnValueForMissingStub: null); + @override List<_i3.Exercise> get items => (super.noSuchMethod(Invocation.getter(#items), returnValue: <_i3.Exercise>[]) as List<_i3.Exercise>); @override @@ -63,12 +71,15 @@ class MockExercisesProvider extends _i1.Mock implements _i9.ExercisesProvider { (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) as bool); @override - void initFilters() => super.noSuchMethod(Invocation.method(#initFilters, []), - returnValueForMissingStub: null); + _i10.Future setFilters(_i9.Filters? newFilters) => (super.noSuchMethod( + Invocation.method(#setFilters, [newFilters]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i10.Future); @override - List<_i3.Exercise> findByFilters() => - (super.noSuchMethod(Invocation.method(#findByFilters, []), - returnValue: <_i3.Exercise>[]) as List<_i3.Exercise>); + _i10.Future findByFilters() => (super.noSuchMethod( + Invocation.method(#findByFilters, []), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i10.Future); @override List<_i3.Exercise> findByCategory(_i4.ExerciseCategory? category) => (super.noSuchMethod(Invocation.method(#findByCategory, [category]), @@ -130,12 +141,12 @@ class MockExercisesProvider extends _i1.Mock implements _i9.ExercisesProvider { returnValue: Future.value(), returnValueForMissingStub: Future.value()) as _i10.Future); @override - _i10.Future> searchExercise(String? name, + _i10.Future> searchExercise(String? name, [String? languageCode = r'en']) => (super.noSuchMethod( Invocation.method(#searchExercise, [name, languageCode]), - returnValue: Future>.value([])) - as _i10.Future>); + returnValue: Future>.value(<_i3.Exercise>[])) + as _i10.Future>); @override String toString() => super.toString(); @override diff --git a/test_data/exercises.dart b/test_data/exercises.dart index 1981b23c..efb9b38e 100644 --- a/test_data/exercises.dart +++ b/test_data/exercises.dart @@ -29,9 +29,11 @@ const muscle3 = Muscle(id: 3, name: 'Booty', isFront: false); const category1 = ExerciseCategory(id: 1, name: 'Arms'); const category2 = ExerciseCategory(id: 2, name: 'Legs'); const category3 = ExerciseCategory(id: 3, name: 'Abs'); +const category4 = ExerciseCategory(id: 4, name: 'Shoulders'); const equipment1 = Equipment(id: 1, name: 'Bench'); const equipment2 = Equipment(id: 1, name: 'Dumbbell'); +const equipment3 = Equipment(id: 2, name: 'Matress'); List getExercise() { final base1 = ExerciseBase(