diff --git a/Sources/InstantSearchCore/Searcher/AbstractSearcher.swift b/Sources/InstantSearchCore/Searcher/AbstractSearcher.swift index d78e0690..16ec5356 100644 --- a/Sources/InstantSearchCore/Searcher/AbstractSearcher.swift +++ b/Sources/InstantSearchCore/Searcher/AbstractSearcher.swift @@ -125,7 +125,7 @@ open class AbstractSearcher: Searcher, SequencerDelegate public extension AbstractSearcher { /// Search error composition encapsulating the error returned by the search service and the request for which this error occured struct RequestError: Error { - /// Request for which an error occured + /// Request for which an error occurred public let request: Request /// Error returned by the search service diff --git a/Sources/InstantSearchSwiftUI/DataModel/HitsObservableController.swift b/Sources/InstantSearchSwiftUI/DataModel/HitsObservableController.swift index f7e0d60b..b41861a4 100644 --- a/Sources/InstantSearchSwiftUI/DataModel/HitsObservableController.swift +++ b/Sources/InstantSearchSwiftUI/DataModel/HitsObservableController.swift @@ -29,10 +29,13 @@ import InstantSearchTelemetry } public func reload() { - if let hitsSource = hitsSource, hitsSource.numberOfHits() > 0 { - hits = hitsSource.hits - } else { - hits = [] + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if let hitsSource = self.hitsSource, hitsSource.numberOfHits() > 0 { + self.hits = hitsSource.hits + } else { + self.hits = [] + } } } diff --git a/Sources/InstantSearchSwiftUI/View/HitsList.swift b/Sources/InstantSearchSwiftUI/View/HitsList.swift index 5b4b6edd..371080a8 100644 --- a/Sources/InstantSearchSwiftUI/View/HitsList.swift +++ b/Sources/InstantSearchSwiftUI/View/HitsList.swift @@ -52,7 +52,8 @@ } private func row(atIndex index: Int) -> some View { - row(hitsObservable.hits[index], index).onAppear { + let hit = index < hitsObservable.hits.count ? hitsObservable.hits[index] : nil + return row(hit, index).onAppear { hitsObservable.notifyAppearanceOfHit(atIndex: index) } } diff --git a/Tests/InstantSearchSwiftUITests/ObservableControllerTests.swift b/Tests/InstantSearchSwiftUITests/ObservableControllerTests.swift index 8693b111..972f2f4f 100644 --- a/Tests/InstantSearchSwiftUITests/ObservableControllerTests.swift +++ b/Tests/InstantSearchSwiftUITests/ObservableControllerTests.swift @@ -5,7 +5,147 @@ // Created by Vladislav Fitc on 05/11/2021. // +#if !InstantSearchCocoaPods + import InstantSearchCore +#endif +import AlgoliaSearchClient @testable import InstantSearchSwiftUI import XCTest -class ObservableControllerTests: XCTestCase {} +// MARK: - Test Helper + +struct TestRecord: Codable { + let objectID: ObjectID + let value: Value + + init(_ value: Value, objectID: ObjectID = ObjectID(rawValue: UUID().uuidString)) { + self.value = value + self.objectID = objectID + } + + static func withValue(_ value: Value) -> Self { + .init(value) + } +} + +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +class ObservableControllerTests: XCTestCase { + + // MARK: - Basic Functionality Tests + + func testInitialState() { + let controller = HitsObservableController() + + XCTAssertEqual(controller.hits.count, 0) + XCTAssertNil(controller.hitsSource) + } + + func testReloadWithNoHitsSource() { + let controller = HitsObservableController() + + let expectation = self.expectation(description: "hits remain empty") + + controller.reload() + + // Wait for async dispatch to complete + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(controller.hits.count, 0) + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testReloadWithEmptyHitsSource() { + let controller = HitsObservableController() + let interactor = HitsInteractor(infiniteScrolling: .off, showItemsOnEmptyQuery: true) + + controller.hitsSource = interactor + + let expectation = self.expectation(description: "hits updated to empty") + + controller.reload() + + // Wait for async dispatch to complete + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(controller.hits.count, 0) + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testScrollToTop() { + let controller = HitsObservableController() + let initialScrollID = controller.scrollID + + controller.scrollToTop() + + XCTAssertNotEqual(controller.scrollID, initialScrollID) + } + + // MARK: - Thread Safety Tests + + func testReloadIsAsyncAndMainThread() { + let controller = HitsObservableController() + let interactor = HitsInteractor(infiniteScrolling: .off, showItemsOnEmptyQuery: true) + + controller.hitsSource = interactor + + let expectation = self.expectation(description: "reload completes on main thread") + + // Call reload from a background thread + DispatchQueue.global(qos: .background).async { + controller.reload() + + // Wait for main thread update + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // Verify reload was dispatched to main thread (test passes if no crash) + XCTAssertTrue(Thread.isMainThread) + expectation.fulfill() + } + } + + waitForExpectations(timeout: 1.0) + } + + // MARK: - Concurrency Tests + + func testConcurrentReloadAndAccessDoesNotCrash() { + let controller = HitsObservableController() + let interactor = HitsInteractor(infiniteScrolling: .off, showItemsOnEmptyQuery: true) + + controller.hitsSource = interactor + + let expectation = self.expectation(description: "concurrent operations complete") + expectation.expectedFulfillmentCount = 2 + + // Simulate concurrent reloads + DispatchQueue.global(qos: .background).async { + for _ in 0..<10 { + controller.reload() + } + expectation.fulfill() + } + + // Concurrently access hits array (simulating ForEach iteration) + DispatchQueue.main.async { + for _ in 0..<50 { + let count = controller.hits.count + // Try to access indices - should not crash with bounds checking + for index in 0..