diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a27e9..d6a6c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,26 @@ # DevFoundation Changelog +## 1.6.0: October 24, 2025 + +This release introduces the `LiveQuery` subsystem, a set of types for managing search-as-you-type +functionality and other query-based operations. Live queries automatically handle scheduling, +deduplication, and caching as query fragments change. + + - `LiveQuery` is an `Observable` type that produces results as its query fragment changes. It + coordinates between user input and result production, managing debouncing, duplicate removal, + and error handling. + - `LiveQueryResultsProducer` is a protocol that defines how to generate results for query + fragments. Conforming types specify their scheduling strategy and implement result production + logic. + - `LiveQuerySchedulingStrategy` determines when results are generated: `.passthrough` for + immediate results (best for cheap operations like local filtering), or `.debounce(_:)` to wait + for typing to pause (best for expensive operations like network requests). + +The live query subsystem is fully thread-safe, `Sendable`, and integrates seamlessly with SwiftUI +through the Observation framework. + + ## 1.5.0: October 22, 2025 This release adds the `UserSelection` type, a generic structure that manages a user’s selection with diff --git a/Package.resolved b/Package.resolved index 84d99b6..f964ad1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8c9c5f6252f63613cd2bd94e26edc1e8a576e4dfda3f1bbdae3b4db9ccf67968", + "originHash" : "92ff398a7ce63387dbc1def4471ad9511bc7ac22e49894ad104172d481ee0012", "pins" : [ { "identity" : "devtesting", @@ -19,6 +19,24 @@ "version" : "1.6.1" } }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, { "identity" : "swift-numerics", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 36b18d0..83e5c90 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.6.1"), + .package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.4"), .package(url: "https://github.com/apple/swift-numerics.git", from: "1.1.0"), .package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.5.0"), .package(url: "https://github.com/prachigauriar/URLMock.git", from: "1.3.6"), @@ -38,7 +39,10 @@ let package = Package( targets: [ .target( name: "DevFoundation", - swiftSettings: swiftSettings, + dependencies: [ + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") + ], + swiftSettings: swiftSettings ), .testTarget( name: "DevFoundationTests", diff --git a/Sources/DevFoundation/Documentation.docc/Documentation.md b/Sources/DevFoundation/Documentation.docc/Documentation.md index bd4d261..3adcf19 100644 --- a/Sources/DevFoundation/Documentation.docc/Documentation.md +++ b/Sources/DevFoundation/Documentation.docc/Documentation.md @@ -24,6 +24,12 @@ for paging through data, and essential utility types for building robust applica - +### Live Queries + +- ``LiveQuery`` +- ``LiveQueryResultsProducer`` +- ``LiveQuerySchedulingStrategy`` + ### Caching - ``ExpiringValue`` @@ -57,6 +63,10 @@ for paging through data, and essential utility types for building robust applica - ``GibberishGenerator`` +### Handling User Inputs + +- ``UserSelection`` + ### Obfuscating and Deobfuscating Data - ``Foundation/Data`` diff --git a/Sources/DevFoundation/Live Query/LiveQuery.swift b/Sources/DevFoundation/Live Query/LiveQuery.swift new file mode 100644 index 0000000..c6589b8 --- /dev/null +++ b/Sources/DevFoundation/Live Query/LiveQuery.swift @@ -0,0 +1,208 @@ +// +// LiveQuery.swift +// DevFoundation +// +// Created by Prachi Gauriar on 10/23/2025. +// + +import AsyncAlgorithms +import Foundation +import Observation +import Synchronization + +/// An observable query that produces results as its fragment changes. +/// +/// Live queries manage the lifecycle of producing results as users type. Set the ``queryFragment`` property and observe +/// ``results`` for updates. The query automatically handles scheduling, deduplication, and caching. +/// +/// +/// ## Overview +/// +/// `LiveQuery` provides a reactive interface for search-as-you-type functionality. It coordinates between user input +/// and result production, handling common concerns like debouncing, duplicate removal, and error management. +/// +/// +/// ## Usage +/// +/// Create a live query by providing a ``LiveQueryResultsProducer`` that defines how to generate results: +/// +/// struct SearchProducer: LiveQueryResultsProducer { +/// var schedulingStrategy: LiveQuerySchedulingStrategy { +/// .debounce(.milliseconds(300)) +/// } +/// +/// func results(forQueryFragment queryFragment: String) async throws -> [SearchResult] { +/// // Perform search and return results +/// } +/// } +/// +/// let liveQuery = LiveQuery(resultsProducer: SearchProducer()) +/// +/// Update the query fragment to trigger result production: +/// +/// liveQuery.queryFragment = "search term" +/// +/// Observe results using SwiftUI or the Observation framework: +/// +/// @State var liveQuery = LiveQuery(resultsProducer: SearchProducer()) +/// +/// var body: some View { +/// List(liveQuery.results ?? []) { result in +/// Text(result.title) +/// } +/// .searchable(text: $liveQuery.queryFragment) +/// } +/// +/// +/// ## Scheduling Strategies +/// +/// The results producer’s ``LiveQueryResultsProducer/schedulingStrategy`` determines when results are generated: +/// +/// - ``LiveQuerySchedulingStrategy/passthrough``: Produces results immediately for every change. Best for cheap +/// operations like filtering local data. +/// - ``LiveQuerySchedulingStrategy/debounce(_:)``: Waits for typing to pause before producing results. Best for +/// expensive operations like network requests. +/// +/// +/// ## Error Handling +/// +/// Errors from result production are captured in ``lastError``. The query continues operating after errors, +/// preserving the last successful results until new ones are produced. +/// +/// +/// ## Thread Safety +/// +/// `LiveQuery` is fully thread-safe and `Sendable`. All properties can be accessed from any thread, though +/// observation notifications follow the standard Observation framework behavior. +/// +/// +/// ## Performance +/// +/// The query automatically deduplicates identical fragments and canonicalizes input through the results producer. +/// This prevents unnecessary work when users make redundant changes. +@Observable +public final class LiveQuery: Sendable where Results: Sendable { + /// The query’s mutable state. + private struct State { + /// The current query fragment. + var queryFragment: String = "" + + /// The latest results. + var results: Results? + + /// The last error that occurred. + var lastError: (any Error)? + + /// The task that handles inputs. + var inputHandlingTask: Task? + } + + /// A mutex that synchronizes access to the query’s mutable state. + private let stateMutex = Mutex(State()) + + /// A continuation used to yield query fragment changes to the input handling task. + private let queryFragmentContinuation: AsyncStream.Continuation + + /// The results producer used to generate results. + private let resultsProducer: any LiveQueryResultsProducer + + + /// Creates a live query with the specified results producer. + /// + /// - Parameter resultsProducer: The producer used to generate results. + public init(resultsProducer: some LiveQueryResultsProducer) { + self.resultsProducer = resultsProducer + + let (queryFragmentStream, queryFragmentContinuation) = AsyncStream.makeStream() + self.queryFragmentContinuation = queryFragmentContinuation + + let queryFragmentValues = queryFragmentStream.schedule(with: resultsProducer.schedulingStrategy) + let task = Task { [weak self] in + for await queryFragment in queryFragmentValues { + guard let self else { + return + } + + do { + let results = try await resultsProducer.results(forQueryFragment: queryFragment) + + _$observationRegistrar.willSet(self, keyPath: \.lastError) + _$observationRegistrar.willSet(self, keyPath: \.results) + stateMutex.withLock { state in + state.results = results + state.lastError = nil + } + _$observationRegistrar.didSet(self, keyPath: \.results) + _$observationRegistrar.didSet(self, keyPath: \.lastError) + } catch { + withMutation(keyPath: \.lastError) { + stateMutex.withLock { $0.lastError = error } + } + } + } + } + + stateMutex.withLock { $0.inputHandlingTask = task } + } + + + deinit { + stateMutex.withLock { $0.inputHandlingTask?.cancel() } + queryFragmentContinuation.finish() + } + + + /// The current query fragment. + /// + /// Updates trigger result production based on the scheduling strategy. + public var queryFragment: String { + get { + access(keyPath: \.queryFragment) + return stateMutex.withLock(\.queryFragment) + } + + set { + withMutation(keyPath: \.queryFragment) { + stateMutex.withLock { $0.queryFragment = newValue } + } + + if let canonicalQueryFragment = resultsProducer.canonicalQueryFragment(from: newValue) { + queryFragmentContinuation.yield(canonicalQueryFragment) + } + } + } + + + /// The most recent query results, or `nil` if none have been produced yet. + public var results: Results? { + access(keyPath: \.results) + return stateMutex.withLock(\.results) + } + + + /// The error that occurred the last time we produced results, or `nil` if none occurred. + public var lastError: (any Error)? { + access(keyPath: \.lastError) + return stateMutex.withLock(\.lastError) + } +} + + +extension AsyncSequence where Self: Sendable, Element: Equatable & Sendable { + /// Applies modifiers to the input stream so that it delivers values according to the strategy. + /// + /// - Parameter inputSequence: The input sequence to modify. + /// - Returns: A sequence with duplicates removed and the appropriate timing strategy applied. + fileprivate func schedule( + with strategy: LiveQuerySchedulingStrategy + ) -> any AsyncSequence { + let deduplicated = removeDuplicates() + + switch strategy.strategy { + case .passthrough: + return deduplicated + case .debounce(let duration): + return deduplicated.debounce(for: duration) + } + } +} diff --git a/Sources/DevFoundation/Live Query/LiveQueryResultsProducer.swift b/Sources/DevFoundation/Live Query/LiveQueryResultsProducer.swift new file mode 100644 index 0000000..3a32f69 --- /dev/null +++ b/Sources/DevFoundation/Live Query/LiveQueryResultsProducer.swift @@ -0,0 +1,46 @@ +// +// LiveQueryResultsProducer.swift +// DevFoundation +// +// Created by Prachi Gauriar on 10/23/2025. +// + +import Foundation + +/// A type that produces results for query fragments. +/// +/// Conform to this protocol to define how results are generated or fetched for a given query fragment. ``LiveQuery`` +/// uses result producers to fetch results and determine how to schedule result production. +public protocol LiveQueryResultsProducer: Sendable { + /// The type of results that instances produce. + associatedtype Results: Sendable + + /// The strategy used to schedule result production. + var schedulingStrategy: LiveQuerySchedulingStrategy { get } + + /// Returns a canonical form of the query fragment, or `nil` if it’s invalid. + /// + /// Conforming types can use this function to sanitize or validate query fragments before producing results. The + /// default implementation trims whitespace, collapses multiple spaces into one, and returns `nil` for empty + /// strings. + /// + /// - Parameter queryFragment: The query fragment to canonicalize. + func canonicalQueryFragment(from queryFragment: String) -> String? + + /// Produces results for the specified query fragment. + /// + /// - Parameter queryFragment: A canonical query fragment. Live queries ensure this is already canonicalized before + /// calling this function. + func results(forQueryFragment queryFragment: String) async throws -> Results +} + + +extension LiveQueryResultsProducer { + public func canonicalQueryFragment(from queryFragment: String) -> String? { + let canonicalFragment = queryFragment.components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + + return canonicalFragment.isEmpty ? nil : canonicalFragment + } +} diff --git a/Sources/DevFoundation/Live Query/LiveQuerySchedulingStrategy.swift b/Sources/DevFoundation/Live Query/LiveQuerySchedulingStrategy.swift new file mode 100644 index 0000000..f4e990b --- /dev/null +++ b/Sources/DevFoundation/Live Query/LiveQuerySchedulingStrategy.swift @@ -0,0 +1,51 @@ +// +// LiveQuerySchedulingStrategy.swift +// DevFoundation +// +// Created by Prachi Gauriar on 10/23/2025. +// + +import Foundation + +/// A strategy for scheduling when result production occurs as query fragments change. +/// +/// Different strategies balance user experience against resource usage. Some strategies prioritize showing results +/// immediately, while others wait to reduce unnecessary queries. Live queries automatically skip duplicate query +/// fragments to prevent redundant work. +public struct LiveQuerySchedulingStrategy: Hashable, Sendable { + /// The underlying strategies used to schedule result production. + /// + /// This enum exists so that we can add new strategies without breaking the public API. + enum Strategy: Hashable, Sendable { + /// Produces results for every change immediately. + case passthrough + + /// Waits for changes to stop before producing results. + case debounce(Duration) + } + + + /// The strategy used to schedule result production. + let strategy: Strategy + + + /// Produces results immediately for every query fragment change. + /// + /// Use this strategy when results are cheap to produce, like filtering in-memory data or validating input. + public static let passthrough = Self(strategy: .passthrough) + + + /// Waits for typing to pause before producing results. + /// + /// Debouncing delays result production until the query fragment hasn’t changed for the specified duration. This + /// minimizes the number of queries at the cost of slightly delayed feedback. If a user types “search” rapidly, + /// results only appear after they stop typing. + /// + /// This strategy is best for expensive operations like API calls where reducing request volume matters more than + /// instant feedback. Keep durations under 500ms to avoid feeling sluggish. Typical ranges are between 250–500ms. + /// + /// - Parameter duration: How long to wait after the last change before producing results. + public static func debounce(_ duration: Duration) -> Self { + Self(strategy: .debounce(duration)) + } +} diff --git a/Tests/DevFoundationTests/Live Query/LiveQueryResultsProducerTests.swift b/Tests/DevFoundationTests/Live Query/LiveQueryResultsProducerTests.swift new file mode 100644 index 0000000..126fa9f --- /dev/null +++ b/Tests/DevFoundationTests/Live Query/LiveQueryResultsProducerTests.swift @@ -0,0 +1,51 @@ +// +// LiveQueryResultsProducerTests.swift +// DevFoundation +// +// Created by Prachi Gauriar on 10/24/2025. +// + +import Foundation +import Testing + +@testable import DevFoundation + +struct LiveQueryResultsProducerTests { + @Test( + arguments: [ + ("hello", "hello"), + (" hello ", "hello"), + ("hello world", "hello world"), + (" hello world ", "hello world"), + ("hello\nworld", "hello world"), + ("hello\tworld", "hello world"), + (" hello \n world \t test ", "hello world test"), + ("", nil), + (" ", nil), + ("\n\t", nil), + (" \n \t ", nil), + ] + ) + func canonicalQueryFragment(input: String, expected: String?) { + // set up the test by creating a results producer + let producer = TestResultsProducer() + + // exercise the test by canonicalizing the query fragment + let result = producer.canonicalQueryFragment(from: input) + + // expect the result matches the expected canonical form + #expect(result == expected) + } + + + struct TestResultsProducer: LiveQueryResultsProducer { + var schedulingStrategy: LiveQuerySchedulingStrategy { + .passthrough + } + + + func results(forQueryFragment queryFragment: String) async throws -> [String] { + [] + } + } +} diff --git a/Tests/DevFoundationTests/Live Query/LiveQuerySchedulingStrategyTests.swift b/Tests/DevFoundationTests/Live Query/LiveQuerySchedulingStrategyTests.swift new file mode 100644 index 0000000..ac5d23b --- /dev/null +++ b/Tests/DevFoundationTests/Live Query/LiveQuerySchedulingStrategyTests.swift @@ -0,0 +1,39 @@ +// +// LiveQuerySchedulingStrategyTests.swift +// DevFoundation +// +// Created by Prachi Gauriar on 10/24/2025. +// + +import DevTesting +import Foundation +import Testing + +@testable import DevFoundation + +struct LiveQuerySchedulingStrategyTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + @Test + func passthroughPropertyCreatesPassthroughStrategy() { + // exercise the test by accessing the passthrough property + let strategy = LiveQuerySchedulingStrategy.passthrough + + // expect that the strategy is passthrough + #expect(strategy.strategy == .passthrough) + } + + + @Test + mutating func debounceCreatesDebounceStrategyWithDuration() { + // set up the test by generating a random duration + let duration = Duration.milliseconds(randomInt(in: 0 ... 10_000)) + + // exercise the test by creating a debounce strategy + let strategy = LiveQuerySchedulingStrategy.debounce(duration) + + // expect that the strategy is debounce with the correct duration + #expect(strategy.strategy == .debounce(duration)) + } +} diff --git a/Tests/DevFoundationTests/Live Query/LiveQueryTests.swift b/Tests/DevFoundationTests/Live Query/LiveQueryTests.swift new file mode 100644 index 0000000..1df298a --- /dev/null +++ b/Tests/DevFoundationTests/Live Query/LiveQueryTests.swift @@ -0,0 +1,304 @@ +// +// LiveQueryTests.swift +// DevFoundation +// +// Created by Prachi Gauriar on 10/24/2025. +// + +import DevTesting +import Foundation +import Testing + +@testable import DevFoundation + +struct LiveQueryTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + // MARK: - Initialization + + @Test + mutating func initializationSetsCorrectDefaults() { + // set up the test by creating a mock results producer + let resultsProducer = MockLiveQueryResultsProducer<[String]>() + resultsProducer.schedulingStrategyStub = Stub(defaultReturnValue: randomLiveQuerySchedulingStrategy()) + resultsProducer.canonicalQueryFragmentStub = Stub(defaultReturnValue: nil) + + // exercise the test by initializing a live query + let liveQuery = LiveQuery(resultsProducer: resultsProducer) + + // expect the initial state is correct + #expect(liveQuery.queryFragment == "") + #expect(liveQuery.results == nil) + #expect(liveQuery.lastError == nil) + } + + + // MARK: - queryFragment + + @Test + mutating func settingQueryFragmentThatCanonicalizesToNilDoesNotCallResultsProducer() async throws { + // set up the test by creating a mock results producer + let resultsProducer = MockLiveQueryResultsProducer<[String]>() + resultsProducer.schedulingStrategyStub = Stub(defaultReturnValue: .passthrough) + resultsProducer.canonicalQueryFragmentStub = Stub(defaultReturnValue: nil) + resultsProducer.resultsStub = ThrowingStub(defaultReturnValue: [randomAlphanumericString()]) + + let liveQuery = LiveQuery(resultsProducer: resultsProducer) + + // exercise the test by setting a query fragment that canonicalizes to nil + liveQuery.queryFragment = randomAlphanumericString() + + // wait briefly to ensure no call happens + try await Task.sleep(for: .milliseconds(50)) + + // expect the results producer was not called + #expect(resultsProducer.resultsStub.calls.isEmpty) + } + + + @Test + mutating func settingQueryFragmentsWithSameCanonicalFormDeduplicatesCalls() async throws { + // set up the test by creating a mock results producer with epilogue + let resultsProducer = MockLiveQueryResultsProducer<[String]>() + resultsProducer.schedulingStrategyStub = Stub(defaultReturnValue: .passthrough) + + let canonicalFragment = randomAlphanumericString() + resultsProducer.canonicalQueryFragmentStub = Stub(defaultReturnValue: canonicalFragment) + resultsProducer.resultsStub = ThrowingStub(defaultReturnValue: [randomAlphanumericString()]) + + let (signalStream, signaler) = AsyncStream.makeStream() + resultsProducer.resultsEpilogue = { + signaler.yield() + } + + let liveQuery = LiveQuery(resultsProducer: resultsProducer) + + let fragment1 = randomAlphanumericString() + let fragment2 = randomAlphanumericString() + + // exercise the test by setting two different query fragments that canonicalize to the same value + liveQuery.queryFragment = fragment1 + liveQuery.queryFragment = fragment2 + + // wait for the first call to complete + await signalStream.first { _ in true } + + // expect canonicalQueryFragment was called twice with correct arguments + #expect(resultsProducer.canonicalQueryFragmentStub.callArguments == [fragment1, fragment2]) + + // expect the results producer was called only once with the canonical fragment + #expect(resultsProducer.resultsStub.callArguments == [canonicalFragment]) + } + + + // MARK: - results + + @Test + mutating func settingValidQueryFragmentProducesResults() async throws { + // set up the test by creating a mock results producer + let resultsProducer = MockLiveQueryResultsProducer<[String]>() + resultsProducer.schedulingStrategyStub = Stub(defaultReturnValue: .passthrough) + + let queryFragment = randomAlphanumericString() + resultsProducer.canonicalQueryFragmentStub = Stub(defaultReturnValue: queryFragment) + + let expectedResults = [randomAlphanumericString(), randomAlphanumericString()] + resultsProducer.resultsStub = ThrowingStub(defaultReturnValue: expectedResults) + + let (signalStream, signaler) = AsyncStream.makeStream() + resultsProducer.resultsEpilogue = { + signaler.yield() + } + + let liveQuery = LiveQuery(resultsProducer: resultsProducer) + + // exercise the test by setting a valid query fragment + liveQuery.queryFragment = queryFragment + + // wait for results to be produced + await signalStream.first { _ in true } + + // expect the results match what the producer returned + #expect(liveQuery.results == expectedResults) + #expect(resultsProducer.resultsStub.callArguments == [queryFragment]) + } + + + // MARK: - lastError + + @Test + mutating func errorFromProducerUpdatesLastErrorAndPreservesResults() async throws { + // set up the test by creating a mock results producer + let resultsProducer = MockLiveQueryResultsProducer<[String]>() + resultsProducer.schedulingStrategyStub = Stub(defaultReturnValue: .passthrough) + + let fragment1 = randomAlphanumericString() + let fragment2 = randomAlphanumericString() + let canonicalFragment1 = randomAlphanumericString() + let canonicalFragment2 = randomAlphanumericString() + resultsProducer.canonicalQueryFragmentStub = Stub( + defaultReturnValue: canonicalFragment2, + returnValueQueue: [canonicalFragment1] + ) + + let initialResults = [randomAlphanumericString()] + let expectedError = randomError() + resultsProducer.resultsStub = ThrowingStub( + defaultError: expectedError, + resultQueue: [.success(initialResults)] + ) + + let (signalStream, signaler) = AsyncStream.makeStream() + resultsProducer.resultsEpilogue = { + signaler.yield() + } + + let liveQuery = LiveQuery(resultsProducer: resultsProducer) + + // exercise the test by first producing successful results + liveQuery.queryFragment = fragment1 + await signalStream.first { _ in true } + + #expect(liveQuery.results == initialResults) + #expect(liveQuery.lastError == nil) + + // exercise the test by producing an error + liveQuery.queryFragment = fragment2 + await signalStream.first { _ in true } + + // expect the error is captured and previous results are preserved + #expect(liveQuery.results == initialResults) + #expect(liveQuery.lastError as? MockError == expectedError) + } + + + @Test + mutating func successfulResultAfterErrorClearsLastError() async throws { + // set up the test by creating a mock results producer + let resultsProducer = MockLiveQueryResultsProducer<[String]>() + resultsProducer.schedulingStrategyStub = Stub(defaultReturnValue: .passthrough) + + let fragment1 = randomAlphanumericString() + let fragment2 = randomAlphanumericString() + let canonicalFragment1 = randomAlphanumericString() + let canonicalFragment2 = randomAlphanumericString() + resultsProducer.canonicalQueryFragmentStub = Stub( + defaultReturnValue: canonicalFragment2, + returnValueQueue: [canonicalFragment1] + ) + + let expectedError = randomError() + let newResults = [randomAlphanumericString()] + resultsProducer.resultsStub = ThrowingStub( + defaultReturnValue: newResults, + resultQueue: [.failure(expectedError)] + ) + + let (signalStream, signaler) = AsyncStream.makeStream() + resultsProducer.resultsEpilogue = { + signaler.yield() + } + + let liveQuery = LiveQuery(resultsProducer: resultsProducer) + + // exercise the test by first producing an error + liveQuery.queryFragment = fragment1 + await signalStream.first { _ in true } + + #expect(liveQuery.results == nil) + #expect(liveQuery.lastError as? MockError == expectedError) + + // exercise the test by producing successful results + liveQuery.queryFragment = fragment2 + await signalStream.first { _ in true } + + // expect the error is cleared + #expect(liveQuery.results == newResults) + #expect(liveQuery.lastError == nil) + } + + + // MARK: - Scheduling Strategies + + @Test + mutating func passthroughStrategyProducesResultsForAllFragments() async throws { + // set up the test by creating a mock results producer with passthrough strategy + let resultsProducer = MockLiveQueryResultsProducer<[String]>() + resultsProducer.schedulingStrategyStub = Stub(defaultReturnValue: .passthrough) + + let canonicalFragment1 = randomAlphanumericString() + let canonicalFragment2 = randomAlphanumericString() + let canonicalFragment3 = randomAlphanumericString() + resultsProducer.canonicalQueryFragmentStub = Stub( + defaultReturnValue: canonicalFragment3, + returnValueQueue: [canonicalFragment1, canonicalFragment2] + ) + + let results1 = [randomAlphanumericString()] + let results2 = [randomAlphanumericString()] + let results3 = [randomAlphanumericString()] + resultsProducer.resultsStub = ThrowingStub( + defaultReturnValue: results3, + resultQueue: [.success(results1), .success(results2)] + ) + + let (signalStream, signaler) = AsyncStream.makeStream() + resultsProducer.resultsEpilogue = { + signaler.yield() + } + + let liveQuery = LiveQuery(resultsProducer: resultsProducer) + + // exercise the test by setting multiple query fragments + liveQuery.queryFragment = randomAlphanumericString() + liveQuery.queryFragment = randomAlphanumericString() + liveQuery.queryFragment = randomAlphanumericString() + + // wait for all three results to be produced + for try await _ in signalStream.prefix(3) {} + + // expect all three results were produced + #expect( + resultsProducer.resultsStub.callArguments == [canonicalFragment1, canonicalFragment2, canonicalFragment3] + ) + #expect(liveQuery.results == results3) + } + + + @Test + mutating func debounceStrategyDelaysResultProduction() async throws { + // set up the test by creating a mock results producer with debounce strategy + let resultsProducer = MockLiveQueryResultsProducer<[String]>() + let debounceDuration = Duration.milliseconds(200) + resultsProducer.schedulingStrategyStub = Stub(defaultReturnValue: .debounce(debounceDuration)) + + let canonicalFragment = randomAlphanumericString() + resultsProducer.canonicalQueryFragmentStub = Stub(defaultReturnValue: canonicalFragment) + + let expectedResults = [randomAlphanumericString()] + resultsProducer.resultsStub = ThrowingStub(defaultReturnValue: expectedResults) + + let (signalStream, signaler) = AsyncStream.makeStream() + resultsProducer.resultsEpilogue = { + signaler.yield() + } + + let liveQuery = LiveQuery(resultsProducer: resultsProducer) + + // exercise the test by setting a query fragment + liveQuery.queryFragment = randomAlphanumericString() + + // expect results are not immediately available + try await Task.sleep(for: .milliseconds(50)) + #expect(liveQuery.results == nil) + #expect(resultsProducer.resultsStub.callArguments.isEmpty) + + // wait for debounce to complete + await signalStream.first { _ in true } + + // expect results are now available + #expect(liveQuery.results == expectedResults) + #expect(resultsProducer.resultsStub.callArguments == [canonicalFragment]) + } +} diff --git a/Tests/DevFoundationTests/Testing Helpers/MockLiveQueryResultsProducer.swift b/Tests/DevFoundationTests/Testing Helpers/MockLiveQueryResultsProducer.swift new file mode 100644 index 0000000..9fb5c76 --- /dev/null +++ b/Tests/DevFoundationTests/Testing Helpers/MockLiveQueryResultsProducer.swift @@ -0,0 +1,39 @@ +// +// MockLiveQueryResultsProducer.swift +// DevFoundation +// +// Created by Prachi Gauriar on 10/24/2025. +// + +import DevTesting +import Foundation + +@testable import DevFoundation + + +final class MockLiveQueryResultsProducer: LiveQueryResultsProducer where Results: Sendable { + nonisolated(unsafe) var schedulingStrategyStub: Stub! + nonisolated(unsafe) var canonicalQueryFragmentStub: Stub! + nonisolated(unsafe) var resultsStub: ThrowingStub! + nonisolated(unsafe) var resultsEpilogue: (() async throws -> Void)? + + + var schedulingStrategy: LiveQuerySchedulingStrategy { + schedulingStrategyStub() + } + + + func canonicalQueryFragment(from queryFragment: String) -> String? { + canonicalQueryFragmentStub(queryFragment) + } + + + func results(forQueryFragment queryFragment: String) async throws -> Results { + defer { + if let epilogue = resultsEpilogue { + Task { try? await epilogue() } + } + } + return try resultsStub(queryFragment) + } +} diff --git a/Tests/DevFoundationTests/Testing Helpers/RandomValueGenerating+DevFoundation.swift b/Tests/DevFoundationTests/Testing Helpers/RandomValueGenerating+DevFoundation.swift index 07b7932..4381f95 100644 --- a/Tests/DevFoundationTests/Testing Helpers/RandomValueGenerating+DevFoundation.swift +++ b/Tests/DevFoundationTests/Testing Helpers/RandomValueGenerating+DevFoundation.swift @@ -67,6 +67,11 @@ extension RandomValueGenerating { } + mutating func randomLiveQuerySchedulingStrategy() -> LiveQuerySchedulingStrategy { + return randomBool() ? .passthrough : .debounce(.milliseconds(randomInt(in: 10 ... 10_000))) + } + + mutating func randomMediaType() -> MediaType { return MediaType("\(randomAlphanumericString())/\(randomAlphanumericString())") }