diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index bfde8aac96..9aca7fc118 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -464,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), @@ -492,6 +493,7 @@ class SearchPage extends HookConsumerWidget { ), ), child: TextField( + key: const Key('search_text_field'), controller: textSearchController, decoration: InputDecoration( contentPadding: prefilter != null @@ -547,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), @@ -576,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/media_type_picker.dart b/mobile/lib/widgets/search/search_filter/media_type_picker.dart index 350fce155d..495f4d007e 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/test/dto.mocks.dart b/mobile/test/dto.mocks.dart new file mode 100644 index 0000000000..ed53fcdc90 --- /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 0000000000..489fa581eb --- /dev/null +++ b/mobile/test/pages/search/search.page_test.dart @@ -0,0 +1,186 @@ +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); + }); + + 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.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 507b4f281b..cc9d657e9e 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 {}