diff --git a/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift index 79ab1f49412..da64ea1a58a 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift @@ -11,10 +11,14 @@ struct BookableProductListSyncable: ListSyncable { let title = Localization.title - let emptyStateMessage = Localization.noMembersFound + let emptyStateMessage = Localization.noServiceFound let emptyItemTitlePlaceholder: String? = nil - let searchConfiguration: ListSearchConfiguration? = nil + let searchConfiguration: ListSearchConfiguration? = ListSearchConfiguration( + searchPrompt: Localization.searchPrompt, + emptySearchTitle: Localization.noServiceFound, + emptySearchDescription: Localization.emptySearchDescription + ) let selectionDisabledMessage: String? = nil @@ -53,7 +57,19 @@ struct BookableProductListSyncable: ListSyncable { /// Creates the action to search items with keyword func createSearchAction(keyword: String, pageNumber: Int, pageSize: Int, completion: @escaping (Result) -> Void) -> Action { - fatalError("Searching is not supported") + ProductAction.searchProducts( + siteID: siteID, + keyword: keyword, + pageNumber: pageNumber, + pageSize: pageSize, + productType: .booking, + onCompletion: completion + ) + } + + /// Creates the predicate for filtering search results + func createSearchPredicate(keyword: String) -> NSPredicate? { + NSPredicate(format: "SUBQUERY(searchResults, $result, $result.keyword = %@).@count > 0", keyword) } // MARK: - Display Configuration @@ -79,10 +95,20 @@ private extension BookableProductListSyncable { value: "Service / Event", comment: "Title of the booking service/event selector view" ) - static let noMembersFound = NSLocalizedString( + static let noServiceFound = NSLocalizedString( "bookingServiceEventSelectorView.noMembersFound", value: "No service or event found", comment: "Text on the empty view of the booking service/event selector view" ) + static let searchPrompt = NSLocalizedString( + "bookingServiceEventSelectorView.searchPrompt", + value: "Search service / event", + comment: "Prompt in the search bar of the booking service/event selector view" + ) + static let emptySearchDescription = NSLocalizedString( + "bookingServiceEventSelectorView.emptySearchDescription", + value: "Try adjusting your search term to see more results", + comment: "Message on the empty search result view of the booking service/event selector view" + ) } } diff --git a/WooCommerce/Classes/Bookings/BookingFilters/CustomerListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/CustomerListSyncable.swift index cd8abbb6fd7..e1c6326b536 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/CustomerListSyncable.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/CustomerListSyncable.swift @@ -66,6 +66,12 @@ struct CustomerListSyncable: ListSyncable { ) } + /// Creates the predicate for filtering search results + /// - Returns: nil because customer search handles filtering directly + func createSearchPredicate(keyword: String) -> NSPredicate? { + nil + } + // MARK: - Display Configuration func displayName(for item: Customer) -> String { diff --git a/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift index 4fe4f949c89..1c7a058d135 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift @@ -30,6 +30,11 @@ protocol ListSyncable { /// Creates the action to search items with keyword func createSearchAction(keyword: String, pageNumber: Int, pageSize: Int, completion: @escaping (Result) -> Void) -> Action + /// Creates the predicate for filtering search results + /// - Parameter keyword: The search keyword + /// - Returns: A predicate to filter storage objects by search results, or nil if search predicate is not needed + func createSearchPredicate(keyword: String) -> NSPredicate? + // MARK: - Display Configuration /// Returns the display name for an item diff --git a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift index bceffcc5701..2d864b6d634 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift @@ -77,12 +77,16 @@ private extension SyncableListSelectorView { } ) .renderedIf(viewModel.searchQuery.isEmpty) + .listRowSeparator(.hidden, edges: .top) - ForEach(items, id: \.self) { item in + ForEach(Array(items.enumerated()), id: \.element) { (index, item) in optionRow(text: syncable.displayName(for: item), description: syncable.description(for: item), isSelected: selectedItems.contains(where: { $0 == syncable.filterItem(for: item) }), onSelection: { toggleSelectionIfPossible(for: item) }) + .if(index == 0 && viewModel.searchQuery.isNotEmpty) { + $0.listRowSeparator(.hidden, edges: .top) + } } InfiniteScrollIndicator(showContent: viewModel.shouldShowBottomActivityIndicator) diff --git a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorViewModel.swift b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorViewModel.swift index d19b416cf42..6c21f90d070 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorViewModel.swift @@ -81,8 +81,15 @@ final class SyncableListSelectorViewModel: ObservableObj /// Handles search query changes by resetting pagination and triggering new search private func handleSearchQueryChange(_ query: String) { - syncState = .syncingFirstPage currentSearchKeyword = query + + // Update the predicate to filter by search results if needed + var predicates = [syncable.createPredicate()] + if !query.isEmpty, let searchPredicate = syncable.createSearchPredicate(keyword: query) { + predicates.append(searchPredicate) + } + resultsController.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + paginationTracker.syncFirstPage() } diff --git a/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift index c0c741c2be0..510e5fcb250 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift @@ -48,6 +48,12 @@ struct TeamMemberListSyncable: ListSyncable { fatalError("Searching is not supported") } + /// Creates the predicate for filtering search results + /// - Returns: nil because searching is not supported for team members + func createSearchPredicate(keyword: String) -> NSPredicate? { + nil + } + // MARK: - Display Configuration func displayName(for item: BookingResource) -> String { diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 973571e4525..13e9fd9faf9 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -2471,6 +2471,7 @@ DE8AA0B32BBE55E40084D2CC /* DashboardViewHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8AA0B22BBE55E40084D2CC /* DashboardViewHostingController.swift */; }; DE8AA0B52BBEBE590084D2CC /* ViewControllerContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8AA0B42BBEBE590084D2CC /* ViewControllerContainer.swift */; }; DE8C22732EB0AE8500C69F35 /* MultiSelectListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C22722EB0AE8500C69F35 /* MultiSelectListView.swift */; }; + DE8C3A002EB3527300C69F35 /* SyncableListSelectorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C39FF2EB3527300C69F35 /* SyncableListSelectorViewModelTests.swift */; }; DE8C63AE2E1E2D2D00DA48AC /* OrderDetailsShipmentDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C63AD2E1E2D1400DA48AC /* OrderDetailsShipmentDetailsView.swift */; }; DE8C946E264699B600C94823 /* PluginListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C946D264699B600C94823 /* PluginListViewModel.swift */; }; DE96844B2A331AD2000FBF4E /* WooAnalyticsEvent+ProductSharingAI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE96844A2A331AD2000FBF4E /* WooAnalyticsEvent+ProductSharingAI.swift */; }; @@ -5403,6 +5404,7 @@ DE8AA0B22BBE55E40084D2CC /* DashboardViewHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewHostingController.swift; sourceTree = ""; }; DE8AA0B42BBEBE590084D2CC /* ViewControllerContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerContainer.swift; sourceTree = ""; }; DE8C22722EB0AE8500C69F35 /* MultiSelectListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelectListView.swift; sourceTree = ""; }; + DE8C39FF2EB3527300C69F35 /* SyncableListSelectorViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncableListSelectorViewModelTests.swift; sourceTree = ""; }; DE8C63AD2E1E2D1400DA48AC /* OrderDetailsShipmentDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsShipmentDetailsView.swift; sourceTree = ""; }; DE8C946D264699B600C94823 /* PluginListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListViewModel.swift; sourceTree = ""; }; DE96844A2A331AD2000FBF4E /* WooAnalyticsEvent+ProductSharingAI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+ProductSharingAI.swift"; sourceTree = ""; }; @@ -12530,6 +12532,7 @@ DED1E3162E8556270089909C /* Bookings */ = { isa = PBXGroup; children = ( + DE8C39FF2EB3527300C69F35 /* SyncableListSelectorViewModelTests.swift */, DE49CD212E966814006DCB07 /* BookingSearchViewModelTests.swift */, DED1E3152E8556270089909C /* BookingListViewModelTests.swift */, 2D054A292E953E3C004111FD /* BookingDetailsViewModelTests.swift */, @@ -16037,6 +16040,7 @@ 746FC23D2200A62B00C3096C /* DateWooTests.swift in Sources */, DEF8CF1129A8933E00800A60 /* JetpackBenefitsViewModelTests.swift in Sources */, 31F21B5A263CB41A0035B50A /* MockCardPresentPaymentsStoresManager.swift in Sources */, + DE8C3A002EB3527300C69F35 /* SyncableListSelectorViewModelTests.swift in Sources */, 86F0896F2B307D7E00D668A1 /* ThemesPreviewViewModelTests.swift in Sources */, CC3B35DF28E5BE6F0036B097 /* ReviewReplyViewModelTests.swift in Sources */, DEE215322D116FBB004A11F3 /* EditStoreListViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/SyncableListSelectorViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/SyncableListSelectorViewModelTests.swift new file mode 100644 index 00000000000..287b1733fc4 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/SyncableListSelectorViewModelTests.swift @@ -0,0 +1,487 @@ +import Combine +import Foundation +import Testing +import Yosemite +import class Storage.ProductSearchResults +import protocol Storage.StorageManagerType +import protocol Storage.StorageType +@testable import WooCommerce + +@MainActor +struct SyncableListSelectorViewModelTests { + + private let sampleSiteID: Int64 = 123 + + /// Mock Storage: InMemory + private var storageManager: StorageManagerType + + /// View storage for tests + private var storage: StorageType { + storageManager.viewStorage + } + + init() { + storageManager = MockStorageManager() + } + + // MARK: - Initialization + + @Test func initial_state_is_empty() { + // Given + let syncable = MockListSyncable(siteID: sampleSiteID) + let stores = MockStoresManager(sessionManager: .testingInstance) + + // When + let viewModel = SyncableListSelectorViewModel(syncable: syncable, stores: stores, storage: storageManager) + + // Then + #expect(viewModel.syncState == .empty) + #expect(viewModel.items.isEmpty) + #expect(viewModel.searchQuery.isEmpty) + #expect(viewModel.shouldShowBottomActivityIndicator == false) + } + + // MARK: - State transitions + + @Test func state_transitions_to_syncing_first_page_on_load_resources() { + // Given + let syncable = MockListSyncable(siteID: sampleSiteID) + let stores = MockStoresManager(sessionManager: .testingInstance) + let viewModel = SyncableListSelectorViewModel(syncable: syncable, stores: stores, storage: storageManager) + + // When + viewModel.loadResources() + + // Then + #expect(viewModel.syncState == .syncingFirstPage) + #expect(viewModel.shouldShowBottomActivityIndicator == true) + } + + @Test func state_transitions_to_results_after_successful_sync_with_data() async { + // Given + let syncable = MockListSyncable(siteID: sampleSiteID) + let stores = MockStoresManager(sessionManager: .testingInstance) + let product = Product.fake().copy(siteID: sampleSiteID, productID: 1, productTypeKey: ProductType.booking.rawValue) + + stores.whenReceivingAction(ofType: ProductAction.self) { action in + guard case let .synchronizeProducts(_, _, _, _, _, _, _, _, _, _, _, onCompletion) = action else { + return + } + self.insertProducts([product]) + onCompletion(.success(true)) + } + + let viewModel = SyncableListSelectorViewModel(syncable: syncable, stores: stores, storage: storageManager) + + var states = [SyncableListSelectorViewModel.SyncState]() + await confirmation("State transitions") { confirmation in + var subscriptions: [AnyCancellable] = [] + var expectedStateCount = 0 + viewModel.$syncState + .removeDuplicates() + .sink { state in + states.append(state) + expectedStateCount += 1 + if expectedStateCount >= 3 { + confirmation() + } + } + .store(in: &subscriptions) + + // When + viewModel.loadResources() + } + + // Then + #expect(states == [.empty, .syncingFirstPage, .results]) + #expect(viewModel.items.count == 1) + #expect(viewModel.shouldShowBottomActivityIndicator == false) + } + + @Test func state_transitions_to_empty_after_successful_sync_with_no_data() async { + // Given + let syncable = MockListSyncable(siteID: sampleSiteID) + let stores = MockStoresManager(sessionManager: .testingInstance) + + stores.whenReceivingAction(ofType: ProductAction.self) { action in + guard case let .synchronizeProducts(_, _, _, _, _, _, _, _, _, _, _, onCompletion) = action else { + return + } + onCompletion(.success(false)) + } + + let viewModel = SyncableListSelectorViewModel(syncable: syncable, stores: stores, storage: storageManager) + + var states = [SyncableListSelectorViewModel.SyncState]() + await confirmation("State transitions") { confirmation in + var subscriptions: [AnyCancellable] = [] + var expectedStateCount = 0 + viewModel.$syncState + .removeDuplicates() + .sink { state in + states.append(state) + expectedStateCount += 1 + if expectedStateCount >= 3 { + confirmation() + } + } + .store(in: &subscriptions) + + // When + viewModel.loadResources() + } + + // Then + #expect(states == [.empty, .syncingFirstPage, .empty]) + #expect(viewModel.items.isEmpty) + } + + // MARK: - Pagination + + @Test func sync_action_is_dispatched_on_load_resources() { + // Given + let syncable = MockListSyncable(siteID: sampleSiteID) + let stores = MockStoresManager(sessionManager: .testingInstance) + var syncActionCalled = false + + stores.whenReceivingAction(ofType: ProductAction.self) { action in + guard case .synchronizeProducts = action else { + return + } + syncActionCalled = true + } + + let viewModel = SyncableListSelectorViewModel(syncable: syncable, stores: stores, storage: storageManager) + + // When + viewModel.loadResources() + + // Then + #expect(syncActionCalled) + } + + @Test func next_page_is_loaded_on_load_next_page_action() async { + // Given + let syncable = MockListSyncable(siteID: sampleSiteID) + let stores = MockStoresManager(sessionManager: .testingInstance) + var syncCallCount = 0 + let firstPageProducts = [Product.fake().copy(siteID: sampleSiteID, productID: 1, productTypeKey: ProductType.booking.rawValue)] + let secondPageProducts = [Product.fake().copy(siteID: sampleSiteID, productID: 2, productTypeKey: ProductType.booking.rawValue)] + + stores.whenReceivingAction(ofType: ProductAction.self) { action in + guard case let .synchronizeProducts(_, pageNumber, _, _, _, _, _, _, _, _, _, onCompletion) = action else { + return + } + syncCallCount += 1 + let products = pageNumber == 1 ? firstPageProducts : secondPageProducts + self.insertProducts(products) + onCompletion(.success(pageNumber == 1)) + } + + let viewModel = SyncableListSelectorViewModel(syncable: syncable, stores: stores, storage: storageManager) + + await confirmation("Pagination") { confirmation in + var subscriptions: [AnyCancellable] = [] + viewModel.$items + .dropFirst() // Skip initial empty state + .removeDuplicates() + .sink { items in + if items.count >= 2 { + confirmation() + } + } + .store(in: &subscriptions) + + // When + viewModel.loadResources() // Load first page + viewModel.onLoadNextPageAction() // Load second page + } + + // Then + #expect(syncCallCount == 2) + #expect(viewModel.items.count == 2) + } + + // MARK: - Search functionality + + @Test func search_action_is_dispatched_when_search_query_changes() async { + // Given + let syncable = MockListSyncable(siteID: sampleSiteID) + let stores = MockStoresManager(sessionManager: .testingInstance) + var searchActionCalled = false + var capturedKeyword: String? + + stores.whenReceivingAction(ofType: ProductAction.self) { action in + guard case let .searchProducts(_, keyword, _, _, _, _, _, _, _, _, onCompletion) = action else { + return + } + searchActionCalled = true + capturedKeyword = keyword + onCompletion(.success(false)) + } + + let viewModel = SyncableListSelectorViewModel(syncable: syncable, stores: stores, storage: storageManager) + + // When + viewModel.searchQuery = "test" + + // Wait for debounce + try? await Task.sleep(nanoseconds: 400_000_000) // 400ms + + // Then + #expect(searchActionCalled) + #expect(capturedKeyword == "test") + } + + @Test func search_predicate_is_applied_when_search_query_is_not_empty() async { + // Given + let syncable = MockListSyncable(siteID: sampleSiteID) + let stores = MockStoresManager(sessionManager: .testingInstance) + let searchKeyword = "booking" + let matchingProduct = Product.fake().copy( + siteID: sampleSiteID, + productID: 1, + name: "Booking Service", + productTypeKey: ProductType.booking.rawValue + ) + let nonMatchingProduct = Product.fake().copy( + siteID: sampleSiteID, + productID: 2, + name: "Other Service", + productTypeKey: ProductType.booking.rawValue + ) + + // Insert products with search results + insertProducts([matchingProduct, nonMatchingProduct]) + addSearchResult(for: matchingProduct, keyword: searchKeyword) + + stores.whenReceivingAction(ofType: ProductAction.self) { action in + guard case let .searchProducts(_, _, _, _, _, _, _, _, _, _, onCompletion) = action else { + return + } + onCompletion(.success(false)) + } + + let viewModel = SyncableListSelectorViewModel(syncable: syncable, stores: stores, storage: storageManager) + + // Initially should have 2 products + #expect(viewModel.items.count == 2) + + // When + viewModel.searchQuery = searchKeyword + + // Wait for debounce and sync to complete + try? await Task.sleep(nanoseconds: 500_000_000) // 500ms + + // Then - should only show products matching the search + #expect(viewModel.items.count == 1) + #expect(viewModel.items.first?.productID == matchingProduct.productID) + } + + @Test func search_predicate_is_removed_when_search_query_is_cleared() async { + // Given + let syncable = MockListSyncable(siteID: sampleSiteID) + let stores = MockStoresManager(sessionManager: .testingInstance) + let product1 = Product.fake().copy(siteID: sampleSiteID, productID: 1, productTypeKey: ProductType.booking.rawValue) + let product2 = Product.fake().copy(siteID: sampleSiteID, productID: 2, productTypeKey: ProductType.booking.rawValue) + + insertProducts([product1, product2]) + addSearchResult(for: product1, keyword: "test") + + stores.whenReceivingAction(ofType: ProductAction.self) { action in + switch action { + case let .searchProducts(_, _, _, _, _, _, _, _, _, _, onCompletion): + onCompletion(.success(false)) + case let .synchronizeProducts(_, _, _, _, _, _, _, _, _, _, _, onCompletion): + onCompletion(.success(false)) + default: + break + } + } + + let viewModel = SyncableListSelectorViewModel(syncable: syncable, stores: stores, storage: storageManager) + + // Set search query + viewModel.searchQuery = "test" + try? await Task.sleep(nanoseconds: 400_000_000) + + #expect(viewModel.items.count == 1) + + // When - clear search query + viewModel.searchQuery = "" + try? await Task.sleep(nanoseconds: 400_000_000) + + // Then - should show all products + #expect(viewModel.items.count == 2) + } + + @Test func syncable_without_search_predicate_uses_base_predicate_during_search() async { + // Given + let syncable = MockListSyncable(siteID: sampleSiteID, hasSearchPredicate: false) + let stores = MockStoresManager(sessionManager: .testingInstance) + var searchActionCalled = false + + let product1 = Product.fake().copy(siteID: sampleSiteID, productID: 1, productTypeKey: ProductType.booking.rawValue) + let product2 = Product.fake().copy(siteID: sampleSiteID, productID: 2, productTypeKey: ProductType.booking.rawValue) + insertProducts([product1, product2]) + + stores.whenReceivingAction(ofType: ProductAction.self) { action in + switch action { + case let .searchProducts(_, _, _, _, _, _, _, _, _, _, onCompletion): + searchActionCalled = true + onCompletion(.success(false)) + case let .synchronizeProducts(_, _, _, _, _, _, _, _, _, _, _, onCompletion): + onCompletion(.success(false)) + default: + break + } + } + + let viewModel = SyncableListSelectorViewModel(syncable: syncable, stores: stores, storage: storageManager) + + // When + viewModel.searchQuery = "test" + + // Wait for debounce + try? await Task.sleep(nanoseconds: 400_000_000) + + // Then - search action should be called but predicate remains unchanged + #expect(searchActionCalled) + #expect(viewModel.items.count == 2) + } + + // MARK: - Error handling + + @Test func state_transitions_to_empty_on_sync_error() async { + // Given + let syncable = MockListSyncable(siteID: sampleSiteID) + let stores = MockStoresManager(sessionManager: .testingInstance) + + stores.whenReceivingAction(ofType: ProductAction.self) { action in + guard case let .synchronizeProducts(_, _, _, _, _, _, _, _, _, _, _, onCompletion) = action else { + return + } + onCompletion(.failure(NSError(domain: "test", code: 1))) + } + + let viewModel = SyncableListSelectorViewModel(syncable: syncable, stores: stores, storage: storageManager) + + var states = [SyncableListSelectorViewModel.SyncState]() + await confirmation("Error state transition") { confirmation in + var subscriptions: [AnyCancellable] = [] + var expectedStateCount = 0 + viewModel.$syncState + .removeDuplicates() + .sink { state in + states.append(state) + expectedStateCount += 1 + if expectedStateCount >= 3 { + confirmation() + } + } + .store(in: &subscriptions) + + // When + viewModel.loadResources() + } + + // Then + #expect(states == [.empty, .syncingFirstPage, .empty]) + #expect(viewModel.shouldShowBottomActivityIndicator == false) + } +} + +// MARK: - Test Helpers + +private extension SyncableListSelectorViewModelTests { + func insertProducts(_ products: [Product]) { + storageManager.performAndSave({ storage in + products.forEach { product in + let storageProduct = storage.insertNewObject(ofType: StorageProduct.self) + storageProduct.update(with: product) + } + }, completion: {}, on: .main) + } + + func addSearchResult(for product: Product, keyword: String) { + storageManager.performAndSave({ storage in + guard let storageProduct = storage.loadProduct(siteID: product.siteID, productID: product.productID) else { + return + } + let searchResult = storage.insertNewObject(ofType: ProductSearchResults.self) + searchResult.keyword = keyword + searchResult.filterKey = ProductSearchFilter.all.rawValue + searchResult.addToProducts(storageProduct) + }, completion: {}, on: .main) + } +} + +// MARK: - Mock Syncable + +private struct MockListSyncable: ListSyncable { + typealias StorageType = StorageProduct + typealias ModelType = Product + typealias ListFilterType = BookingProductFilter + + let siteID: Int64 + let title = "Test Products" + let emptyStateMessage = "No products found" + let emptyItemTitlePlaceholder: String? = nil + let searchConfiguration: ListSearchConfiguration? = nil + let selectionDisabledMessage: String? = nil + + private let hasSearchPredicate: Bool + + init(siteID: Int64, hasSearchPredicate: Bool = true) { + self.siteID = siteID + self.hasSearchPredicate = hasSearchPredicate + } + + func createPredicate() -> NSPredicate { + NSPredicate(format: "siteID == %lld AND productTypeKey == %@", siteID, ProductType.booking.rawValue) + } + + func createSortDescriptors() -> [NSSortDescriptor] { + [NSSortDescriptor(key: "productID", ascending: false)] + } + + func createSyncAction(pageNumber: Int, pageSize: Int, completion: @escaping (Result) -> Void) -> Action { + ProductAction.synchronizeProducts( + siteID: siteID, + pageNumber: pageNumber, + pageSize: pageSize, + stockStatus: nil, + productStatus: nil, + productType: .booking, + productCategory: nil, + sortOrder: .dateDescending, + shouldDeleteStoredProductsOnFirstPage: true, + onCompletion: completion + ) + } + + func createSearchAction(keyword: String, pageNumber: Int, pageSize: Int, completion: @escaping (Result) -> Void) -> Action { + ProductAction.searchProducts( + siteID: siteID, + keyword: keyword, + pageNumber: pageNumber, + pageSize: pageSize, + productType: .booking, + onCompletion: completion + ) + } + + func createSearchPredicate(keyword: String) -> NSPredicate? { + hasSearchPredicate ? NSPredicate(format: "SUBQUERY(searchResults, $result, $result.keyword = %@).@count > 0", keyword) : nil + } + + func displayName(for item: Product) -> String { item.name } + + func description(for item: Product) -> String? { nil } + + func selectionEnabled(for item: Product) -> Bool { true } + + func filterItem(for item: Product) -> BookingProductFilter { + BookingProductFilter(productID: item.productID, name: item.name) + } +}