diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 9327780f1dcd3d..629c71a92d70ca 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -106,6 +106,8 @@ custom_lint: - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart - lib/services/auth.service.dart # on ApiException - test/services/auth.service_test.dart # on ApiException + # allow import from test + - test/**.dart dart_code_metrics: metrics: diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 47baf356b7f6ae..297a819b6a335e 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -266,8 +266,8 @@ class SearchFilter { AssetType? mediaType, }) { return SearchFilter( - context: context, - filename: filename, + context: context ?? this.context, + filename: filename ?? this.filename, people: people ?? this.people, location: location ?? this.location, camera: camera ?? this.camera, diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 01119485cfe4e6..9aca7fc1188162 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -441,19 +441,15 @@ class SearchPage extends HookConsumerWidget { } handleTextSubmitted(String value) { - if (value.isEmpty) { - return; - } - if (isContextualSearch.value) { filter.value = filter.value.copyWith( - filename: null, + filename: '', context: value, ); } else { filter.value = filter.value.copyWith( filename: value, - context: null, + context: '', ); } @@ -468,6 +464,7 @@ class SearchPage extends HookConsumerWidget { Padding( padding: const EdgeInsets.only(right: 14.0), child: IconButton( + key: const Key('contextual_search_button'), icon: isContextualSearch.value ? const Icon(Icons.abc_rounded) : const Icon(Icons.image_search_rounded), @@ -496,6 +493,7 @@ class SearchPage extends HookConsumerWidget { ), ), child: TextField( + key: const Key('search_text_field'), controller: textSearchController, decoration: InputDecoration( contentPadding: prefilter != null @@ -551,6 +549,7 @@ class SearchPage extends HookConsumerWidget { child: SizedBox( height: 50, child: ListView( + key: const Key('search_filter_chip_list'), shrinkWrap: true, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), @@ -580,6 +579,7 @@ class SearchPage extends HookConsumerWidget { currentFilter: dateRangeCurrentFilterWidget.value, ), SearchFilterChip( + key: const Key('media_type_chip'), icon: Icons.video_collection_outlined, onTap: showMediaTypePicker, label: 'search_filter_media_type'.tr(), diff --git a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart index bda9335c771869..fe938e16ed31c9 100644 --- a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart +++ b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart @@ -53,6 +53,7 @@ class FilterBottomSheetScaffold extends StatelessWidget { ), const SizedBox(width: 8), ElevatedButton( + key: const Key('search_filter_apply'), onPressed: () { onSearch(); context.pop(); diff --git a/mobile/lib/widgets/search/search_filter/media_type_picker.dart b/mobile/lib/widgets/search/search_filter/media_type_picker.dart index 350fce155df8a6..495f4d007e63d3 100644 --- a/mobile/lib/widgets/search/search_filter/media_type_picker.dart +++ b/mobile/lib/widgets/search/search_filter/media_type_picker.dart @@ -17,6 +17,7 @@ class MediaTypePicker extends HookWidget { shrinkWrap: true, children: [ RadioListTile( + key: const Key("search_filter_media_type_all"), title: const Text("search_filter_media_type_all").tr(), value: AssetType.other, onChanged: (value) { @@ -26,6 +27,7 @@ class MediaTypePicker extends HookWidget { groupValue: selectedMediaType.value, ), RadioListTile( + key: const Key("search_filter_media_type_image"), title: const Text("search_filter_media_type_image").tr(), value: AssetType.image, onChanged: (value) { @@ -35,6 +37,7 @@ class MediaTypePicker extends HookWidget { groupValue: selectedMediaType.value, ), RadioListTile( + key: const Key("search_filter_media_type_video"), title: const Text("search_filter_media_type_video").tr(), value: AssetType.video, onChanged: (value) { diff --git a/mobile/openapi/devtools_options.yaml b/mobile/openapi/devtools_options.yaml new file mode 100644 index 00000000000000..fa0b357c4f4a29 --- /dev/null +++ b/mobile/openapi/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/mobile/test/dto.mocks.dart b/mobile/test/dto.mocks.dart new file mode 100644 index 00000000000000..ed53fcdc905d4a --- /dev/null +++ b/mobile/test/dto.mocks.dart @@ -0,0 +1,6 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:openapi/api.dart'; + +class MockSmartSearchDto extends Mock implements SmartSearchDto {} + +class MockMetadataSearchDto extends Mock implements MetadataSearchDto {} diff --git a/mobile/test/pages/search/search.page_test.dart b/mobile/test/pages/search/search.page_test.dart new file mode 100644 index 00000000000000..8cdf6104337612 --- /dev/null +++ b/mobile/test/pages/search/search.page_test.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/pages/search/search.page.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:isar/isar.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:openapi/api.dart'; + +import '../../dto.mocks.dart'; +import '../../service.mocks.dart'; +import '../../test_utils.dart'; +import '../../widget_tester_extensions.dart'; + +void main() { + late List overrides; + late Isar db; + late MockApiService mockApiService; + late MockSearchApi mockSearchApi; + + setUpAll(() async { + TestUtils.init(); + db = await TestUtils.initIsar(); + Store.init(db); + mockApiService = MockApiService(); + mockSearchApi = MockSearchApi(); + when(() => mockApiService.searchApi).thenReturn(mockSearchApi); + registerFallbackValue(MockSmartSearchDto()); + registerFallbackValue(MockMetadataSearchDto()); + overrides = [ + paginatedSearchRenderListProvider + .overrideWithValue(AsyncValue.data(RenderList.empty())), + dbProvider.overrideWithValue(db), + apiServiceProvider.overrideWithValue(mockApiService), + ]; + }); + + final emptyTextSearch = isA() + .having((s) => s.originalFileName, 'originalFileName', null); + + testWidgets('contextual search with/without text', (tester) async { + await tester.pumpConsumerWidget( + const SearchPage(), + overrides: overrides, + ); + + await tester.pumpAndSettle(); + + expect( + find.byIcon(Icons.abc_rounded), + findsOneWidget, + reason: 'Should have contextual search icon', + ); + + final searchField = find.byKey(const Key('search_text_field')); + expect(searchField, findsOneWidget); + + await tester.enterText(searchField, 'test'); + await tester.testTextInput.receiveAction(TextInputAction.search); + + var captured = verify( + () => mockSearchApi.searchSmart(captureAny()), + ).captured; + + expect( + captured.first, + isA().having((s) => s.query, 'query', 'test'), + ); + + await tester.enterText(searchField, ''); + await tester.testTextInput.receiveAction(TextInputAction.search); + + captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured; + expect(captured.first, emptyTextSearch); + }); + + testWidgets('not contextual search with/without text', (tester) async { + await tester.pumpConsumerWidget( + const SearchPage(), + overrides: overrides, + ); + + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('contextual_search_button'))); + + await tester.pumpAndSettle(); + + expect( + find.byIcon(Icons.image_search_rounded), + findsOneWidget, + reason: 'Should not have contextual search icon', + ); + + final searchField = find.byKey(const Key('search_text_field')); + expect(searchField, findsOneWidget); + + await tester.enterText(searchField, 'test'); + await tester.testTextInput.receiveAction(TextInputAction.search); + + var captured = verify( + () => mockSearchApi.searchAssets(captureAny()), + ).captured; + + expect( + captured.first, + isA() + .having((s) => s.originalFileName, 'originalFileName', 'test'), + ); + + await tester.enterText(searchField, ''); + await tester.testTextInput.receiveAction(TextInputAction.search); + + captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured; + expect(captured.first, emptyTextSearch); + }); + + // COME BACK LATER + // testWidgets('contextual search with text combined with media type', + // (tester) async { + // await tester.pumpConsumerWidget( + // const SearchPage(), + // overrides: overrides, + // ); + + // await tester.pumpAndSettle(); + + // expect( + // find.byIcon(Icons.abc_rounded), + // findsOneWidget, + // reason: 'Should have contextual search icon', + // ); + + // final searchField = find.byKey(const Key('search_text_field')); + // expect(searchField, findsOneWidget); + + // await tester.enterText(searchField, 'test'); + // await tester.testTextInput.receiveAction(TextInputAction.search); + + // var captured = verify( + // () => mockSearchApi.searchSmart(captureAny()), + // ).captured; + + // expect( + // captured.first, + // isA().having((s) => s.query, 'query', 'test'), + // ); + + // await tester.dragUntilVisible( + // find.byKey(const Key('media_type_chip')), + // find.byKey(const Key('search_filter_chip_list')), + // const Offset(-100, 0), + // ); + // await tester.pumpAndSettle(); + + // await tester.tap(find.byKey(const Key('media_type_chip'))); + // await tester.pumpAndSettle(); + + // await tester.tap(find.byKey(const Key('search_filter_media_type_image'))); + // await tester.pumpAndSettle(); + + // await tester.tap(find.byKey(const Key('search_filter_apply'))); + // await tester.pumpAndSettle(); + + // captured = verify(() => mockSearchApi.searchSmart(captureAny())).captured; + + // expect( + // captured.first, + // isA() + // .having((s) => s.query, 'query', 'test') + // .having((s) => s.type, 'type', AssetTypeEnum.IMAGE), + // ); + + // await tester.enterText(searchField, ''); + // await tester.testTextInput.receiveAction(TextInputAction.search); + + // captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured; + // expect( + // captured.first, + // isA() + // .having((s) => s.originalFileName, 'originalFileName', null) + // .having((s) => s.type, 'type', AssetTypeEnum.IMAGE), + // ); + // }); +} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index 507b4f281b4fbe..cc9d657e9e563b 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:openapi/api.dart'; class MockApiService extends Mock implements ApiService {} @@ -17,3 +18,5 @@ class MockHashService extends Mock implements HashService {} class MockEntityService extends Mock implements EntityService {} class MockNetworkService extends Mock implements NetworkService {} + +class MockSearchApi extends Mock implements SearchApi {}