diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift index 388878c3427..024f1e7847c 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift @@ -7,6 +7,7 @@ import protocol Yosemite.PointOfSaleCouponServiceProtocol import struct Yosemite.PointOfSaleCouponFetchStrategyFactory import protocol Yosemite.PointOfSaleCouponFetchStrategy import class Yosemite.AsyncPaginationTracker +import enum Yosemite.SearchDebounceStrategy protocol PointOfSaleCouponsControllerProtocol: PointOfSaleSearchingItemsControllerProtocol { /// Enables coupons in store settings @@ -57,6 +58,18 @@ protocol PointOfSaleCouponsControllerProtocol: PointOfSaleSearchingItemsControll setSearchingState() } + var currentDebounceStrategy: SearchDebounceStrategy { + fetchStrategy.debounceStrategy + } + + var searchDebounceStrategy: SearchDebounceStrategy { + // Return the debounce strategy that would be used for a search + let searchStrategy = fetchStrategyFactory.searchStrategy(searchTerm: "", + analytics: POSItemFetchAnalytics(itemType: .coupon, + analytics: analyticsProvider)) + return searchStrategy.debounceStrategy + } + @MainActor func loadNextItems(base: ItemListBaseItem) async { guard paginationTracker.hasNextPage else { diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift index b67da2bce67..b14221eb7c8 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift @@ -11,6 +11,7 @@ import struct Yosemite.POSVariableParentProduct import class Yosemite.Store import enum Yosemite.POSItemType import class Yosemite.AsyncPaginationTracker +import enum Yosemite.SearchDebounceStrategy protocol PointOfSaleItemsControllerProtocol { /// @@ -27,6 +28,10 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController /// Searches for items func searchItems(searchTerm: String, baseItem: ItemListBaseItem) async func clearSearchItems(baseItem: ItemListBaseItem) + /// The debouncing strategy from the current fetch strategy + var currentDebounceStrategy: SearchDebounceStrategy { get } + /// The debouncing strategy that will be used when performing a search + var searchDebounceStrategy: SearchDebounceStrategy { get } } @@ -70,7 +75,6 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController fetchStrategy = itemFetchStrategyFactory.searchStrategy(searchTerm: searchTerm, analytics: POSItemFetchAnalytics(itemType: .product, analytics: analyticsProvider)) - setSearchingState(base: baseItem) await loadFirstPage(base: baseItem) } @@ -78,6 +82,19 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController setSearchingState(base: baseItem) } + var currentDebounceStrategy: SearchDebounceStrategy { + fetchStrategy.debounceStrategy + } + + var searchDebounceStrategy: SearchDebounceStrategy { + // Return the debounce strategy that would be used for a search + // We create a temporary search strategy to get its debounce settings + let searchStrategy = itemFetchStrategyFactory.searchStrategy(searchTerm: "", + analytics: POSItemFetchAnalytics(itemType: .product, + analytics: analyticsProvider)) + return searchStrategy.debounceStrategy + } + @MainActor private func loadFirstPage(base: ItemListBaseItem) async { switch base { diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift index a74053265dd..1869e7849de 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift @@ -2,6 +2,7 @@ import Foundation import enum Yosemite.POSItemType import protocol Yosemite.POSSearchHistoryProviding import enum Yosemite.POSItem +import enum Yosemite.SearchDebounceStrategy final class POSProductSearchable: POSSearchable { private let itemListType: ItemListType @@ -24,6 +25,14 @@ final class POSProductSearchable: POSSearchable { itemListType.itemType.searchFieldLabel } + var currentDebounceStrategy: SearchDebounceStrategy { + itemsController.currentDebounceStrategy + } + + var searchDebounceStrategy: SearchDebounceStrategy { + itemsController.searchDebounceStrategy + } + func performSearch(term: String) async { await itemsController.searchItems(searchTerm: term, baseItem: .root) } diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift index 974b7d09868..396ca103e93 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift @@ -1,12 +1,17 @@ import SwiftUI import enum Yosemite.POSItemType import enum Yosemite.POSItem +import enum Yosemite.SearchDebounceStrategy /// Protocol defining search capabilities for POS items protocol POSSearchable { var searchFieldPlaceholder: String { get } /// Recent search history for the current item type var searchHistory: [String] { get } + /// The debouncing strategy currently active based on the controller's current state + var currentDebounceStrategy: SearchDebounceStrategy { get } + /// The debouncing strategy that will be used when performing a search (may differ from current strategy) + var searchDebounceStrategy: SearchDebounceStrategy { get } /// Called when a search should be performed /// - Parameter term: The search term to use @@ -54,40 +59,7 @@ struct POSSearchField: View { .textInputAutocapitalization(.never) .focused($isSearchFieldFocused) .onChange(of: searchTerm) { oldValue, newValue in - // The debouncing logic is a little tricky, because the loading state is held in the controller. - // Arguably, we should use view state `isSearching` for this, so the UI is independent of the request timing. - - // As the user types, we don't want to send every keystroke to the remote, so we debounce the requests. - // However, we don't want to debounce the first keystroke of a new search, so that the loading - // state shows immediately and the UI feels responsive. - - // So, if the last search was finished, we don't debounce the first character. If it didn't - // finish i.e. it is still ongoing, we debounce the next keystrokes by 300ms. In either case, - // the ongoing search is redundant now there's a new search term, so we cancel it. - let shouldDebounceNextSearchRequest = !didFinishSearch - searchTask?.cancel() - - searchTask = Task { - if shouldDebounceNextSearchRequest { - try? await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC) - } else { - searchable.clearSearchResults() - } - - guard !Task.isCancelled else { return } - - guard newValue.isNotEmpty else { - didFinishSearch = true - return - } - - didFinishSearch = false - await searchable.performSearch(term: newValue) - - if !Task.isCancelled { - didFinishSearch = true - } - } + handleSearchTermChange(newValue) } } .onChange(of: keyboardObserver.isKeyboardVisible) { _, isVisible in @@ -100,6 +72,142 @@ struct POSSearchField: View { } } +// MARK: - Search Handling +private extension POSSearchField { + func handleSearchTermChange(_ newValue: String) { + searchTask?.cancel() + + let debounceStrategy = selectDebounceStrategy(for: newValue) + + searchTask = Task { + await executeSearchWithStrategy(debounceStrategy, searchTerm: newValue) + } + } + + func selectDebounceStrategy(for searchTerm: String) -> SearchDebounceStrategy { + // Use searchDebounceStrategy for non-empty search terms (actual searches), + // and currentDebounceStrategy for empty terms (returning to popular products). + searchTerm.isNotEmpty ? searchable.searchDebounceStrategy : searchable.currentDebounceStrategy + } + + func executeSearchWithStrategy(_ strategy: SearchDebounceStrategy, searchTerm: String) async { + switch strategy { + case .smart(let duration, let loadingDelayThreshold): + await executeSmartDebouncedSearch(duration: duration, loadingDelayThreshold: loadingDelayThreshold, searchTerm: searchTerm) + case .simple(let duration, let loadingDelayThreshold): + await executeSimpleDebouncedSearch(duration: duration, loadingDelayThreshold: loadingDelayThreshold, searchTerm: searchTerm) + case .immediate: + await executeImmediateSearch(searchTerm: searchTerm) + } + } + + func executeSmartDebouncedSearch(duration: UInt64, loadingDelayThreshold: UInt64?, searchTerm: String) async { + // Smart debouncing: Don't debounce first keystroke, but debounce subsequent keystrokes + // The loading indicator behavior depends on whether there's a threshold: + // - With threshold: Show loading after threshold if search hasn't completed (prevents flicker) + // - Without threshold: Show loading immediately (responsive feel) + + let isFirstKeystroke = didFinishSearch + + guard searchTerm.isNotEmpty else { + didFinishSearch = true + return + } + + // Handle loading indicators for first keystroke + let loadingTask = isFirstKeystroke ? startLoadingIndicatorTask(threshold: loadingDelayThreshold) : nil + + // Debounce subsequent keystrokes + if !isFirstKeystroke { + try? await Task.sleep(nanoseconds: duration) + } + + guard !Task.isCancelled else { + loadingTask?.cancel() + return + } + + await performSearchAndTrackCompletion(searchTerm: searchTerm) + loadingTask?.cancel() + } + + func executeSimpleDebouncedSearch(duration: UInt64, + loadingDelayThreshold: UInt64?, + searchTerm: String) async { + // Simple debouncing: Always debounce every keystroke + try? await Task.sleep(nanoseconds: duration) + + guard !Task.isCancelled else { return } + guard searchTerm.isNotEmpty else { + didFinishSearch = true + return + } + + didFinishSearch = false + + if let threshold = loadingDelayThreshold { + await performSearchWithDelayedLoading(searchTerm: searchTerm, threshold: threshold) + } else { + searchable.clearSearchResults() + await searchable.performSearch(term: searchTerm) + } + + if !Task.isCancelled { + didFinishSearch = true + } + } + + func executeImmediateSearch(searchTerm: String) async { + guard !Task.isCancelled else { return } + guard searchTerm.isNotEmpty else { + didFinishSearch = true + return + } + + await performSearchAndTrackCompletion(searchTerm: searchTerm) + } + + func startLoadingIndicatorTask(threshold: UInt64?) -> Task? { + if let threshold { + // With threshold: delay showing loading to prevent flicker for fast searches + return Task { @MainActor in + try? await Task.sleep(nanoseconds: threshold) + if !Task.isCancelled { + searchable.clearSearchResults() + } + } + } else { + // No threshold - show loading immediately for responsive feel + searchable.clearSearchResults() + return nil + } + } + + func performSearchWithDelayedLoading(searchTerm: String, threshold: UInt64) async { + // Create a loading task that shows indicators after threshold + let loadingTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: threshold) + if !Task.isCancelled { + searchable.clearSearchResults() + } + } + + await searchable.performSearch(term: searchTerm) + + // Cancel loading task if search completed before threshold + loadingTask.cancel() + } + + private func performSearchAndTrackCompletion(searchTerm: String) async { + didFinishSearch = false + await searchable.performSearch(term: searchTerm) + + if !Task.isCancelled { + didFinishSearch = true + } + } +} + /// A reusable search content view for POS items struct POSSearchContentView: View { @Environment(\.posAnalytics) private var analytics diff --git a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift index 8d2f488db1f..8c56105571e 100644 --- a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift @@ -2,6 +2,7 @@ import SwiftUI import struct WooFoundation.WooAnalyticsEvent import struct Yosemite.POSOrder import enum Yosemite.OrderPaymentMethod +import enum Yosemite.SearchDebounceStrategy struct POSOrderListView: View { @Binding var isSearching: Bool @@ -364,6 +365,18 @@ final class POSOrderSearchable: POSSearchable { [] } + var currentDebounceStrategy: SearchDebounceStrategy { + // Use smart debouncing for order search to match original behavior: + // don't debounce first keystroke to show loading immediately, + // then debounce subsequent keystrokes while search is ongoing + .smart() + } + + var searchDebounceStrategy: SearchDebounceStrategy { + // Orders use the same strategy for both modes + currentDebounceStrategy + } + func performSearch(term: String) async { await ordersController.searchOrders(searchTerm: term) } diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index ead13993bec..131c0485644 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -18,6 +18,7 @@ import struct Yosemite.POSProduct import struct Yosemite.POSProductVariation import protocol Yosemite.POSSearchHistoryProviding import enum Yosemite.POSItemType +import enum Yosemite.SearchDebounceStrategy import protocol Yosemite.PointOfSaleBarcodeScanServiceProtocol import enum Yosemite.PointOfSaleBarcodeScanError import Combine @@ -105,6 +106,8 @@ final class PointOfSalePreviewCouponsController: PointOfSaleCouponsControllerPro @Published var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading(), itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy { .immediate } + var searchDebounceStrategy: SearchDebounceStrategy { .smart() } func enableCoupons() async { } func loadItems(base: ItemListBaseItem) async { } func refreshItems(base: ItemListBaseItem) async { } @@ -118,6 +121,9 @@ final class PointOfSalePreviewItemsController: PointOfSaleSearchingItemsControll itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy { .immediate } + var searchDebounceStrategy: SearchDebounceStrategy { .smart() } + func loadItems(base: ItemListBaseItem) async { switch base { case .root: diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index b1db2239528..a0f86f86046 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -156,6 +156,7 @@ public extension PersistedProduct { /// - Returns: An escaped pattern safe for use in LIKE queries private static func escapeSQLLikePattern(_ pattern: String) -> String { pattern + .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "%", with: "\\%") .replacingOccurrences(of: "_", with: "\\_") } diff --git a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift index 138b9b68702..38e5efa8a8a 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift @@ -6,6 +6,16 @@ import protocol Storage.StorageManagerType public protocol PointOfSaleCouponFetchStrategy { func fetchCoupons(pageNumber: Int) async throws -> PagedItems func fetchLocalCoupons() async throws -> [POSItem] + /// The debouncing strategy to use for search input. + /// Default is `.immediate` (no debouncing) for non-search strategies. + var debounceStrategy: SearchDebounceStrategy { get } +} + +public extension PointOfSaleCouponFetchStrategy { + /// Default implementation returns `.immediate` for strategies that don't need debouncing + var debounceStrategy: SearchDebounceStrategy { + .immediate + } } struct PointOfSaleDefaultCouponFetchStrategy: PointOfSaleCouponFetchStrategy { @@ -89,6 +99,12 @@ struct PointOfSaleSearchCouponFetchStrategy: PointOfSaleCouponFetchStrategy { self.analytics = analytics } + var debounceStrategy: SearchDebounceStrategy { + // Use smart debouncing for remote coupon search + // No loading delay threshold - show loading immediately for responsive feel + .smart() + } + func fetchCoupons(pageNumber: Int) async throws -> PagedItems { let startTime = Date() try await couponStoreMethods.searchCoupons(siteID: siteID, diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift index 06a7a55aecd..186db3c6202 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift @@ -36,7 +36,7 @@ public final class PointOfSaleItemService: PointOfSaleItemServiceProtocol { return .init(items: itemMapper.mapProductsToPOSItems(products: products), hasMorePages: pagedProducts.hasMorePages, totalItems: pagedProducts.totalItems) - } catch AFError.explicitlyCancelled { + } catch AFError.explicitlyCancelled, is CancellationError { throw PointOfSaleItemServiceError.requestCancelled } } @@ -55,7 +55,7 @@ public final class PointOfSaleItemService: PointOfSaleItemServiceProtocol { hasMorePages: pagedVariations.hasMorePages, totalItems: pagedVariations.totalItems ) - } catch AFError.explicitlyCancelled { + } catch AFError.explicitlyCancelled, is CancellationError { throw PointOfSaleItemServiceError.requestCancelled } } diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift index 047e6286b9f..58b32470436 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift @@ -3,10 +3,11 @@ import protocol Storage.GRDBManagerProtocol import protocol Networking.ProductVariationsRemoteProtocol /// Fetch strategy for searching products in the local GRDB catalog using SQL LIKE queries -public struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePurchasableItemFetchStrategy { +struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePurchasableItemFetchStrategy { private let siteID: Int64 private let searchTerm: String private let grdbManager: GRDBManagerProtocol + // periphery:ignore - Reserved for future variation fetching from remote when not in local catalog private let variationsRemote: ProductVariationsRemoteProtocol private let analytics: POSItemFetchAnalyticsTracking private let pageSize: Int @@ -25,7 +26,14 @@ public struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePur self.pageSize = pageSize } - public func fetchProducts(pageNumber: Int) async throws -> PagedItems { + var debounceStrategy: SearchDebounceStrategy { + // Use simple debouncing for local search: always debounce to prevent excessive queries + // even though local searches are fast. 100ms provides responsive feel while preventing + // queries on every keystroke. Delay loading indicators by 150ms to avoid flicker for fast queries. + .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + } + + func fetchProducts(pageNumber: Int) async throws -> PagedItems { let startTime = Date() // Get total count and persisted products in one transaction @@ -62,7 +70,7 @@ public struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePur totalItems: totalCount) } - public func fetchVariations(parentProductID: Int64, pageNumber: Int) async throws -> PagedItems { + func fetchVariations(parentProductID: Int64, pageNumber: Int) async throws -> PagedItems { // Get total count and persisted variations in one transaction let (persistedVariations, totalCount) = try await grdbManager.databaseConnection.read { db in let totalCount = try PersistedProductVariation diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift index f1178dbe7af..90ab4d1a4c4 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift @@ -5,6 +5,17 @@ import protocol Networking.ProductVariationsRemoteProtocol public protocol PointOfSalePurchasableItemFetchStrategy { func fetchProducts(pageNumber: Int) async throws -> PagedItems func fetchVariations(parentProductID: Int64, pageNumber: Int) async throws -> PagedItems + + /// The debouncing strategy to use for search input. + /// Default is `.immediate` (no debouncing) for non-search strategies. + var debounceStrategy: SearchDebounceStrategy { get } +} + +public extension PointOfSalePurchasableItemFetchStrategy { + /// Default implementation returns `.immediate` for strategies that don't need debouncing + var debounceStrategy: SearchDebounceStrategy { + .immediate + } } public struct PointOfSaleDefaultPurchasableItemFetchStrategy: PointOfSalePurchasableItemFetchStrategy { @@ -69,6 +80,14 @@ public struct PointOfSaleSearchPurchasableItemFetchStrategy: PointOfSalePurchasa self.analytics = analytics } + // periphery:ignore - Protocol requirement, used via protocol + public var debounceStrategy: SearchDebounceStrategy { + // Use smart debouncing for remote search: don't debounce first keystroke to show loading immediately, + // then debounce subsequent keystrokes while search is ongoing. + // No loading delay threshold - show loading immediately for responsive feel. + .smart() + } + public func fetchProducts(pageNumber: Int) async throws -> PagedItems { let startTime = Date() let pagedProducts = try await productsRemote.searchProductsForPointOfSale( diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift new file mode 100644 index 00000000000..7faa67bd7d3 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Defines the debouncing behavior for search input +public enum SearchDebounceStrategy: Equatable { + /// Smart debouncing: Skip debounce on first keystroke after a search completes, then debounce subsequent keystrokes. + /// Optionally delays showing loading indicators until a threshold is exceeded. + /// Optimized for slow network searches where the first keystroke should show loading immediately. + /// - Parameters: + /// - duration: The debounce duration in nanoseconds for subsequent keystrokes + /// - loadingDelayThreshold: Optional threshold in nanoseconds before showing loading indicators. If nil, shows loading immediately. + case smart(duration: UInt64 = 500 * NSEC_PER_MSEC, loadingDelayThreshold: UInt64? = nil) + + /// Simple debouncing: Always debounce every keystroke by the specified duration. + /// Optionally delays showing loading indicators until a threshold is exceeded. + /// Optimized for fast local searches to prevent excessive queries and loading flicker. + /// - Parameters: + /// - duration: The debounce duration in nanoseconds + /// - loadingDelayThreshold: Optional threshold in nanoseconds before showing loading indicators. If nil, shows loading immediately. + case simple(duration: UInt64, loadingDelayThreshold: UInt64? = nil) + + /// Immediate: No debouncing, execute search on every keystroke. + /// Use when debouncing is not needed (e.g., non-search operations). + case immediate +} diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift index 0484ea0d91c..5da65e0170e 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift @@ -1,4 +1,6 @@ @testable import PointOfSale +import Yosemite +import Foundation final class MockPointOfSaleCouponsController: PointOfSaleCouponsControllerProtocol { var loadItemsCalled = false @@ -7,6 +9,9 @@ final class MockPointOfSaleCouponsController: PointOfSaleCouponsControllerProtoc var itemsViewState: ItemsViewState = .init(containerState: .content, itemsStack: .init(root: .empty, itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy = .immediate + var searchDebounceStrategy: SearchDebounceStrategy = .smart() + func loadItems(base: ItemListBaseItem) async { loadItemsCalled = true loadItemsBase = base diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift index 976a85379a5..4576e0606fb 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift @@ -2,11 +2,15 @@ import Foundation import Combine @testable import PointOfSale import enum Yosemite.POSItem +import enum Yosemite.SearchDebounceStrategy final class MockPointOfSalePurchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol { var itemsViewState: ItemsViewState = .init(containerState: .content, itemsStack: .init(root: .empty, itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy = .immediate + var searchDebounceStrategy: SearchDebounceStrategy = .smart() + func searchItems(searchTerm: String, baseItem: ItemListBaseItem) async {} func loadItems(base: ItemListBaseItem) async { } @@ -15,5 +19,5 @@ final class MockPointOfSalePurchasableItemsSearchController: PointOfSaleSearchin func loadNextItems(base: ItemListBaseItem) async { } - func clearSearchItems(baseItem: PointOfSale.ItemListBaseItem) { } + func clearSearchItems(baseItem: ItemListBaseItem) { } } diff --git a/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift new file mode 100644 index 00000000000..13392276abd --- /dev/null +++ b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing +import Networking +@testable import Storage +@testable import Yosemite + +@Suite("SearchDebounceStrategy Tests") +struct SearchDebounceStrategyTests { + + // MARK: - Equatable Tests + + @Test("Smart strategies with same duration are equal") + func test_smart_strategies_with_same_duration_are_equal() { + let strategy1: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + + #expect(strategy1 == strategy2) + } + + @Test("Smart strategies with different durations are not equal") + func test_smart_strategies_with_different_durations_are_not_equal() { + let strategy1: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .smart(duration: 300 * NSEC_PER_MSEC) + + #expect(strategy1 != strategy2) + } + + @Test("Simple strategies with same duration and no threshold are equal") + func test_simple_strategies_with_same_duration_and_no_threshold_are_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC) + + #expect(strategy1 == strategy2) + } + + @Test("Simple strategies with same duration and same threshold are equal") + func test_simple_strategies_with_same_duration_and_threshold_are_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + + #expect(strategy1 == strategy2) + } + + @Test("Simple strategies with different durations are not equal") + func test_simple_strategies_with_different_durations_are_not_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 200 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + + #expect(strategy1 != strategy2) + } + + @Test("Simple strategies with different thresholds are not equal") + func test_simple_strategies_with_different_thresholds_are_not_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 400 * NSEC_PER_MSEC) + + #expect(strategy1 != strategy2) + } + + @Test("Immediate strategies are equal") + func test_immediate_strategies_are_equal() { + let strategy1: SearchDebounceStrategy = .immediate + let strategy2: SearchDebounceStrategy = .immediate + + #expect(strategy1 == strategy2) + } + + @Test("Different strategy types are not equal") + func test_different_strategy_types_are_not_equal() { + let smartStrategy: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + let simpleStrategy: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC) + let immediateStrategy: SearchDebounceStrategy = .immediate + + #expect(smartStrategy != simpleStrategy) + #expect(smartStrategy != immediateStrategy) + #expect(simpleStrategy != immediateStrategy) + } +} + +@Suite("Fetch Strategy Debouncing Tests") +struct FetchStrategyDebouncingTests { + private let siteID: Int64 = 123 + private let mockAnalytics = MockPOSItemFetchAnalyticsTracking() + private let mockProductsRemote = MockProductsRemote() + private let mockVariationsRemote = MockProductVariationsRemote() + private let mockCouponStoreMethods = MockCouponStoreMethods() + + // MARK: - Local Search Strategy Tests + + @Test("Local search strategy returns simple debouncing with loading delay threshold") + func test_local_search_strategy_returns_simple_debouncing_with_threshold() async throws { + let grdbManager = try GRDBManager() + + // Initialize site + try await grdbManager.databaseConnection.write { db in + try PersistedSite(id: siteID).insert(db) + } + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: "test", + grdbManager: grdbManager, + variationsRemote: mockVariationsRemote, + analytics: mockAnalytics + ) + + let expected: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + #expect(strategy.debounceStrategy == expected) + } + + // MARK: - Remote Search Strategy Tests + + @Test("Remote search strategy returns smart debouncing") + func test_remote_search_strategy_returns_smart_debouncing() { + let strategy = PointOfSaleSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: "test", + productsRemote: mockProductsRemote, + variationsRemote: mockVariationsRemote, + analytics: mockAnalytics + ) + + let expected: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + #expect(strategy.debounceStrategy == expected) + } + + // MARK: - Default Strategy Tests + + @Test("Default purchasable item strategy returns immediate debouncing") + func test_default_purchasable_item_strategy_returns_immediate_debouncing() { + let strategy = PointOfSaleDefaultPurchasableItemFetchStrategy( + siteID: siteID, + productsRemote: mockProductsRemote, + variationsRemote: mockVariationsRemote, + analytics: mockAnalytics + ) + + #expect(strategy.debounceStrategy == .immediate) + } + + // MARK: - Coupon Strategy Tests + + @Test("Search coupon strategy returns smart debouncing") + func test_search_coupon_strategy_returns_smart_debouncing() { + let strategy = PointOfSaleSearchCouponFetchStrategy( + siteID: siteID, + currencySettings: .init(), + storage: MockStorageManager(), + couponStoreMethods: MockCouponStoreMethods(), + searchTerm: "test", + analytics: mockAnalytics + ) + + let expected: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + #expect(strategy.debounceStrategy == expected) + } + + @Test("Default coupon strategy returns immediate debouncing") + func test_default_coupon_strategy_returns_immediate_debouncing() { + let strategy = PointOfSaleDefaultCouponFetchStrategy( + siteID: siteID, + currencySettings: .init(), + storage: MockStorageManager(), + couponStoreMethods: MockCouponStoreMethods() + ) + + #expect(strategy.debounceStrategy == .immediate) + } +}