Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/InstantSearchCore/Searcher/AbstractSearcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ open class AbstractSearcher<Service: SearchService>: 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion Sources/InstantSearchSwiftUI/View/HitsList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
142 changes: 141 additions & 1 deletion Tests/InstantSearchSwiftUITests/ObservableControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value: Codable>: 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<String>()

XCTAssertEqual(controller.hits.count, 0)
XCTAssertNil(controller.hitsSource)
}

func testReloadWithNoHitsSource() {
let controller = HitsObservableController<String>()

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<String>()
let interactor = HitsInteractor<String>(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<String>()
let initialScrollID = controller.scrollID

controller.scrollToTop()

XCTAssertNotEqual(controller.scrollID, initialScrollID)
}

// MARK: - Thread Safety Tests

func testReloadIsAsyncAndMainThread() {
let controller = HitsObservableController<String>()
let interactor = HitsInteractor<String>(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<String>()
let interactor = HitsInteractor<String>(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..<count {
if index < controller.hits.count {
_ = controller.hits[index]
}
}
Thread.sleep(forTimeInterval: 0.001)
}
expectation.fulfill()
}

waitForExpectations(timeout: 5.0)
}
}

// Note: Full integration tests with SearchResponse are in InstantSearchCoreTests.
// These tests focus on the SwiftUI-specific threading and bounds checking behavior.