From 1262a03d388b2488b730eced3a566f6984ccdca6 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Fri, 9 May 2025 10:47:17 +0200 Subject: [PATCH 01/41] Bump PrincipleMacros --- Package.resolved | 6 +++--- Package.swift | 14 ++------------ Sources/Probing/Imports.swift | 10 ---------- 3 files changed, 5 insertions(+), 25 deletions(-) delete mode 100644 Sources/Probing/Imports.swift diff --git a/Package.resolved b/Package.resolved index 38aa930..59bd1e3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "cf9e9b2e390026dca11e218c86984cd1350fe349932bb490e0b0ea47ce66b64a", + "originHash" : "3c4f50eb2486121f00c308626fcdd9c864894f1af2662c1319872a67f76ae93c", "pins" : [ { "identity" : "principle", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/NSFatalError/PrincipleMacros", "state" : { - "revision" : "e4b9e83001aa017f649e45e7657dee769eac7eef", - "version" : "1.0.3" + "revision" : "6381001399379bcbe787f20f13ff5a3b9ff3ef7c", + "version" : "1.0.4" } }, { diff --git a/Package.swift b/Package.swift index 2379fc1..6e5f830 100644 --- a/Package.swift +++ b/Package.swift @@ -68,24 +68,16 @@ let package = Package( .library( name: "ProbeTesting", targets: ["ProbeTesting"] - ), - .library( - name: "DeeplyCopyable", - targets: ["DeeplyCopyable"] - ), - .library( - name: "EquatableObject", - targets: ["EquatableObject"] ) ], dependencies: [ .package( url: "https://github.com/NSFatalError/Principle", - from: "1.0.0" + from: "1.0.3" ), .package( url: "https://github.com/NSFatalError/PrincipleMacros", - from: "1.0.0" + from: "1.0.4" ), .package( url: "https://github.com/swiftlang/swift-syntax", @@ -126,8 +118,6 @@ let package = Package( ] + macroTargets( name: "Probing", dependencies: [ - "DeeplyCopyable", - "EquatableObject", .product( name: "PrincipleConcurrency", package: "Principle" diff --git a/Sources/Probing/Imports.swift b/Sources/Probing/Imports.swift deleted file mode 100644 index 07093c1..0000000 --- a/Sources/Probing/Imports.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// Imports.swift -// Probing -// -// Created by Kamil Strzelecki on 29/03/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -@_documentation(visibility: private) @_exported import DeeplyCopyable -@_documentation(visibility: private) @_exported import EquatableObject From a0ec05c8431f61f63e43bea20c3ffe02a617bf58 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Fri, 9 May 2025 11:56:43 +0200 Subject: [PATCH 02/41] - --- Package.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Package.swift b/Package.swift index 6e5f830..c5a44ba 100644 --- a/Package.swift +++ b/Package.swift @@ -110,9 +110,6 @@ let package = Package( name: "Algorithms", package: "swift-algorithms" ) - ], - swiftSettings: [ - .enableExperimentalFeature("LifetimeDependence") ] ) ] + macroTargets( From 99b8fe5097bb2614fb1cfed92d826cfef24eedfa Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Wed, 7 May 2025 17:03:16 +0200 Subject: [PATCH 03/41] Added DeeplyCopyable docs --- .../Documentation.docc/DeeplyCopyable.md | 51 ++++++++++++++ .../Protocols/DeeplyCopyable.swift | 24 ++++++- .../DeeplyCopyableByAssignment.swift | 66 +++++++++---------- 3 files changed, 107 insertions(+), 34 deletions(-) create mode 100644 Sources/DeeplyCopyable/Documentation.docc/DeeplyCopyable.md diff --git a/Sources/DeeplyCopyable/Documentation.docc/DeeplyCopyable.md b/Sources/DeeplyCopyable/Documentation.docc/DeeplyCopyable.md new file mode 100644 index 0000000..4c0200c --- /dev/null +++ b/Sources/DeeplyCopyable/Documentation.docc/DeeplyCopyable.md @@ -0,0 +1,51 @@ +# ``DeeplyCopyable-module`` + +Create copies of objects without sharing underlying storage, while remaining otherwise value-equal. + +## Overview + +In Swift, we can recognize three groups of `Copyable` types: +- Value types that don't store any reference types (directly or indirectly): + - Basic types, like `Bool`, `Int`, etc. + - Enums without associated values, or with associated values that don't hold any reference types. + - Structs without stored properties, or with stored properties that don't hold any reference types. +- Value types that store reference types (directly or indirectly), optionally implementing copy-on-write mechanisms: + - Many standard collection types, like `String`, `Array`, `Dictionary`, `Set`, etc. + - Enums with associated values that hold reference types. + - Structs with stored properties that hold reference types. +- Reference types: + - Classes + - Actors + +Instances of these types can be copied either explicitly (by assigning them to another variable) or implicitly (by passing them as arguments to functions). +However, only the first group of types supports copying without sharing any underlying storage - in other words, they can be **deeply copied**. + +Conformance to the ``DeeplyCopyable-protocol`` protocol indicates that a type can create a deep copy of itself, even if it stores reference types or is a reference type. +The easiest way to add this functionality is to apply the ``DeeplyCopyable()`` macro to the type’s declaration: + +```swift +@DeeplyCopyable +final class Person { + let name: String + var age: Int + + init(name: String, age: Int) { + self.name = name + self.age = age + } +} + +let person = Person(name: "Kamil", age: 25) +let deepCopy = person.deepCopy() + +person.age += 1 +print(person.age) // 26 +print(deepCopy.age) // 25 +``` + +## Topics + +### Making Deep Copies + +- ``DeeplyCopyable()`` +- ``DeeplyCopyable-protocol`` diff --git a/Sources/DeeplyCopyable/Protocols/DeeplyCopyable.swift b/Sources/DeeplyCopyable/Protocols/DeeplyCopyable.swift index bcae807..2343b98 100644 --- a/Sources/DeeplyCopyable/Protocols/DeeplyCopyable.swift +++ b/Sources/DeeplyCopyable/Protocols/DeeplyCopyable.swift @@ -6,6 +6,12 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// Defines and implements conformance to the ``DeeplyCopyable-protocol`` protocol for final classes, structs and enums. +/// +/// - Important: If any stored property or associated value of the type is not ``DeeplyCopyable-protocol``, the code generated by the macro +/// will fail to compile. To resolve this, either apply the macro directly to the types used as stored properties or associated values, or manually declare +/// their conformance if they come from a third-party module. +/// @attached( member, names: named(init(deeplyCopying:)) @@ -20,13 +26,29 @@ public macro DeeplyCopyable() = #externalMacro( type: "DeeplyCopyableMacro" ) -public protocol DeeplyCopyable { +/// A type whose instances can be copied without sharing underlying storage, while remaining otherwise value-equal. +/// +/// A deep copy of an instance, created via ``init(deeplyCopying:)``, will ultimately not share any direct on indirect references +/// with the original instance. As a result, mutations to one instance will never affect the other. +/// +/// - Note: For types that use copy-on-write mechanism, underlying storage may be shared until the first mutation. +/// This implementation detail does not affect any of the guarantees provided by `DeeplyCopyable` protocol. +/// +public protocol DeeplyCopyable: Copyable { + /// Creates a new instance that is value-equal to `other`, but shares no underlying storage. + /// + /// - Parameter other: The instance to deep copy. + /// init(deeplyCopying other: Self) } extension DeeplyCopyable { + /// Creates a new instance that is value-equal to this instance, but shares no underlying storage. + /// + /// - Returns: A deep copy of this instance. + /// public func deepCopy() -> Self { .init(deeplyCopying: self) } diff --git a/Sources/DeeplyCopyable/Protocols/DeeplyCopyableByAssignment.swift b/Sources/DeeplyCopyable/Protocols/DeeplyCopyableByAssignment.swift index a3dd5b4..870467d 100644 --- a/Sources/DeeplyCopyable/Protocols/DeeplyCopyableByAssignment.swift +++ b/Sources/DeeplyCopyable/Protocols/DeeplyCopyableByAssignment.swift @@ -8,7 +8,7 @@ import Foundation -public protocol DeeplyCopyableByAssignment: DeeplyCopyable {} +internal protocol DeeplyCopyableByAssignment: DeeplyCopyable {} extension DeeplyCopyableByAssignment { @@ -17,57 +17,57 @@ extension DeeplyCopyableByAssignment { } } -extension Int: DeeplyCopyableByAssignment {} -extension Int8: DeeplyCopyableByAssignment {} -extension Int16: DeeplyCopyableByAssignment {} -extension Int32: DeeplyCopyableByAssignment {} -extension Int64: DeeplyCopyableByAssignment {} -extension Int128: DeeplyCopyableByAssignment {} +extension Int: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Int8: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Int16: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Int32: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Int64: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Int128: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension UInt: DeeplyCopyableByAssignment {} -extension UInt8: DeeplyCopyableByAssignment {} -extension UInt16: DeeplyCopyableByAssignment {} -extension UInt32: DeeplyCopyableByAssignment {} -extension UInt64: DeeplyCopyableByAssignment {} -extension UInt128: DeeplyCopyableByAssignment {} +extension UInt: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension UInt8: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension UInt16: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension UInt32: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension UInt64: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension UInt128: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension Double: DeeplyCopyableByAssignment {} -extension Float: DeeplyCopyableByAssignment {} -extension Float16: DeeplyCopyableByAssignment {} -extension Decimal: DeeplyCopyableByAssignment {} +extension Double: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Float: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Float16: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Decimal: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension Bool: DeeplyCopyableByAssignment {} -extension Character: DeeplyCopyableByAssignment {} -extension Duration: DeeplyCopyableByAssignment {} -extension UUID: DeeplyCopyableByAssignment {} +extension Bool: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Character: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Duration: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension UUID: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension URL: DeeplyCopyableByAssignment {} -extension URLComponents: DeeplyCopyableByAssignment {} -extension URLQueryItem: DeeplyCopyableByAssignment {} +extension URL: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension URLComponents: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension URLQueryItem: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension Date: DeeplyCopyableByAssignment {} -extension DateComponents: DeeplyCopyableByAssignment {} -extension TimeZone: DeeplyCopyableByAssignment {} -extension Calendar: DeeplyCopyableByAssignment {} +extension Date: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension DateComponents: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension TimeZone: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Calendar: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension Locale: DeeplyCopyableByAssignment {} -extension PersonNameComponents: DeeplyCopyableByAssignment {} +extension Locale: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension PersonNameComponents: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension Data: DeeplyCopyableByAssignment { +extension Data: DeeplyCopyable, DeeplyCopyableByAssignment { public init(deeplyCopying other: Self) { self = other } } -extension String: DeeplyCopyableByAssignment { +extension String: DeeplyCopyable, DeeplyCopyableByAssignment { public init(deeplyCopying other: Self) { self = other } } -extension Substring: DeeplyCopyableByAssignment { +extension Substring: DeeplyCopyable, DeeplyCopyableByAssignment { public init(deeplyCopying other: Self) { self = other From 13369d443016c86887e5085eb1e9ff41ccd36057 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Wed, 7 May 2025 17:03:27 +0200 Subject: [PATCH 04/41] Added EquatableObject docs --- .../Documentation.docc/EquatableObject.md | 14 ++++++++++++++ Sources/EquatableObject/EquatableObject.swift | 4 ++++ 2 files changed, 18 insertions(+) create mode 100644 Sources/EquatableObject/Documentation.docc/EquatableObject.md diff --git a/Sources/EquatableObject/Documentation.docc/EquatableObject.md b/Sources/EquatableObject/Documentation.docc/EquatableObject.md new file mode 100644 index 0000000..712c874 --- /dev/null +++ b/Sources/EquatableObject/Documentation.docc/EquatableObject.md @@ -0,0 +1,14 @@ +# ``EquatableObject`` + +Automatically generate property-wise `Equatable` conformance for `final` classes using a macro. + +## Overview + +Swift automatically synthesizes `Equatable` conformance for structs and enums, but not for classes. +The ``EquatableObject()`` macro provides this functionality for `final` classes, allowing you to compare instances based on their stored properties. + +## Topics + +### Adding Equatable Conformance + +- ``EquatableObject()`` diff --git a/Sources/EquatableObject/EquatableObject.swift b/Sources/EquatableObject/EquatableObject.swift index 19eeab2..faa4491 100644 --- a/Sources/EquatableObject/EquatableObject.swift +++ b/Sources/EquatableObject/EquatableObject.swift @@ -6,6 +6,10 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// Defines and implements conformance to the `Equatable` protocol for `final` classes. +/// +/// - Important: If any stored property of the class is not `Equatable`, the code generated by the macro will fail to compile. +/// @attached( member, names: named(==) From 247695713f14b928b5d040f8a67c0441812d1894 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Fri, 9 May 2025 17:13:09 +0200 Subject: [PATCH 05/41] - --- .../ProbeTesting/Dispatcher/AsyncSignal.swift | 30 ---- .../Dispatcher/ProbingDispatcher.swift | 133 +++++------------- .../WithProbing/WithProbing.swift | 6 +- .../TestingSupport/ProbingCoordinator.swift | 19 +-- .../Suites/APIMisuseTests.swift | 23 --- ...onTests.swift => AsyncSequenceTests.swift} | 107 +++++--------- .../ProbeTestingTests/Suites/ProbeTests.swift | 15 ++ .../Suites/WithProbingTests.swift | 14 +- 8 files changed, 105 insertions(+), 242 deletions(-) delete mode 100644 Sources/ProbeTesting/Dispatcher/AsyncSignal.swift rename Tests/ProbeTestingTests/Suites/{InjectionTests.swift => AsyncSequenceTests.swift} (64%) diff --git a/Sources/ProbeTesting/Dispatcher/AsyncSignal.swift b/Sources/ProbeTesting/Dispatcher/AsyncSignal.swift deleted file mode 100644 index 7dce61d..0000000 --- a/Sources/ProbeTesting/Dispatcher/AsyncSignal.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// AsyncSignal.swift -// Probing -// -// Created by Kamil Strzelecki on 05/05/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -internal struct AsyncSignal { - - private typealias Underlying = AsyncStream - - private let stream: Underlying - private let continuation: Underlying.Continuation - - init() { - let (stream, continuation) = Underlying.makeStream() - self.stream = stream - self.continuation = continuation - } - - func wait() async { - // swiftlint:disable:next no_empty_block - for await _ in stream {} - } - - func finish() { - continuation.finish() - } -} diff --git a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift index 5fd77a9..25cdecb 100644 --- a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift +++ b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift @@ -23,62 +23,20 @@ public struct ProbingDispatcher: ~Escapable, Sendable { extension ProbingDispatcher { - public typealias Injection = @Sendable () async throws -> sending R - - private func withIssueRecording( + private func withIssueRecording( at sourceLocation: SourceLocation, isolation: isolated (any Actor)?, - perform dispatch: @escaping (() -> Void) async throws -> Void, - after injection: @escaping Injection - ) async throws -> sending R { - // https://github.com/swiftlang/swift/issues/77301 - // When `isolation` is nil `injection` must be `@Sendable` - - let signal = AsyncSignal() - var result: R? - - let injectionTask = Task { - _ = isolation - await signal.wait() - await Task.yield() - - guard !Task.isCancelled else { - return - } - - result = try await injection() - } - - let dispatchTask = Task { - do { - _ = isolation - try await dispatch { - signal.finish() - } - } catch { - injectionTask.cancel() - signal.finish() - throw RecordedError( - underlying: error, - sourceLocation: sourceLocation - ) - } - } - - defer { - injectionTask.cancel() - dispatchTask.cancel() + perform dispatch: () async throws -> Void + ) async throws { + do { + try await dispatch() + } catch { + throw RecordedError( + underlying: error, + sourceLocation: sourceLocation + ) } - - try await injectionTask.value - try await dispatchTask.value try Task.checkCancellation() - - guard let result else { - preconditionFailure("Injection task did not produce any result.") - } - - return result } private func withIssueRecording( @@ -100,50 +58,42 @@ extension ProbingDispatcher { // swiftlint:disable no_empty_block - public func runUpToProbe( + public func runUpToProbe( _ id: ProbeIdentifier, sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation, - @_inheritActorContext @_implicitSelfCapture after injection: @escaping Injection = {} - ) async throws -> sending R { + isolation: isolated (any Actor)? = #isolation + ) async throws { try await withIssueRecording( at: sourceLocation, isolation: isolation, - perform: { [coordinator] startOperation in + perform: { try await coordinator.runUntilProbeInstalled( withID: id, - isolation: isolation, - after: startOperation + isolation: isolation ) - }, - after: injection + } ) } - public func runUpToProbe( + public func runUpToProbe( inEffect effectID: EffectIdentifier, sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation, - @_inheritActorContext @_implicitSelfCapture after injection: @escaping Injection = {} - ) async throws -> sending R { + isolation: isolated (any Actor)? = #isolation + ) async throws { try await runUpToProbe( .init(effect: effectID, name: .default), sourceLocation: sourceLocation, - isolation: isolation, - after: injection + isolation: isolation ) } - public func runUpToProbe( + public func runUpToProbe( sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation, - @_inheritActorContext @_implicitSelfCapture after injection: @escaping Injection = {} - ) async throws -> sending R { + isolation: isolated (any Actor)? = #isolation + ) async throws { try await runUpToProbe( inEffect: .root, - sourceLocation: sourceLocation, - isolation: isolation, - after: injection + sourceLocation: sourceLocation ) } @@ -154,53 +104,46 @@ extension ProbingDispatcher { // swiftlint:disable no_empty_block - public func runUntilEffectCompleted( + public func runUntilEffectCompleted( _ id: EffectIdentifier, includingDescendants includeDescendants: Bool = false, sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation, - @_inheritActorContext @_implicitSelfCapture after injection: @escaping Injection = {} - ) async throws -> sending R { + isolation: isolated (any Actor)? = #isolation + ) async throws { try await withIssueRecording( at: sourceLocation, isolation: isolation, - perform: { [coordinator] startOperation in + perform: { try await coordinator.runUntilEffectCompleted( withID: id, includingDescendants: includeDescendants, - isolation: isolation, - after: startOperation + isolation: isolation ) - }, - after: injection + } ) } - public func runUntilEverythingCompleted( + public func runUntilEverythingCompleted( sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation, - @_inheritActorContext @_implicitSelfCapture after injection: @escaping Injection = {} - ) async throws -> sending R { + isolation: isolated (any Actor)? = #isolation + ) async throws { try await runUntilEffectCompleted( .root, includingDescendants: true, sourceLocation: sourceLocation, - isolation: isolation, - after: injection + isolation: isolation ) } - public func runUntilExitOfBody( + public func runUntilExitOfBody( sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation, - @_inheritActorContext @_implicitSelfCapture after injection: @escaping Injection = {} - ) async throws -> sending R { + isolation: isolated (any Actor)? = #isolation + ) async throws { try await runUntilEffectCompleted( .root, includingDescendants: false, sourceLocation: sourceLocation, - isolation: isolation, - after: injection + isolation: isolation ) } diff --git a/Sources/ProbeTesting/WithProbing/WithProbing.swift b/Sources/ProbeTesting/WithProbing/WithProbing.swift index eb37043..85f3863 100644 --- a/Sources/ProbeTesting/WithProbing/WithProbing.swift +++ b/Sources/ProbeTesting/WithProbing/WithProbing.swift @@ -53,14 +53,16 @@ public func withProbing( do { try await testTask.value - try await bodyTask.value } catch { if testTask.isCancelled { try await bodyTask.value + } else { + try? await bodyTask.value } throw error } + try await bodyTask.value guard let result else { preconditionFailure("Body task did not produce any result.") } @@ -99,7 +101,7 @@ private func makeTestTask( } private func runRootEffect( - using body: @escaping () async throws -> sending R, + using body: () async throws -> sending R, testTask: Task, coordinator: ProbingCoordinator, isolation: isolated (any Actor)? diff --git a/Sources/Probing/TestingSupport/ProbingCoordinator.swift b/Sources/Probing/TestingSupport/ProbingCoordinator.swift index a8a7f8c..5d32cdc 100644 --- a/Sources/Probing/TestingSupport/ProbingCoordinator.swift +++ b/Sources/Probing/TestingSupport/ProbingCoordinator.swift @@ -72,8 +72,7 @@ extension ProbingCoordinator { private func pauseTest( isolation: isolated (any Actor)?, phasePrecondition precondition: TestPhase.Precondition, - awaiting dispatches: (ProbingState) throws -> Void, - after injection: () -> Void + awaiting dispatches: (ProbingState) throws -> Void ) async throws { try await withCheckedThrowingContinuation(isolation: isolation) { continuation in state.resumeTestIfPossible { state in @@ -81,7 +80,6 @@ extension ProbingCoordinator { state.pauseTest(using: continuation) try dispatches(state) } - injection() } } @@ -92,8 +90,7 @@ extension ProbingCoordinator { try await pauseTest( isolation: isolation, phasePrecondition: .init(\.isScheduled), - awaiting: { _ in }, // swiftlint:disable:this no_empty_block - after: {} // swiftlint:disable:this no_empty_block + awaiting: { _ in } // swiftlint:disable:this no_empty_block ) } catch { preconditionFailure(""" @@ -119,24 +116,21 @@ extension ProbingCoordinator { package func runUntilProbeInstalled( withID id: ProbeIdentifier, - isolation: isolated (any Actor)?, - after injection: () -> Void + isolation: isolated (any Actor)? ) async throws { try await pauseTest( isolation: isolation, phasePrecondition: .init(\.isRunning), awaiting: { state in try state.rootEffect.runUntilProbeInstalled(withID: id) - }, - after: injection + } ) } package func runUntilEffectCompleted( withID id: EffectIdentifier, includingDescendants includeDescendants: Bool, - isolation: isolated (any Actor)?, - after injection: () -> Void + isolation: isolated (any Actor)? ) async throws { try await pauseTest( isolation: isolation, @@ -146,8 +140,7 @@ extension ProbingCoordinator { withID: id, includingDescendants: includeDescendants ) - }, - after: injection + } ) } diff --git a/Tests/ProbeTestingTests/Suites/APIMisuseTests.swift b/Tests/ProbeTestingTests/Suites/APIMisuseTests.swift index fbfe10f..82d531d 100644 --- a/Tests/ProbeTestingTests/Suites/APIMisuseTests.swift +++ b/Tests/ProbeTestingTests/Suites/APIMisuseTests.swift @@ -91,29 +91,6 @@ extension APIMisuseTests { } } -extension APIMisuseTests { - - @Test - func testProbingWhileTesting() async throws { - try await withProbing { - // Void - } dispatchedBy: { _ in - await #probe() - } - } - - @Test - func testProbingWhileInjecting() async throws { - try await withProbing { - // Void - } dispatchedBy: { dispatcher in - try await dispatcher.runUntilEverythingCompleted { - await #probe() - } - } - } -} - extension APIMisuseTests { private struct NonSendableInteractor { diff --git a/Tests/ProbeTestingTests/Suites/InjectionTests.swift b/Tests/ProbeTestingTests/Suites/AsyncSequenceTests.swift similarity index 64% rename from Tests/ProbeTestingTests/Suites/InjectionTests.swift rename to Tests/ProbeTestingTests/Suites/AsyncSequenceTests.swift index fd294f7..51e5890 100644 --- a/Tests/ProbeTestingTests/Suites/InjectionTests.swift +++ b/Tests/ProbeTestingTests/Suites/AsyncSequenceTests.swift @@ -1,5 +1,5 @@ // -// InjectionTests.swift +// AsyncSequenceTests.swift // Probing // // Created by Kamil Strzelecki on 26/04/2025. @@ -10,7 +10,7 @@ @testable import Probing import Testing -internal struct InjectionTests { +internal struct AsyncSequenceTests { private let model: IsolatedModel private let interactor: IsolatedInteractor @@ -25,21 +25,18 @@ internal struct InjectionTests { try await withProbing { await interactor.callWithAsyncStream() } dispatchedBy: { dispatcher in - let first = try await dispatcher.runUpToProbe { - await interactor.yield() - } + let first = await interactor.yield() + try await dispatcher.runUpToProbe() #expect(first == 0) await #expect(model.value == 1) - let second = try await dispatcher.runUpToProbe { - await interactor.yield() - } + let second = await interactor.yield() + try await dispatcher.runUpToProbe() #expect(second == 1) await #expect(model.value == 2) - let final = try await dispatcher.runUpToProbe("1") { - await interactor.finish() - } + let final = await interactor.finish() + try await dispatcher.runUpToProbe("1") #expect(final == 2) await #expect(model.value == 4) @@ -56,21 +53,18 @@ internal struct InjectionTests { try await dispatcher.runUpToProbe("2") await #expect(model.value == 2) - let first = try await dispatcher.runUpToProbe(inEffect: "stream") { - await interactor.yield() - } + let first = await interactor.yield() + try await dispatcher.runUpToProbe(inEffect: "stream") #expect(first == 2) await #expect(model.value == 3) - let second = try await dispatcher.runUpToProbe(inEffect: "stream") { - await interactor.yield() - } + let second = await interactor.yield() + try await dispatcher.runUpToProbe(inEffect: "stream") #expect(second == 3) await #expect(model.value == 4) - let final = try await dispatcher.runUpToProbe("stream.1") { - await interactor.finish() - } + let final = await interactor.finish() + try await dispatcher.runUpToProbe("stream.1") #expect(final == 4) await #expect(model.value == 6) @@ -83,16 +77,15 @@ internal struct InjectionTests { } } -extension InjectionTests { +extension AsyncSequenceTests { @Test func testRunningUpToProbe() async throws { try await withProbing { await interactor.callWithAsyncStream() } dispatchedBy: { dispatcher in - let result = try await dispatcher.runUpToProbe { - await interactor.finish() - } + let result = await interactor.finish() + try await dispatcher.runUpToProbe() #expect(result == 0) await #expect(model.value == 1) } @@ -103,9 +96,8 @@ extension InjectionTests { try await withProbing { await interactor.callWithAsyncStream() } dispatchedBy: { dispatcher in - let result = try await dispatcher.runUpToProbe("1") { - await interactor.finish() - } + let result = await interactor.finish() + try await dispatcher.runUpToProbe("1") #expect(result == 0) await #expect(model.value == 2) } @@ -116,25 +108,23 @@ extension InjectionTests { try await withProbing { await interactor.callWithAsyncStreamInEffect() } dispatchedBy: { dispatcher in - let result = try await dispatcher.runUpToProbe(inEffect: "stream") { - await interactor.finish() - } - #expect(result <= 1) + let result = await interactor.finish() + try await dispatcher.runUpToProbe(inEffect: "stream") + #expect(result == 0) await #expect(model.value == 2) } } } -extension InjectionTests { +extension AsyncSequenceTests { @Test func testRunningUntilExitOfBody() async throws { try await withProbing { await interactor.callWithAsyncStream() } dispatchedBy: { dispatcher in - let result = try await dispatcher.runUntilExitOfBody { - await interactor.finish() - } + let result = await interactor.finish() + try await dispatcher.runUntilExitOfBody() #expect(result == 0) await #expect(model.value == 3) } @@ -145,10 +135,9 @@ extension InjectionTests { try await withProbing { await interactor.callWithAsyncStreamInEffect() } dispatchedBy: { dispatcher in - let result = try await dispatcher.runUntilEverythingCompleted { - await interactor.finish() - } - #expect(result <= 3) + let result = await interactor.finish() + try await dispatcher.runUntilEverythingCompleted() + #expect(result == 0) await #expect(model.value == 6) } } @@ -158,47 +147,15 @@ extension InjectionTests { try await withProbing { await interactor.callWithAsyncStreamInEffect() } dispatchedBy: { dispatcher in - let result = try await dispatcher.runUntilEffectCompleted("stream") { - await interactor.finish() - } - #expect(result <= 1) + let result = await interactor.finish() + try await dispatcher.runUntilEffectCompleted("stream") + #expect(result == 0) await #expect(model.value == 4) } } } -extension InjectionTests { - - @Test - func testThrowing() async { - await #expect(throws: ErrorMock.self) { - try await withProbing { - await interactor.callWithAsyncStream() - } dispatchedBy: { dispatcher in - try await dispatcher.runUpToProbe("unknown") { - throw ErrorMock() - } - Issue.record() - } - } - } - - @CustomActor - @Test - func testIsolation() async throws { - try await withProbing { - await interactor.callWithAsyncStream() - } dispatchedBy: { dispatcher in - try await dispatcher.runUpToProbe { - #expect(#isolation === CustomActor.shared) - CustomActor.shared.assertIsolated() - _ = await interactor.finish() - } - } - } -} - -extension InjectionTests { +extension AsyncSequenceTests { private struct ErrorMock: Error {} diff --git a/Tests/ProbeTestingTests/Suites/ProbeTests.swift b/Tests/ProbeTestingTests/Suites/ProbeTests.swift index 3c2e7a1..9fdec25 100644 --- a/Tests/ProbeTestingTests/Suites/ProbeTests.swift +++ b/Tests/ProbeTestingTests/Suites/ProbeTests.swift @@ -219,6 +219,21 @@ extension ProbeTests { } } } + + @Test + func testThrowingLateInTest() async { + await #expect(throws: ErrorMock.self) { + try await confirmation { confirmation in + try await withProbing { + await interactor.callWithDefaultProbes() + confirmation() + } dispatchedBy: { dispatcher in + try await dispatcher.runUpToProbe() + throw ErrorMock() + } + } + } + } } extension ProbeTests { diff --git a/Tests/ProbeTestingTests/Suites/WithProbingTests.swift b/Tests/ProbeTestingTests/Suites/WithProbingTests.swift index a31365c..312364d 100644 --- a/Tests/ProbeTestingTests/Suites/WithProbingTests.swift +++ b/Tests/ProbeTestingTests/Suites/WithProbingTests.swift @@ -186,7 +186,7 @@ extension WithProbingTests { } @Test - func testThrowingWhileTesting() async { + func testThrowingEarlyInTest() async { await #expect(throws: ErrorMock.self) { try await confirmation { confirmation in try await withProbing { @@ -197,9 +197,15 @@ extension WithProbingTests { } } } -} -extension WithProbingTests { + @Test + func testProbingInTest() async throws { + try await withProbing { + // Void + } dispatchedBy: { _ in + await #probe() + } + } @CustomActor @Test @@ -214,7 +220,7 @@ extension WithProbingTests { @CustomActor @Test - func testIsolationWhileTesting() async throws { + func testIsolationInTest() async throws { try await withProbing { // Void } dispatchedBy: { _ in From a9b3ce586402eb356d2c15a17700a2d9b9ac1494 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Fri, 9 May 2025 17:13:09 +0200 Subject: [PATCH 06/41] [SwiftFormat] Applied formatting --- Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift index 25cdecb..5d6b88b 100644 --- a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift +++ b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift @@ -25,7 +25,7 @@ extension ProbingDispatcher { private func withIssueRecording( at sourceLocation: SourceLocation, - isolation: isolated (any Actor)?, + isolation _: isolated (any Actor)?, perform dispatch: () async throws -> Void ) async throws { do { @@ -89,7 +89,7 @@ extension ProbingDispatcher { public func runUpToProbe( sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation + isolation _: isolated (any Actor)? = #isolation ) async throws { try await runUpToProbe( inEffect: .root, From 8e620336261e3e1449538b4af9a3bd76c557eeb6 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Fri, 9 May 2025 17:14:13 +0200 Subject: [PATCH 07/41] - --- Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift index 5d6b88b..726e67f 100644 --- a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift +++ b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift @@ -25,10 +25,11 @@ extension ProbingDispatcher { private func withIssueRecording( at sourceLocation: SourceLocation, - isolation _: isolated (any Actor)?, + isolation: isolated (any Actor)?, perform dispatch: () async throws -> Void ) async throws { do { + _ = isolation try await dispatch() } catch { throw RecordedError( @@ -89,11 +90,12 @@ extension ProbingDispatcher { public func runUpToProbe( sourceLocation: SourceLocation = #_sourceLocation, - isolation _: isolated (any Actor)? = #isolation + isolation: isolated (any Actor)? = #isolation ) async throws { try await runUpToProbe( inEffect: .root, - sourceLocation: sourceLocation + sourceLocation: sourceLocation, + isolation: isolation ) } From 8efc3827063fc18529ba5b27c18b099896f05a2e Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 10 May 2025 17:58:55 +0200 Subject: [PATCH 08/41] - --- .../TestingSupport/EffectDispatch.swift | 8 --- .../Probing/TestingSupport/EffectState.swift | 16 ++--- .../TestingSupport/ProbingCoordinator.swift | 19 +++--- .../Probing/TestingSupport/ProbingState.swift | 28 ++++---- .../TestingSupport/_ProbingOptions.swift | 2 +- .../Suites/EffectTests.swift | 66 +++++++++++++++++++ Tests/ProbingTests/EffectNameTests.swift | 38 +++++++++++ 7 files changed, 133 insertions(+), 44 deletions(-) create mode 100644 Tests/ProbingTests/EffectNameTests.swift diff --git a/Sources/Probing/TestingSupport/EffectDispatch.swift b/Sources/Probing/TestingSupport/EffectDispatch.swift index 81b4019..cac7dd3 100644 --- a/Sources/Probing/TestingSupport/EffectDispatch.swift +++ b/Sources/Probing/TestingSupport/EffectDispatch.swift @@ -23,14 +23,6 @@ internal indirect enum EffectDispatch: Sendable { preconditionFailure("Child dispatches cannot be nested.") } - precondition( - childID.path.count <= id.path.count, - """ - Dispatch for effect with identifier \"\(id)\" was relayed \ - to unrelated descendant with identifier \"\(childID)\". - """ - ) - if childID == id { self = .suspendWhenPossible return dispatch diff --git a/Sources/Probing/TestingSupport/EffectState.swift b/Sources/Probing/TestingSupport/EffectState.swift index f25050a..f7301c4 100644 --- a/Sources/Probing/TestingSupport/EffectState.swift +++ b/Sources/Probing/TestingSupport/EffectState.swift @@ -166,9 +166,9 @@ extension EffectState { func runUntilEffectCompleted( withID effectID: EffectIdentifier, includingDescendants includeDescendants: Bool - ) { + ) throws { preconditionRoot() - runUntilEffectCompleted( + try runUntilEffectCompleted( withID: effectID, path: effectID.path[...], includingDescendants: includeDescendants @@ -179,10 +179,10 @@ extension EffectState { withID effectID: EffectIdentifier, path: ArraySlice, includingDescendants includeDescendants: Bool - ) { + ) throws { if let descendantName = path.first { if let descendant = children[descendantName] { - descendant.runUntilEffectCompleted( + try descendant.runUntilEffectCompleted( withID: effectID, path: path.dropFirst(), includingDescendants: includeDescendants @@ -204,7 +204,7 @@ extension EffectState { if includeDescendants { for child in children.values { - child.runUntilEffectCompleted( + try child.runUntilEffectCompleted( withID: effectID, path: path, includingDescendants: true @@ -213,11 +213,7 @@ extension EffectState { } } - do { - try resumeIfNeeded() - } catch { - preconditionFailure("Effect would never complete: \(error)") - } + try resumeIfNeeded() } } diff --git a/Sources/Probing/TestingSupport/ProbingCoordinator.swift b/Sources/Probing/TestingSupport/ProbingCoordinator.swift index 5d32cdc..16f0b7b 100644 --- a/Sources/Probing/TestingSupport/ProbingCoordinator.swift +++ b/Sources/Probing/TestingSupport/ProbingCoordinator.swift @@ -59,11 +59,14 @@ extension ProbingCoordinator { isolation: isolated (any Actor)?, operation: () async throws -> R ) async rethrows -> R { - try await ProbingCoordinator.$_current.withValue( - self, - operation: operation, - isolation: isolation - ) + nonisolated(unsafe) let operation = operation + return try await EffectIdentifier.withRoot(isolation: isolation) { + try await ProbingCoordinator.$_current.withValue( + self, + operation: operation, + isolation: isolation + ) + } } } @@ -104,9 +107,7 @@ extension ProbingCoordinator { guard !state.testPhase.isFailed else { return } - state.preconditionTestPhase { testPhase in - testPhase.isRunning || testPhase.isPaused - } + state.preconditionTestPhase(\.isRunning) try state.passTest() } } @@ -136,7 +137,7 @@ extension ProbingCoordinator { isolation: isolation, phasePrecondition: .init(\.isRunning), awaiting: { state in - state.rootEffect.runUntilEffectCompleted( + try state.rootEffect.runUntilEffectCompleted( withID: id, includingDescendants: includeDescendants ) diff --git a/Sources/Probing/TestingSupport/ProbingState.swift b/Sources/Probing/TestingSupport/ProbingState.swift index e258851..7c715be 100644 --- a/Sources/Probing/TestingSupport/ProbingState.swift +++ b/Sources/Probing/TestingSupport/ProbingState.swift @@ -55,31 +55,27 @@ extension ProbingState { extension ProbingState { mutating func passTest() throws { - switch testPhase { - case let .paused(continuation): - let error = ProbingInterruptedError() - continuation.resume(throwing: error) - failTest(with: error) - throw error - - default: - if let firstError = errors.first { - failTest(with: firstError) - throw firstError - } + if let firstError = errors.first { + failTest(with: firstError) + throw firstError + } + do { + try unblockRootEffect() testPhase = .passed - completeTest() + } catch { + testPhase = .failed(error) + throw error } } private mutating func failTest(with error: any Error) { testPhase = .failed(error) - completeTest() + try? unblockRootEffect() } - private func completeTest() { - rootEffect.runUntilEffectCompleted( + private func unblockRootEffect() throws { + try rootEffect.runUntilEffectCompleted( withID: .root, includingDescendants: true ) diff --git a/Sources/Probing/TestingSupport/_ProbingOptions.swift b/Sources/Probing/TestingSupport/_ProbingOptions.swift index 674d906..57ab7d3 100644 --- a/Sources/Probing/TestingSupport/_ProbingOptions.swift +++ b/Sources/Probing/TestingSupport/_ProbingOptions.swift @@ -24,6 +24,6 @@ extension _ProbingOptions { extension _ProbingOptions { static var current: Self { - ProbingCoordinator.current?.options ?? .attemptProbingInTasks + ProbingCoordinator.current?.options ?? .ignoreProbingInTasks } } diff --git a/Tests/ProbeTestingTests/Suites/EffectTests.swift b/Tests/ProbeTestingTests/Suites/EffectTests.swift index 3f3b09f..be38129 100644 --- a/Tests/ProbeTestingTests/Suites/EffectTests.swift +++ b/Tests/ProbeTestingTests/Suites/EffectTests.swift @@ -293,6 +293,20 @@ extension EffectTests { } await #expect(model.values[.root, default: 0] == 3) } + + @Test + func testNameEnumeration() async throws { + try await withProbing { + await interactor.callWithIndependentEnumeratedEffects() + } dispatchedBy: { dispatcher in + try await dispatcher.runUntilExitOfBody() + try await dispatcher.runUntilEffectCompleted("effect0") + await #expect(model.values == ["effect0": 3]) + + try await dispatcher.runUntilEffectCompleted("effect1") + await #expect(model.values == ["effect0": 3, "effect1": 3]) + } + } } } @@ -581,6 +595,44 @@ extension EffectTests { } await #expect(model.values[.root, default: 0] == 3) } + + @Test + func testNameEnumeration() async throws { + try await withProbing { + await interactor.callWithNestedEnumeratedEffects() + } dispatchedBy: { dispatcher in + try await dispatcher.runUntilExitOfBody() + try await dispatcher.runUntilEffectCompleted("name0.effect0") + await #expect(model.values == ["name0.effect0": 3]) + + try await dispatcher.runUntilEffectCompleted("name1.effect1") + await #expect( + model.values == [ + "name0.effect0": 3, + "name1.effect1": 3 + ] + ) + + try await dispatcher.runUntilEffectCompleted("name0", includingDescendants: true) + await #expect( + model.values == [ + "name0.effect0": 3, + "name0.effect1": 3, + "name1.effect1": 3 + ] + ) + + try await dispatcher.runUntilEffectCompleted("name1", includingDescendants: true) + await #expect( + model.values == [ + "name0.effect0": 3, + "name0.effect1": 3, + "name1.effect0": 3, + "name1.effect1": 3 + ] + ) + } + } } } @@ -801,6 +853,11 @@ extension EffectTests { model.tick() } + func callWithIndependentEnumeratedEffects() { + makeEffect(.enumerated("effect")) + makeEffect(.enumerated("effect")) + } + func callWithNestedEffects() async { model.tick() #Effect("1") { @@ -817,6 +874,15 @@ extension EffectTests { model.tick() } + func callWithNestedEnumeratedEffects() { + #Effect(.enumerated("name")) { + self.callWithIndependentEnumeratedEffects() + } + #ConcurrentEffect(.enumerated("name")) { + await self.callWithIndependentEnumeratedEffects() + } + } + func callWithAmbiguousEffects() async { #Effect("ambiguous") { #Effect("nested") { -1 } diff --git a/Tests/ProbingTests/EffectNameTests.swift b/Tests/ProbingTests/EffectNameTests.swift new file mode 100644 index 0000000..ec09939 --- /dev/null +++ b/Tests/ProbingTests/EffectNameTests.swift @@ -0,0 +1,38 @@ +// +// EffectNameTests.swift +// Probing +// +// Created by Kamil Strzelecki on 10/05/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import Probing +import Testing + +internal struct EffectNameTests { + + @Test + func testEquality() { + let name: EffectName = "effect" + let enumeratedName = EffectName.enumerated(name) + + #expect(name == enumeratedName) + #expect(name.hashValue == enumeratedName.hashValue) + } + + @Test + func testIndexing() { + let name: EffectName = "effect" + let enumeratedName = EffectName.enumerated(name) + + let index: UInt = 123 + let indexedName = name.withIndex(index) + let enumeratedIndexedName = enumeratedName.withIndex(index) + let literalName: EffectName = "effect123" + + #expect(indexedName == literalName) + #expect(indexedName == enumeratedIndexedName) + #expect(indexedName.hashValue == literalName.hashValue) + #expect(indexedName.hashValue == enumeratedIndexedName.hashValue) + } +} From 41bc01fd216b60f6563ef645ce297965b84d3e29 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 10 May 2025 18:02:15 +0200 Subject: [PATCH 09/41] - --- .../Probing/Effects/EffectIdentifier.swift | 76 ++++++++++++++++--- Sources/Probing/Effects/TestableEffect.swift | 9 ++- .../ProbingIdentifierProtocol.swift | 12 +++ 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/Sources/Probing/Effects/EffectIdentifier.swift b/Sources/Probing/Effects/EffectIdentifier.swift index e1b6f5f..22d49d3 100644 --- a/Sources/Probing/Effects/EffectIdentifier.swift +++ b/Sources/Probing/Effects/EffectIdentifier.swift @@ -6,6 +6,8 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +import Synchronization + public struct EffectIdentifier { public static let root = Self(path: []) @@ -22,26 +24,45 @@ public struct EffectIdentifier { extension EffectIdentifier { @TaskLocal - private static var _current = EffectIdentifier.root + private static var _currentNodes = [Node]() public static var current: EffectIdentifier { - _current + _currentNodes.last?.id ?? .root } - static func appending( - _ childName: EffectName, - operation: (EffectIdentifier) throws -> R - ) rethrows -> R { - let childID = current.appending(childName) - return try $_current.withValue(childID) { - try operation(childID) + static func current(appending childName: EffectName) -> EffectIdentifier { + guard let parentNode = _currentNodes.last else { + preconditionFailure("Root identifier has not been set.") } - } - func appending(_ childName: EffectName) -> EffectIdentifier { - let childPath = path + CollectionOfOne(childName) + let childName = parentNode.enumerateIfNeeded(childName) + let childPath = current.path + CollectionOfOne(childName) return EffectIdentifier(path: childPath) } + + static func withChild( + _ childID: EffectIdentifier, + operation: () throws -> R + ) rethrows -> R { + let childNode = Node(id: childID) + let childrenNodes = _currentNodes + CollectionOfOne(childNode) + return try $_currentNodes.withValue( + childrenNodes, + operation: operation + ) + } + + static func withRoot( + isolation: isolated (any Actor)?, + operation: () async throws -> R + ) async rethrows -> R { + let rootNode = [Node(id: .root)] + return try await $_currentNodes.withValue( + rootNode, + operation: operation, + isolation: isolation + ) + } } extension EffectIdentifier: ProbingIdentifierProtocol { @@ -66,3 +87,34 @@ extension EffectIdentifier: ExpressibleByArrayLiteral { self.init(path: elements) } } + +extension EffectIdentifier { + + private final class Node: Sendable { + + let id: EffectIdentifier + private let childrenIndices = Mutex([EffectName: UInt]()) + + init(id: EffectIdentifier) { + self.id = id + } + + func enumerateIfNeeded(_ childName: EffectName) -> EffectName { + guard childName.isEnumerated else { + return childName + } + + let index = childrenIndices.withLock { childrenIndices in + let index = if let existing = childrenIndices[childName] { + existing + 1 + } else { + UInt.zero + } + childrenIndices[childName] = index + return index + } + + return childName.withIndex(index) + } + } +} diff --git a/Sources/Probing/Effects/TestableEffect.swift b/Sources/Probing/Effects/TestableEffect.swift index 96100cc..2c09a90 100644 --- a/Sources/Probing/Effects/TestableEffect.swift +++ b/Sources/Probing/Effects/TestableEffect.swift @@ -8,6 +8,7 @@ import PrincipleConcurrency +@_documentation(visibility: private) public struct TestableEffect: Effect, Hashable { public let task: Task @@ -35,7 +36,7 @@ public struct TestableEffect: Effect, Hashable { } let name = name() - let id = EffectIdentifier.current.appending(name) + let id = EffectIdentifier.current(appending: name) let isolation = extractIsolation(operation) var transfer = SingleUseTransfer(operation) let location = ProbingLocation( @@ -53,7 +54,7 @@ public struct TestableEffect: Effect, Hashable { ) } - let task = EffectIdentifier.appending(name) { id in + let task = EffectIdentifier.withChild(id) { var transfer = transfer.take() return Task(priority: priority) { @@ -101,7 +102,7 @@ public struct TestableEffect: Effect, Hashable { } let name = name() - let id = EffectIdentifier.current.appending(name) + let id = EffectIdentifier.current(appending: name) var transfer = SingleUseTransfer(operation) let location = ProbingLocation( fileID: fileID, @@ -119,7 +120,7 @@ public struct TestableEffect: Effect, Hashable { ) } - let task = EffectIdentifier.appending(name) { [taskExecutor] id in + let task = EffectIdentifier.withChild(id) { [taskExecutor] in var transfer = transfer.take() return Task(executorPreference: taskExecutor, priority: priority) { diff --git a/Sources/Probing/Identifiers/ProbingIdentifierProtocol.swift b/Sources/Probing/Identifiers/ProbingIdentifierProtocol.swift index 66def53..2b1fca5 100644 --- a/Sources/Probing/Identifiers/ProbingIdentifierProtocol.swift +++ b/Sources/Probing/Identifiers/ProbingIdentifierProtocol.swift @@ -6,6 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +@_documentation(visibility: private) public protocol ProbingIdentifierProtocol: RawRepresentable, Hashable, @@ -31,3 +32,14 @@ extension ProbingIdentifierProtocol { self.init(rawValue: .init(stringInterpolation: stringInterpolation)) } } + +extension ProbingIdentifierProtocol { + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue == rhs.rawValue + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(rawValue) + } +} From ac996697be0d9e9d0f02100e0640e2895b9504e6 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 10 May 2025 18:30:51 +0200 Subject: [PATCH 10/41] Added EffectName and ProbeName docs --- Sources/Probing/Effects/EffectName.swift | 43 ++++++++++++++++++++++ Sources/Probing/Errors/ProbingErrors.swift | 1 + Sources/Probing/Probes/Probe.swift | 19 ++++++++++ Sources/Probing/Probes/ProbeName.swift | 21 +++++++++-- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/Sources/Probing/Effects/EffectName.swift b/Sources/Probing/Effects/EffectName.swift index 1f21e19..5a0095a 100644 --- a/Sources/Probing/Effects/EffectName.swift +++ b/Sources/Probing/Effects/EffectName.swift @@ -6,12 +6,55 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// The name of an effect, forming the last component of an ``EffectIdentifier``. +/// +/// An `EffectName` must be unique within the scope of its parent effect while the effect is still running. +/// Once the effect has completed, the same name may be reused by another effect. +/// +/// It’s recommended to assign distinct names to effects with different purposes. +/// If you need to invoke the same function repeatedly and cannot guarantee that the effect created by its previous invocation has completed, +/// use ``enumerated(_:)`` to automatically append an index to the name, making it unique. +/// public struct EffectName: ProbingIdentifierProtocol { public let rawValue: String + private(set) var isEnumerated = false + /// Creates a new effect name. + /// + /// - Parameter rawValue: Non-empty string that must not contain any `.` characters. + /// public init(rawValue: String) { ProbingNames.preconditionValid(rawValue) self.rawValue = rawValue } } + +extension EffectName { + + /// Prepares the given effect name for automatic indexing during tests, starting at zero. + /// + /// - Parameter name: The effect name to append an index to. + /// + /// - Returns: A new effect name prepared for automatic indexing. + /// + /// The resulting ``rawValue`` during tests will be formatted as `name0`, `name1`, etc. + /// + public static func enumerated(_ name: EffectName) -> EffectName { + var enumerated = name + enumerated.isEnumerated = true + return enumerated + } + + /// Appends the given index to the ``rawValue``. + /// + /// - Parameter index: The index to append to the ``rawValue``. + /// + /// - Returns: A new effect name with the index appended. + /// + /// The resulting ``rawValue`` will be formatted as `name0`, `name1`, etc. + /// + public func withIndex(_ index: UInt) -> EffectName { + .init(rawValue: "\(rawValue)\(index)") + } +} diff --git a/Sources/Probing/Errors/ProbingErrors.swift b/Sources/Probing/Errors/ProbingErrors.swift index 523a656..7921fe9 100644 --- a/Sources/Probing/Errors/ProbingErrors.swift +++ b/Sources/Probing/Errors/ProbingErrors.swift @@ -56,6 +56,7 @@ extension ProbingErrors { Recovery suggestions: - If effects have distinct purposes assign unique identifiers to each of them. - Ensure preexisting effect and its children have completed before a new one is created. + - Use EffectName.enumerated(_:) to automatically append an index to the name, making it unique. """ } } diff --git a/Sources/Probing/Probes/Probe.swift b/Sources/Probing/Probes/Probe.swift index 6508537..5c1cb28 100644 --- a/Sources/Probing/Probes/Probe.swift +++ b/Sources/Probing/Probes/Probe.swift @@ -6,6 +6,24 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// Defines a possible suspension point accessible from your tests. +/// +/// - Parameters: +/// - name: The name of the probe. Does not need to be unique. Defaults to ``ProbeName/default``. +/// - preprocessorFlag: A preprocessor flag that determines whether the generated code is included in the compiled binary. +/// Defaults to `DEBUG`. +/// +/// Think of a probe as a conditional “breakpoint” for tests, with two key differences: +/// - It suspends only the current effect/task, not the entire program. +/// - It enables you to run test assertions at that point in execution, rather than debugging. +/// +/// When a probe is installed, it is uniquely identified at that point in execution by a ``ProbeIdentifier``. +/// Subsequent probes with the same `name` in the same effect will be assigned the same identifier. +/// +/// If your code is compiled with the given `preprocessorFlag`, the probe becomes accessible and controllable from your tests +/// only when run within the `body` of `ProbeTesting.withProbing` function. Outside of that scope, this call does nothing +/// and resumes execution immediately. +/// @freestanding(expression) public macro probe( _ name: @autoclosure () -> ProbeName = .default, @@ -15,6 +33,7 @@ public macro probe( type: "ProbeMacro" ) +@_documentation(visibility: private) public func _probe( // swiftlint:disable:this identifier_name _ name: @autoclosure () -> ProbeName, isolation: isolated (any Actor)?, diff --git a/Sources/Probing/Probes/ProbeName.swift b/Sources/Probing/Probes/ProbeName.swift index e8b9502..7d231f0 100644 --- a/Sources/Probing/Probes/ProbeName.swift +++ b/Sources/Probing/Probes/ProbeName.swift @@ -6,14 +6,29 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// The name of a probe, forming the last component of a ``ProbeIdentifier``. +/// +/// A `ProbeName` doesn't need to be unique, as it is impossible to install two probes concurrently from the same effect. +/// public struct ProbeName: ProbingIdentifierProtocol { - public static let `default`: Self = "probe" - public let rawValue: String - + + /// Creates a new probe name. + /// + /// - Parameter rawValue: Non-empty string that must not contain any `.` characters. + /// public init(rawValue: String) { ProbingNames.preconditionValid(rawValue) self.rawValue = rawValue } } + +extension ProbeName { + + /// The default probe name, used by ``probe(_:preprocessorFlag:)`` when no `name` argument is provided. + /// + /// It's ``rawValue`` is `"probe"`. + /// + public static let `default`: Self = "probe" +} From c4ccc15b902e9c19b7688965cd654172a1c2af58 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 10 May 2025 18:30:51 +0200 Subject: [PATCH 11/41] [SwiftFormat] Applied formatting --- Sources/Probing/Effects/EffectName.swift | 2 +- Sources/Probing/Probes/Probe.swift | 25 ------------------------ Sources/Probing/Probes/ProbeName.swift | 2 +- 3 files changed, 2 insertions(+), 27 deletions(-) diff --git a/Sources/Probing/Effects/EffectName.swift b/Sources/Probing/Effects/EffectName.swift index 5a0095a..4393528 100644 --- a/Sources/Probing/Effects/EffectName.swift +++ b/Sources/Probing/Effects/EffectName.swift @@ -47,7 +47,7 @@ extension EffectName { } /// Appends the given index to the ``rawValue``. - /// + /// /// - Parameter index: The index to append to the ``rawValue``. /// /// - Returns: A new effect name with the index appended. diff --git a/Sources/Probing/Probes/Probe.swift b/Sources/Probing/Probes/Probe.swift index 5c1cb28..84ff281 100644 --- a/Sources/Probing/Probes/Probe.swift +++ b/Sources/Probing/Probes/Probe.swift @@ -32,28 +32,3 @@ public macro probe( module: "ProbingMacros", type: "ProbeMacro" ) - -@_documentation(visibility: private) -public func _probe( // swiftlint:disable:this identifier_name - _ name: @autoclosure () -> ProbeName, - isolation: isolated (any Actor)?, - fileID: String = #fileID, - line: Int = #line, - column: Int = #column -) async { - guard let coordinator = ProbingCoordinator.current else { - return - } - - let location = ProbingLocation( - fileID: fileID, - line: line, - column: column - ) - - await coordinator.installProbe( - withName: name(), - at: location, - isolation: isolation - ) -} diff --git a/Sources/Probing/Probes/ProbeName.swift b/Sources/Probing/Probes/ProbeName.swift index 7d231f0..71d4b7b 100644 --- a/Sources/Probing/Probes/ProbeName.swift +++ b/Sources/Probing/Probes/ProbeName.swift @@ -13,7 +13,7 @@ public struct ProbeName: ProbingIdentifierProtocol { public let rawValue: String - + /// Creates a new probe name. /// /// - Parameter rawValue: Non-empty string that must not contain any `.` characters. From 305beb67eddde9e920540106bb828de2e3f6f7e7 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 10 May 2025 19:28:46 +0200 Subject: [PATCH 12/41] Added Effect docs --- Sources/Probing/Effects/AnyEffect.swift | 8 +++++++- Sources/Probing/Effects/Effect.swift | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Sources/Probing/Effects/AnyEffect.swift b/Sources/Probing/Effects/AnyEffect.swift index 7cc0275..79d4a5c 100644 --- a/Sources/Probing/Effects/AnyEffect.swift +++ b/Sources/Probing/Effects/AnyEffect.swift @@ -6,6 +6,8 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// Type-erased wrapper for ``Effect``, providing conformance to `Equatable` and `Hashable`. +/// public struct AnyEffect: Effect, Hashable { public let task: Task @@ -17,7 +19,11 @@ public struct AnyEffect: Effect, Hashable { extension Effect { - public func erasedToAnyEffect() -> AnyEffect { + /// Wraps this effect with a type eraser, making it `Equatable` and `Hashable`. + /// + /// - Returns: An instance of ``AnyEffect`` wrapping this effect. + /// + public func eraseToAnyEffect() -> AnyEffect { AnyEffect(self) } } diff --git a/Sources/Probing/Effects/Effect.swift b/Sources/Probing/Effects/Effect.swift index bcfe96d..e7e675b 100644 --- a/Sources/Probing/Effects/Effect.swift +++ b/Sources/Probing/Effects/Effect.swift @@ -43,14 +43,36 @@ public macro ConcurrentEffect( type: "EffectMacro" ) +/// An interface shared by `Task` and types used by `Probing` to represent units of asynchronous work. +/// +/// You do not need to conform your own types to this protocol. +/// Types conforming to `Effect` are returned from effect macros such as ``Effect(_:preprocessorFlag:priority:operation:)``. +/// +/// Unlike `Task`, the `Effect` protocol does not currently support throwing errors. +/// Any error handling should be performed inside the operation executed by the effect itself. +/// public protocol Effect: Sendable { + /// The type of value returned by the effect. + /// associatedtype Success: Sendable + /// The underlying `Task` that performs the effect’s work. + /// var task: Task { get } + + /// The result from an effect, after it completes. + /// var value: Success { get async } + + /// Indicates whether the effect has been cancelled. + /// var isCancelled: Bool { get } + /// Cancels the effect. + /// + /// If the effect was cancelled after completing successfully, `Probing` considers it finished, not cancelled. + /// func cancel() } From a0fc82085f78442896e0e175d903abcf332a005f Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 10 May 2025 19:28:57 +0200 Subject: [PATCH 13/41] - --- Sources/Probing/Probes/Probe.swift | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Sources/Probing/Probes/Probe.swift b/Sources/Probing/Probes/Probe.swift index 84ff281..53a75cb 100644 --- a/Sources/Probing/Probes/Probe.swift +++ b/Sources/Probing/Probes/Probe.swift @@ -32,3 +32,27 @@ public macro probe( module: "ProbingMacros", type: "ProbeMacro" ) + +public func _probe( // swiftlint:disable:this identifier_name + _ name: @autoclosure () -> ProbeName, + isolation: isolated (any Actor)?, + fileID: String = #fileID, + line: Int = #line, + column: Int = #column +) async { + guard let coordinator = ProbingCoordinator.current else { + return + } + + let location = ProbingLocation( + fileID: fileID, + line: line, + column: column + ) + + await coordinator.installProbe( + withName: name(), + at: location, + isolation: isolation + ) +} From a43de1370017a2b2f208496e96722a603d86d53b Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 10 May 2025 20:34:05 +0200 Subject: [PATCH 14/41] Added #Effect macro docs --- Sources/Probing/Effects/Effect.swift | 85 +++++++++++++++++++++++- Sources/Probing/Effects/EffectName.swift | 9 ++- Sources/Probing/Probes/ProbeName.swift | 2 + 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/Sources/Probing/Effects/Effect.swift b/Sources/Probing/Effects/Effect.swift index e7e675b..cb3d634 100644 --- a/Sources/Probing/Effects/Effect.swift +++ b/Sources/Probing/Effects/Effect.swift @@ -6,6 +6,32 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// Creates a `Task`-like effect that can be controlled from your tests. +/// +/// - Parameters: +/// - name: The name of the effect. It must be unique within the scope of its parent while the effect is still running. +/// - preprocessorFlag: A preprocessor flag that determines whether the generated code is included in the compiled binary. +/// Defaults to `DEBUG`. +/// - priority: The priority of the underlying task. +/// - operation: The asynchronous operation to perform. +/// +/// - Returns: An instance of type conforming to the ``Effect`` protocol. +/// +/// When run in the `body` of `ProbeTesting.withProbing` function, the effect is suspended immediately after initialization, +/// instead of starting execution as `Task` would. Later, it can be resumed and suspended at suspension points declared +/// using the ``probe(_:preprocessorFlag:)`` macro within the `operation`. +/// +/// Each effect must be uniquely identified within the scope of its parent by its `name` at every point in its execution. +/// Failure to do so will result in an error during testing. Once an effect completes, its identifier can be reused. +/// +/// If your code is compiled with the given `preprocessorFlag`, the effect becomes accessible and controllable from your tests +/// only when created within the `body` of `ProbeTesting.withProbing` function. Outside of that scope, this call initializes +/// a regular Swift `Task` that is not subject to any additional scheduling. +/// +/// - Attention: Unlike `Task`, the ``Effect`` protocol does not support throwing errors. +/// Any error handling should be performed inside the operation executed by the effect itself. +/// This design choice helps prevent errors from being unintentionally left unhandled. +/// @discardableResult @freestanding(expression) public macro Effect( @@ -18,6 +44,33 @@ public macro Effect( type: "EffectMacro" ) +/// Creates a `Task`-like effect that runs on the specified executor and can be controlled from your tests. +/// +/// - Parameters: +/// - name: The name of the effect. It must be unique within the scope of its parent while the effect is still running. +/// - preprocessorFlag: A preprocessor flag that determines whether the generated code is included in the compiled binary. +/// Defaults to `DEBUG`. +/// - taskExecutor: The preferred task executor for the underlying task, and any child tasks created by it. +/// - priority: The priority of the underlying task. +/// - operation: The asynchronous operation to perform. +/// +/// - Returns: An instance of type conforming to the ``Effect`` protocol. +/// +/// When run in the `body` of `ProbeTesting.withProbing` function, the effect is suspended immediately after initialization, +/// instead of starting execution as `Task` would. Later, it can be resumed and suspended at suspension points declared +/// using the ``probe(_:preprocessorFlag:)`` macro within the `operation`. +/// +/// Each effect must be uniquely identified within the scope of its parent by its `name` at every point in its execution. +/// Failure to do so will result in an error during testing. Once an effect completes, its identifier can be reused. +/// +/// If your code is compiled with the given `preprocessorFlag`, the effect becomes accessible and controllable from your tests +/// only when created within the `body` of `ProbeTesting.withProbing` function. Outside of that scope, this call initializes +/// a regular Swift `Task` that is not subject to any additional scheduling. +/// +/// - Attention: Unlike `Task`, the ``Effect`` protocol does not support throwing errors. +/// Any error handling should be performed inside the operation executed by the effect itself. +/// This design choice helps prevent errors from being unintentionally left unhandled. +/// @discardableResult @freestanding(expression) public macro Effect( @@ -31,6 +84,35 @@ public macro Effect( type: "EffectMacro" ) +/// Creates a `Task`-like effect that runs on the `globalConcurrentExecutor` and can be controlled from your tests. +/// +/// - Parameters: +/// - name: The name of the effect. It must be unique within the scope of its parent while the effect is still running. +/// - preprocessorFlag: A preprocessor flag that determines whether the generated code is included in the compiled binary. +/// Defaults to `DEBUG`. +/// - priority: The priority of the underlying task. +/// - operation: The asynchronous operation to perform. +/// +/// - Returns: An instance of type conforming to the ``Effect`` protocol. +/// +/// - Note: This macro is equivalent to calling ``Effect(_:preprocessorFlag:executorPreference:priority:operation:)`` with `globalConcurrentExecutor`. +/// If you were using `Task.detached`, this may be a viable alternative. +/// +/// When run in the `body` of `ProbeTesting.withProbing` function, the effect is suspended immediately after initialization, +/// instead of starting execution as `Task` would. Later, it can be resumed and suspended at suspension points declared +/// using the ``probe(_:preprocessorFlag:)`` macro within the `operation`. +/// +/// Each effect must be uniquely identified within the scope of its parent by its `name` at every point in its execution. +/// Failure to do so will result in an error during testing. Once an effect completes, its identifier can be reused. +/// +/// If your code is compiled with the given `preprocessorFlag`, the effect becomes accessible and controllable from your tests +/// only when created within the `body` of `ProbeTesting.withProbing` function. Outside of that scope, this call initializes +/// a regular Swift `Task` that is not subject to any additional scheduling. +/// +/// - Attention: Unlike `Task`, the ``Effect`` protocol does not support throwing errors. +/// Any error handling should be performed inside the operation executed by the effect itself. +/// This design choice helps prevent errors from being unintentionally left unhandled. +/// @discardableResult @freestanding(expression) public macro ConcurrentEffect( @@ -48,8 +130,9 @@ public macro ConcurrentEffect( /// You do not need to conform your own types to this protocol. /// Types conforming to `Effect` are returned from effect macros such as ``Effect(_:preprocessorFlag:priority:operation:)``. /// -/// Unlike `Task`, the `Effect` protocol does not currently support throwing errors. +/// - Attention: Unlike `Task`, the `Effect` protocol does not support throwing errors. /// Any error handling should be performed inside the operation executed by the effect itself. +/// This design choice helps prevent errors from being unintentionally left unhandled. /// public protocol Effect: Sendable { diff --git a/Sources/Probing/Effects/EffectName.swift b/Sources/Probing/Effects/EffectName.swift index 4393528..9f37a03 100644 --- a/Sources/Probing/Effects/EffectName.swift +++ b/Sources/Probing/Effects/EffectName.swift @@ -17,7 +17,10 @@ /// public struct EffectName: ProbingIdentifierProtocol { + /// Non-empty string that does not contain any `.` characters. + /// public let rawValue: String + private(set) var isEnumerated = false /// Creates a new effect name. @@ -32,11 +35,11 @@ public struct EffectName: ProbingIdentifierProtocol { extension EffectName { - /// Prepares the given effect name for automatic indexing during tests, starting at zero. + /// Marks the given effect name as eligible for automatic indexing during tests, starting from zero. /// - /// - Parameter name: The effect name to append an index to. + /// - Parameter name: The effect name to append an index to during tests. /// - /// - Returns: A new effect name prepared for automatic indexing. + /// - Returns: A new effect name that will be automatically indexed during tests. /// /// The resulting ``rawValue`` during tests will be formatted as `name0`, `name1`, etc. /// diff --git a/Sources/Probing/Probes/ProbeName.swift b/Sources/Probing/Probes/ProbeName.swift index 71d4b7b..8a72271 100644 --- a/Sources/Probing/Probes/ProbeName.swift +++ b/Sources/Probing/Probes/ProbeName.swift @@ -12,6 +12,8 @@ /// public struct ProbeName: ProbingIdentifierProtocol { + /// Non-empty string that does not contain any `.` characters. + /// public let rawValue: String /// Creates a new probe name. From 4c27f33ae817d203592654ebf5a6ab2d8349715d Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 10 May 2025 20:34:05 +0200 Subject: [PATCH 15/41] [SwiftFormat] Applied formatting --- Sources/Probing/Probes/ProbeName.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Probing/Probes/ProbeName.swift b/Sources/Probing/Probes/ProbeName.swift index 8a72271..f0c1821 100644 --- a/Sources/Probing/Probes/ProbeName.swift +++ b/Sources/Probing/Probes/ProbeName.swift @@ -13,7 +13,7 @@ public struct ProbeName: ProbingIdentifierProtocol { /// Non-empty string that does not contain any `.` characters. - /// + /// public let rawValue: String /// Creates a new probe name. From 43cbfd93ba6043e71dcc52c528e80a0b70698637 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 11 May 2025 14:53:52 +0200 Subject: [PATCH 16/41] - --- .../Helpers/Issue+RecordedError.swift | 6 ++++-- Tests/ProbingTests/EffectTests.swift | 16 ++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Tests/ProbeTestingTests/Helpers/Issue+RecordedError.swift b/Tests/ProbeTestingTests/Helpers/Issue+RecordedError.swift index 3071f95..6e646ba 100644 --- a/Tests/ProbeTestingTests/Helpers/Issue+RecordedError.swift +++ b/Tests/ProbeTestingTests/Helpers/Issue+RecordedError.swift @@ -14,7 +14,9 @@ extension Issue { func didRecordError( _: Underlying.Type ) -> Bool { - let recordedError = error as? RecordedError - return recordedError?.underlying is Underlying + guard let recordedError = error as? RecordedError else { + return error is ProbingTerminatedError + } + return recordedError.underlying is Underlying } } diff --git a/Tests/ProbingTests/EffectTests.swift b/Tests/ProbingTests/EffectTests.swift index 23fac90..21506cf 100644 --- a/Tests/ProbingTests/EffectTests.swift +++ b/Tests/ProbingTests/EffectTests.swift @@ -22,7 +22,7 @@ internal struct EffectTests { } #expect(effect is TestableEffect) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value } } @@ -40,7 +40,7 @@ internal struct EffectTests { ) #expect(effect is Task) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value } } @@ -67,7 +67,7 @@ internal struct EffectTests { } #expect(effect is TestableEffect) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value } } @@ -86,7 +86,7 @@ internal struct EffectTests { ) #expect(effect is Task) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value } } @@ -112,7 +112,7 @@ internal struct EffectTests { } #expect(effect is TestableEffect) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value } } @@ -130,7 +130,7 @@ internal struct EffectTests { ) #expect(effect is Task) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value } } @@ -159,7 +159,7 @@ internal struct EffectTests { } #expect(effect is TestableEffect>>) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value.value.value } } @@ -176,7 +176,7 @@ internal struct EffectTests { } #expect(effect is Task>, Never>) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value.value.value } } From b781d2b6b1ae80b9aebbf32865827f49bc48a147 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 11 May 2025 14:54:43 +0200 Subject: [PATCH 17/41] Completed Probing docs --- .../Documentation.docc/EffectIdentifier.md | 26 ++++ .../Probing/Documentation.docc/EffectName.md | 17 +++ .../Probing/Documentation.docc/Examples.md | 121 ++++++++++++++++++ .../Documentation.docc/ProbeIdentifier.md | 18 +++ .../Probing/Documentation.docc/ProbeName.md | 16 +++ Sources/Probing/Documentation.docc/Probing.md | 72 +++++++++++ .../Probing/Effects/EffectIdentifier.swift | 103 +++++++++++++++ Sources/Probing/Effects/EffectName.swift | 6 +- Sources/Probing/Errors/ProbingErrors.swift | 2 +- .../Errors/ProbingInterruptedError.swift | 9 -- Sources/Probing/Probes/ProbeIdentifier.swift | 55 ++++++++ Sources/Probing/Probes/ProbeName.swift | 6 +- 12 files changed, 435 insertions(+), 16 deletions(-) create mode 100644 Sources/Probing/Documentation.docc/EffectIdentifier.md create mode 100644 Sources/Probing/Documentation.docc/EffectName.md create mode 100644 Sources/Probing/Documentation.docc/Examples.md create mode 100644 Sources/Probing/Documentation.docc/ProbeIdentifier.md create mode 100644 Sources/Probing/Documentation.docc/ProbeName.md create mode 100644 Sources/Probing/Documentation.docc/Probing.md delete mode 100644 Sources/Probing/Errors/ProbingInterruptedError.swift diff --git a/Sources/Probing/Documentation.docc/EffectIdentifier.md b/Sources/Probing/Documentation.docc/EffectIdentifier.md new file mode 100644 index 0000000..7b17c56 --- /dev/null +++ b/Sources/Probing/Documentation.docc/EffectIdentifier.md @@ -0,0 +1,26 @@ +# ``Probing/EffectIdentifier`` + +## Topics + +### Creating Identifiers For Lookup During Tests + +- ``init(path:)`` +- ``init(arrayLiteral:)`` +- ``init(rawValue:)`` +- ``init(stringInterpolation:)`` + +### Accessing Raw Value + +- ``rawValue`` + +### Accessing Components + +- ``path`` + +### Debugging Effects + +- ``current`` + +### Understanding Effects Hierarchy + +- ``root`` diff --git a/Sources/Probing/Documentation.docc/EffectName.md b/Sources/Probing/Documentation.docc/EffectName.md new file mode 100644 index 0000000..0ade355 --- /dev/null +++ b/Sources/Probing/Documentation.docc/EffectName.md @@ -0,0 +1,17 @@ +# ``Probing/EffectName`` + +## Topics + +### Creating Names + +- ``init(rawValue:)`` +- ``init(stringInterpolation:)`` + +### Accessing Raw Value + +- ``rawValue`` + +### Uniquing Names + +- ``enumerated(_:)`` +- ``withIndex(_:)`` diff --git a/Sources/Probing/Documentation.docc/Examples.md b/Sources/Probing/Documentation.docc/Examples.md new file mode 100644 index 0000000..2b76385 --- /dev/null +++ b/Sources/Probing/Documentation.docc/Examples.md @@ -0,0 +1,121 @@ +# Examples + +Explore simplified examples from the `ProbingPlayground` sample project to see `Probing` integration in action. + +## Download the Sample Project + +You can download the `ProbingPlayground` sample project from its [GitHub page](https://github.com/NSFatalError/ProbingPlayground). + +## Probing in Async Functions + +To verify that an object has changed state while an `async` function is running, +you can define probes after the state change but before the next `await` statement: + +```swift +func uploadImage(_ item: ImageItem) async { + do { + uploadState = .uploading + await #probe() // ADDED + let image = try await item.loadImage() + let processedImage = try await processor.processImage(image) + try await uploader.uploadImage(processedImage) + uploadState = .success + } catch { + uploadState = .error + } + + await #probe() // ADDED + try? await Task.sleep(for: .seconds(3)) + uploadState = nil +} +``` + +This lets you reliably check the `uploadState` before the image is processed and uploaded, +and before the presentation timer fires, reproducing the exact sequence of events users experience in your app. + +Similarly, you can install probes after each iteration of an `AsyncSequence` loop: + +```swift +func updateLocation() async { + locationState = .unknown + await #probe() // ADDED + + do { + for try await update in locationProvider.getUpdates() { + try Task.checkCancellation() + + if update.authorizationDenied { + locationState = .error + } else if let isNear = update.location?.isNearSanFrancisco() { + locationState = isNear ? .near : .far + } else { + locationState = .unknown + } + await #probe() // ADDED + } + } catch { + locationState = .error + } +} +``` + +This allows you to reliably verify `locationState` before entering the loop, +after each iteration, or after an error is thrown. + +## Probing with Tasks + +To control the execution of tasks, you can replace them with equivalent effect macros: + +```swift +private var downloadImageEffects = [ImageQuality: any Effect]() // CHANGED + +func downloadImage() { + downloadImageEffects.values.forEach { $0.cancel() } + downloadImageEffects.removeAll() + downloadState = .downloading + + downloadImage(withQuality: .low) + downloadImage(withQuality: .high) +} + +private func downloadImage(withQuality quality: ImageQuality) { + downloadImageEffects[quality] = #Effect("\(quality)") { // CHANGED + defer { + downloadImageEffects[quality] = nil + } + + do { + let image = try await downloader.downloadImage(withQuality: quality) + try Task.checkCancellation() + imageDownloadSucceeded(with: image, quality: quality) + } catch is CancellationError { + return + } catch { + imageDownloadFailed() + } + } +} +``` + +This enables you to reliably check the `downloadState` before the downloads start. +You can also precisely recreate various runtime scenarios, putting your objects into different states +that users might experience, such as: +- low quality download succeeded, while high quality download is pending +- high quality download succeeded, while low quality download is pending +- low quality download succeeded, then high quality download succeeded +- high quality download succeeded, then low quality download succeeded +- low quality download failed, then high quality download succeeded +- high quality download failed, then low quality download succeeded +- low quality download succeeded, then high quality download failed +- high quality download succeeded, then low quality download failed +- low quality download failed, then high quality download failed +- high quality download failed, then low quality download failed +- user requested redownload before either download completed +- user requested redownload before high quality download completed +- user requested redownload before low quality download completed +- etc. + +The return values and cancellation states of the effects are also available during testing, +without needing to reference them directly, as they are uniquely identified and can be retrieved. + +- SeeAlso: Refer to the `ProbeTesting` documentation for details on accessing probes and controlling effects during tests. diff --git a/Sources/Probing/Documentation.docc/ProbeIdentifier.md b/Sources/Probing/Documentation.docc/ProbeIdentifier.md new file mode 100644 index 0000000..c1f9446 --- /dev/null +++ b/Sources/Probing/Documentation.docc/ProbeIdentifier.md @@ -0,0 +1,18 @@ +# ``Probing/ProbeIdentifier`` + +## Topics + +### Creating Identifiers For Lookup During Tests + +- ``init(effect:name:)`` +- ``init(rawValue:)`` +- ``init(stringInterpolation:)`` + +### Accessing Raw Value + +- ``rawValue`` + +### Accessing Components + +- ``name`` +- ``effect`` diff --git a/Sources/Probing/Documentation.docc/ProbeName.md b/Sources/Probing/Documentation.docc/ProbeName.md new file mode 100644 index 0000000..d7679e7 --- /dev/null +++ b/Sources/Probing/Documentation.docc/ProbeName.md @@ -0,0 +1,16 @@ +# ``Probing/ProbeName`` + +## Topics + +### Creating Names + +- ``init(rawValue:)`` +- ``init(stringInterpolation:)`` + +### Accessing Raw Value + +- ``rawValue`` + +### Getting Default Name + +- ``default`` diff --git a/Sources/Probing/Documentation.docc/Probing.md b/Sources/Probing/Documentation.docc/Probing.md new file mode 100644 index 0000000..4b6cd1e --- /dev/null +++ b/Sources/Probing/Documentation.docc/Probing.md @@ -0,0 +1,72 @@ +# ``Probing`` + +Define suspension points accessible from tests with probes, and make side effects controllable. + +## Overview + +To use `ProbeTesting`, you first need to prepare your codebase using the `Probing` library. + +The `Probing` library lets you define suspension points using the ``probe(_:preprocessorFlag:)`` macro. +These are typically placed after a state change and before `await` statements, for example: + +```swift +func load() async { + isLoading = true + + // Add a probe macro after `isLoading` is set to `true`, + // but before next `await`, to verify the state change in your tests. + await #probe() + + await downloadAndProcess() + isLoading = false +} +``` + +If your code uses `Task` to create side effects, you can replace them with the ``Effect(_:preprocessorFlag:priority:operation:)`` +macro or one of its variants. For example: + +```swift +func load() { + isLoading = true + + #Effect("download") { + // Replace `Task` instances with effect macros, + // so they will become initially suspended during tests, + // making their execution controllable and predictable. + + await downloadAndProcess() + isLoading = false + } +} +``` + +`Task` and effects created by `Probing` both conform to the ``Effect`` protocol and have the same public interface, +so replacing one with the other should be seamless. In fact, in release builds (by default), effect macros will return +standard Swift `Task` instances, so that your production code will not be affected by integration with `Probing`. + +- SeeAlso: Refer to the `ProbeTesting` documentation for details on accessing probes and controlling effects during tests. + +## Topics + +### Getting Started + +- + +### Installing Probes + +- ``probe(_:preprocessorFlag:)`` +- ``ProbeName`` +- ``ProbeIdentifier`` + +### Creating Effects + +- ``Effect(_:preprocessorFlag:priority:operation:)`` +- ``Effect(_:preprocessorFlag:executorPreference:priority:operation:)`` +- ``ConcurrentEffect(_:preprocessorFlag:priority:operation:)`` +- ``EffectName`` +- ``EffectIdentifier`` + +### Using Effects + +- ``Effect`` +- ``AnyEffect`` diff --git a/Sources/Probing/Effects/EffectIdentifier.swift b/Sources/Probing/Effects/EffectIdentifier.swift index 22d49d3..6717d05 100644 --- a/Sources/Probing/Effects/EffectIdentifier.swift +++ b/Sources/Probing/Effects/EffectIdentifier.swift @@ -8,13 +8,97 @@ import Synchronization +/// A unique identifier of an effect at every point in its execution. +/// +/// An `EffectIdentifier` consists of the ``EffectName`` components passed to the ``Effect(_:preprocessorFlag:priority:operation:)`` +/// macro (and its variants). These names form the identifier's ``path``, which reflects the stack of nested effects during a particular invocation. +/// +/// The identifier is represented as a string formed by concatenating these components with `"."` separators: +/// ```swift +/// path.map(\.rawValue).joined(separator: ".") +/// // "effect1.effect2.(...).effectN" +/// ``` +/// +/// If the ``path`` is empty, the identifier is also empty, and the effect is known as the ``root``. +/// The root effect is the one that executes the `body` of the `ProbeTesting.withProbing` function, and it is created implicitly. +/// All effects created within `body` are considered descendants of the root effect. +/// ```swift +/// withProbing { +/// // #Effect(.root) { +/// try await body() +/// // } +/// } dispatchedBy: { ... } +/// ``` +/// +/// ### Example +/// +/// ```swift +/// func createInnerEffect() { +/// #Effect("inner") {} +/// } +/// +/// func createOuterEffect() { +/// #Effect("outer") { +/// createInnerEffect() +/// } +/// } +/// +/// createInnerEffect() +/// // Identifier: "inner" +/// +/// createOuterEffect() +/// // Identifiers: "outer", "outer.inner" +/// ``` +/// public struct EffectIdentifier { + /// The identifier of the effect executing the `body` of the `ProbeTesting.withProbing` function. + /// + /// This effect is created implicitly during testing, and all effects created within the `body` are considered its descendants. + /// + /// Its ``rawValue`` is `""`, and its ``path`` is `[]`. + /// public static let root = Self(path: []) + /// A string formed by concatenating the ``path`` elements with `"."` separators. + /// + /// The `rawValue` has the form: + /// ```swift + /// path.map(\.rawValue).joined(separator: ".") + /// // "effect1.effect2.(...).effectN" + /// ``` + /// public let rawValue: String + + /// The stack of effect names that created a particular effect. + /// + /// The `path` reflects the nesting hierarchy of effects during a particular invocation. For example: + /// ```swift + /// func createInnerEffect() { + /// #Effect("inner") {} + /// } + /// + /// func createOuterEffect() { + /// #Effect("outer") { + /// createInnerEffect() + /// } + /// } + /// + /// createInnerEffect() + /// // Path: ["inner"] + /// + /// createOuterEffect() + /// // Paths: ["outer"], ["outer", "inner"] + /// public let path: [EffectName] + /// Creates an effect identifier for lookup during tests. + /// + /// - Parameter path: The stack of effect names that created a particular effect. + /// + /// Effect identifiers are determined dynamically during test execution. + /// Use this initializer to construct expected effect identifiers, which can be passed to the `ProbeTesting.ProbingDispatcher` to control the execution flow. + /// public init(path: [EffectName]) { self.rawValue = ProbingIdentifiers.join(path) self.path = path @@ -26,6 +110,11 @@ extension EffectIdentifier { @TaskLocal private static var _currentNodes = [Node]() + /// The identifier of the effect from which this property was accessed. + /// + /// - Important: This property only works if the effect was created by invocation of the `body` of the `ProbeTesting.withProbing` function. + /// Otherwise, it returns the ``root`` identifier. + /// public static var current: EffectIdentifier { _currentNodes.last?.id ?? .root } @@ -75,6 +164,13 @@ extension EffectIdentifier: ProbingIdentifierProtocol { } } + /// Creates an effect identifier for lookup during tests. + /// + /// - Parameter rawValue: A string in the format described by ``rawValue``. + /// + /// Effect identifiers are determined dynamically during test execution. + /// Use this initializer to construct expected effect identifiers, which can be passed to the `ProbeTesting.ProbingDispatcher` to control the execution flow. + /// public init(rawValue: String) { let path = ProbingIdentifiers.split(rawValue).map(EffectName.init) self.init(path: path) @@ -83,6 +179,13 @@ extension EffectIdentifier: ProbingIdentifierProtocol { extension EffectIdentifier: ExpressibleByArrayLiteral { + /// Creates an effect identifier for lookup during tests. + /// + /// - Parameter elements: The stack of effect names that created a particular effect. + /// + /// Effect identifiers are determined dynamically during test execution. + /// Use this initializer to construct expected effect identifiers, which can be passed to the `ProbeTesting.ProbingDispatcher` to control the execution flow. + /// public init(arrayLiteral elements: EffectName...) { self.init(path: elements) } diff --git a/Sources/Probing/Effects/EffectName.swift b/Sources/Probing/Effects/EffectName.swift index 9f37a03..0ee882c 100644 --- a/Sources/Probing/Effects/EffectName.swift +++ b/Sources/Probing/Effects/EffectName.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -/// The name of an effect, forming the last component of an ``EffectIdentifier``. +/// The name of an effect, forming the elements of ``EffectIdentifier/path`` of an ``EffectIdentifier``. /// /// An `EffectName` must be unique within the scope of its parent effect while the effect is still running. /// Once the effect has completed, the same name may be reused by another effect. @@ -17,7 +17,7 @@ /// public struct EffectName: ProbingIdentifierProtocol { - /// Non-empty string that does not contain any `.` characters. + /// A non-empty string that does not contain any `.` characters. /// public let rawValue: String @@ -25,7 +25,7 @@ public struct EffectName: ProbingIdentifierProtocol { /// Creates a new effect name. /// - /// - Parameter rawValue: Non-empty string that must not contain any `.` characters. + /// - Parameter rawValue: A non-empty string that must not contain any `.` characters. /// public init(rawValue: String) { ProbingNames.preconditionValid(rawValue) diff --git a/Sources/Probing/Errors/ProbingErrors.swift b/Sources/Probing/Errors/ProbingErrors.swift index 7921fe9..e56dcd9 100644 --- a/Sources/Probing/Errors/ProbingErrors.swift +++ b/Sources/Probing/Errors/ProbingErrors.swift @@ -55,8 +55,8 @@ extension ProbingErrors { Recovery suggestions: - If effects have distinct purposes assign unique identifiers to each of them. - - Ensure preexisting effect and its children have completed before a new one is created. - Use EffectName.enumerated(_:) to automatically append an index to the name, making it unique. + - Ensure preexisting effect and its children have completed before a new one is created. """ } } diff --git a/Sources/Probing/Errors/ProbingInterruptedError.swift b/Sources/Probing/Errors/ProbingInterruptedError.swift deleted file mode 100644 index c8d2307..0000000 --- a/Sources/Probing/Errors/ProbingInterruptedError.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// ProbingInterruptedError.swift -// Probing -// -// Created by Kamil Strzelecki on 05/05/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -internal struct ProbingInterruptedError: Error {} diff --git a/Sources/Probing/Probes/ProbeIdentifier.swift b/Sources/Probing/Probes/ProbeIdentifier.swift index cb8a717..7c1ea43 100644 --- a/Sources/Probing/Probes/ProbeIdentifier.swift +++ b/Sources/Probing/Probes/ProbeIdentifier.swift @@ -6,12 +6,60 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// A unique identifier of a probe at a specific point in execution. +/// +/// A `ProbeIdentifier` consists of the ``effect`` identifier in which the probe was installed during a particular test run, +/// and the probe ``name`` passed to the ``probe(_:preprocessorFlag:)`` macro. +/// +/// It is represented as a string formed by concatenating these components with `"."` separators: +/// ```swift +/// "\(effect).\(name)" +/// // "effect1.effect2.(...).effectN.probe" +/// ``` +/// +/// If the probe is installed directly in the `body` of the `ProbeTesting.withProbing` function, without any intermediate effects, +/// the identifier contains only the ``name``, since the ``effect``.``EffectIdentifier/path`` is empty: +/// ```swift +/// "\(name)" +/// // "probe" +/// ``` +/// public struct ProbeIdentifier { + /// A string formed by concatenating the ``effect`` and ``name`` components with `"."` separators. + /// + /// If the probe is installed within an effect, the `rawValue` has the form: + /// ```swift + /// "\(effect).\(name)" + /// // "effect1.effect2.(...).effectN.probe" + /// ``` + /// + /// If the probe is installed directly in the `body` of the `ProbeTesting.withProbing` function, without any intermediate effects, + /// the `rawValue` contains only the ``name``, since the ``effect``.``EffectIdentifier/path`` is empty: + /// ```swift + /// "\(name)" + /// // "probe" + /// ``` + /// public let rawValue: String + + /// The identifier of the effect in which the probe was installed during a particular test run. + /// public let effect: EffectIdentifier + + /// The name of the probe, as passed to the ``probe(_:preprocessorFlag:)`` macro. + /// public let name: ProbeName + /// Creates a probe identifier for lookup during tests. + /// + /// - Parameters: + /// - effect: The identifier of the effect in which the probe was installed. + /// - name: The name of the probe. + /// + /// Probe identifiers are determined dynamically during test execution. + /// Use this initializer to construct expected probe identifiers, which can be passed to the `ProbeTesting.ProbingDispatcher` to control the execution flow. + /// public init( effect: EffectIdentifier, name: ProbeName @@ -27,6 +75,13 @@ public struct ProbeIdentifier { extension ProbeIdentifier: ProbingIdentifierProtocol { + /// Creates a probe identifier for lookup during tests. + /// + /// - Parameter rawValue: A non-empty string in the format described by ``rawValue``. + /// + /// Probe identifiers are determined dynamically during test execution. + /// Use this initializer to construct expected probe identifiers, which can be passed to the `ProbeTesting.ProbingDispatcher` to control the execution flow. + /// public init(rawValue: String) { let components = ProbingIdentifiers.split(rawValue) let path = components.dropLast().map(EffectName.init) diff --git a/Sources/Probing/Probes/ProbeName.swift b/Sources/Probing/Probes/ProbeName.swift index f0c1821..d208a76 100644 --- a/Sources/Probing/Probes/ProbeName.swift +++ b/Sources/Probing/Probes/ProbeName.swift @@ -12,13 +12,13 @@ /// public struct ProbeName: ProbingIdentifierProtocol { - /// Non-empty string that does not contain any `.` characters. + /// A non-empty string that does not contain any `.` characters. /// public let rawValue: String /// Creates a new probe name. /// - /// - Parameter rawValue: Non-empty string that must not contain any `.` characters. + /// - Parameter rawValue: A non-empty string that must not contain any `.` characters. /// public init(rawValue: String) { ProbingNames.preconditionValid(rawValue) @@ -30,7 +30,7 @@ extension ProbeName { /// The default probe name, used by ``probe(_:preprocessorFlag:)`` when no `name` argument is provided. /// - /// It's ``rawValue`` is `"probe"`. + /// Its ``rawValue`` is `"probe"`. /// public static let `default`: Self = "probe" } From a1756ab02d054635eedf186a6bf5318274594eb1 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 11 May 2025 15:42:13 +0200 Subject: [PATCH 18/41] Added ProbingDispatcher docs --- .../Dispatcher/ProbingDispatcher.swift | 253 +++++++++++++++++- .../Dispatcher/RecordedError.swift | 9 +- .../Documentation.docc/ProbingDispatcher.md | 23 ++ .../WithProbing/ProbingTerminatedError.swift | 17 ++ .../WithProbing/WithProbing.swift | 8 +- Sources/Probing/Probes/Probe.swift | 2 +- 6 files changed, 299 insertions(+), 13 deletions(-) create mode 100644 Sources/ProbeTesting/Documentation.docc/ProbingDispatcher.md create mode 100644 Sources/ProbeTesting/WithProbing/ProbingTerminatedError.swift diff --git a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift index 726e67f..0646ff8 100644 --- a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift +++ b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift @@ -9,6 +9,20 @@ import Probing import Testing +/// Object that controls execution of the `body` during a test. +/// +/// You don't create instances of `ProbingDispatcher` directly. +/// Instead, an instance is provided to you within the `test` closure of ``withProbing(options:sourceLocation:isolation:of:dispatchedBy:)``. +/// +/// `ProbingDispatcher` always performs the minimal necessary work to drive execution toward a desired state. +/// After calling one of its methods, it eagerly suspends the `body` and any effects it created by its invocation at two points: +/// - Explicitly, at declared `#probe()` macros within the `body` and nested effects +/// - Implicitly, immediately after initializing effects with `#Effect` macros, preventing them from starting until required +/// +/// This ensures that no part of the tested code runs concurrently with the expectations defined in the `test`. +/// +/// - SeeAlso: For details on how probe and effect identifiers are constructed, see the `Probing` documentation. +/// public struct ProbingDispatcher: ~Escapable, Sendable { private let coordinator: ProbingCoordinator @@ -31,7 +45,7 @@ extension ProbingDispatcher { do { _ = isolation try await dispatch() - } catch { + } catch let error as any RecordableProbingError { throw RecordedError( underlying: error, sourceLocation: sourceLocation @@ -46,7 +60,7 @@ extension ProbingDispatcher { ) rethrows -> R { do { return try block() - } catch { + } catch let error as any RecordableProbingError { throw RecordedError( underlying: error, sourceLocation: sourceLocation @@ -58,7 +72,42 @@ extension ProbingDispatcher { extension ProbingDispatcher { // swiftlint:disable no_empty_block - + + /// Resumes execution of `body`, performing the minimal necessary work to install the specified probe, and suspends `body` again before returning. + /// + /// - Parameter id: Identifier of the probe, which is guaranteed to be installed when this function returns. + /// + /// If any effect along the `id.effect.path` has not yet been created, this function resumes its closest ancestor until the required effect is initialized, + /// suspending that ancestor at the next available probe. Once the parent effect (`id.effect.path.last`) is created , it is resumed and suspended at the first probe matching `id.name`. + /// + /// - Throws: If the probe is unreachable, fails to install, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// + /// ```swift + /// try await withProbing { + /// await #probe("1") // id: "1" + /// print("1") + /// #Effect("first") { // <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// #Effect("second") { + /// await #probe("1") // id: "second.1" + /// print("second.1") + /// await #probe("2") // id: "second.2" <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// await #probe("2") // id: "2" <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } dispatchedBy: { dispatcher in + /// try await dispatcher.runUpToProbe("second.2") + /// // Always prints: + /// // 1 + /// // second.1 + /// } + /// ``` + /// + /// - Tip: Conceptually, this algorithm resembles [breadth-first search](https://en.wikipedia.org/wiki/Breadth-first_search), + /// where effects form the nodes and probes are the leaves of the execution tree. + /// public func runUpToProbe( _ id: ProbeIdentifier, sourceLocation: SourceLocation = #_sourceLocation, @@ -76,6 +125,45 @@ extension ProbingDispatcher { ) } + /// Resumes execution of `body`, performing the minimal necessary work to install a default probe in the specified effect, and suspends `body` again before returning. + /// + /// - Parameter effectID: Identifier of the effect in which a default probe is guaranteed to be installed when this function returns. + /// + /// - Attention: A **default probe** is a probe created via `#probe()` macro without specifying a name, or via `#probe(.default)`. + /// + /// - Note: This function is equivalent to calling ``runUpToProbe(_:sourceLocation:isolation:)`` with `ProbeIdentifier(effect: effectID, name: .default)` + /// + /// If any effect along the `effectID.path` has not yet been created, this function resumes its closest ancestor until the required effect is initialized, + /// suspending that ancestor at the next available probe. Once the parent effect (`effectID.path.last`) is created , it is resumed and suspended at the first probe with `.default` name. + /// + /// - Throws: If the probe is unreachable, fails to install, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// + /// ```swift + /// try await withProbing { + /// await #probe() // id: "probe" + /// print("probe") + /// #Effect("first") { // <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// #Effect("second") { + /// await #probe("1") // id: "second.1" + /// print("second.1") + /// await #probe() // id: "second.probe" <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// await #probe() // id: "probe" <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } dispatchedBy: { dispatcher in + /// try await dispatcher.runUpToProbe(inEffect: "second") + /// // Always prints: + /// // probe + /// // second.1 + /// } + /// ``` + /// + /// - Tip: Conceptually, this algorithm resembles [breadth-first search](https://en.wikipedia.org/wiki/Breadth-first_search), + /// where effects form the nodes and probes are the leaves of the execution tree. + /// public func runUpToProbe( inEffect effectID: EffectIdentifier, sourceLocation: SourceLocation = #_sourceLocation, @@ -88,6 +176,36 @@ extension ProbingDispatcher { ) } + /// Resumes execution of `body`, performing the minimal necessary work to install a default probe that is not nested in any effect, and suspends `body` again before returning. + /// + /// - Attention: A **default probe** is a probe created via `#probe()` macro without specifying a name, or via `#probe(.default)`. + /// + /// - Note: This function is equivalent to calling ``runUpToProbe(_:sourceLocation:isolation:)`` with `ProbeIdentifier(effect: .root, name: .default)` + /// + /// - Throws: If the probe is unreachable, fails to install, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// + /// ```swift + /// try await withProbing { + /// await #probe("1") // id: "1" + /// print("1") + /// #Effect("first") { // <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// #Effect("second") { // <- SUSPENDED + /// await #probe() // id: "second.probe" + /// print("Not called until dispatch is given.") + /// await #probe() // id: "second.probe" + /// print("Not called until dispatch is given.") + /// } + /// await #probe() // id: "probe" <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } dispatchedBy: { dispatcher in + /// try await dispatcher.runUpToProbe() + /// // Always prints: + /// // 1 + /// } + /// ``` + /// public func runUpToProbe( sourceLocation: SourceLocation = #_sourceLocation, isolation: isolated (any Actor)? = #isolation @@ -106,6 +224,45 @@ extension ProbingDispatcher { // swiftlint:disable no_empty_block + /// Resumes execution of `body`, performing the minimal necessary work to complete the specified effect, and suspends `body` again before returning. + /// + /// - Parameters: + /// - id: Identifier of the effect, which is guaranteed to be completed when this function returns. + /// - includeDescendants: If `true`, all descendants of the specified effect will also be completed. + /// Defaults to `false`, meaning they will remain suspended in their current state. + /// + /// If any effect along the `id.path` has not yet been created, this function resumes its closest ancestor until the required effect is initialized, + /// suspending that ancestor at the next available probe. Once the specified effect (`id.path.last`) is created , it is resumed and run until completion. + /// + /// - Throws: If the effect is unreachable, fails to be created, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// + /// ```swift + /// try await withProbing { + /// await #probe("1") // id: "1" + /// print("1") + /// #Effect("first") { // <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// #Effect("second") { + /// await #probe("1") // id: "second.1" + /// print("second.1") + /// await #probe("2") // id: "second.2" + /// print("second.2") // <- COMPLETED + /// } + /// await #probe("2") // id: "2" <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } dispatchedBy: { dispatcher in + /// try await dispatcher.runUntilEffectCompleted("second.2") + /// // Always prints: + /// // 1 + /// // second.1 + /// // second.2 + /// } + /// ``` + /// + /// - Tip: Conceptually, this algorithm resembles [breadth-first search](https://en.wikipedia.org/wiki/Breadth-first_search), + /// where effects form the nodes of the execution tree. + /// public func runUntilEffectCompleted( _ id: EffectIdentifier, includingDescendants includeDescendants: Bool = false, @@ -125,6 +282,40 @@ extension ProbingDispatcher { ) } + /// Resumes execution of `body`, completing all remaining work within it, as well as all effects and their descendants, before returning. + /// + /// - Note: This function is equivalent to calling ``runUntilEffectCompleted(_:includingDescendants:sourceLocation:isolation:)`` + /// with `EffectIdentifier.root` and `includeDescendants` set to `true`. + /// + /// - Throws: If API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// + /// ```swift + /// try await withProbing { + /// await #probe("1") // id: "1" + /// print("1") + /// #Effect("first") { + /// print("first") // <- COMPLETED + /// } + /// #Effect("second") { + /// await #probe("1") // id: "second.1" + /// print("second.1") + /// await #probe("2") // id: "second.2" + /// print("second.2") // <- COMPLETED + /// } + /// await #probe("2") // id: "2" + /// print("2") // <- COMPLETED + /// } dispatchedBy: { dispatcher in + /// try await dispatcher.runUntilEverythingCompleted() + /// // Always prints: + /// // 1 + /// // 2 + /// // first + /// // second.1 + /// // second.2 + /// // Note: Exact order of prints may vary, as effects may execute concurrently. + /// } + /// ``` + /// public func runUntilEverythingCompleted( sourceLocation: SourceLocation = #_sourceLocation, isolation: isolated (any Actor)? = #isolation @@ -137,6 +328,36 @@ extension ProbingDispatcher { ) } + /// Resumes execution of `body`, completing all remaining work within it, while leaving effects suspended in their current state, before returning. + /// + /// - Note: This function is equivalent to calling ``runUntilEffectCompleted(_:includingDescendants:sourceLocation:isolation:)`` + /// with `EffectIdentifier.root` and `includeDescendants` set to `false`. + /// + /// - Throws: If API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// + /// ```swift + /// try await withProbing { + /// await #probe("1") // id: "1" + /// print("1") + /// #Effect("first") { // <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// #Effect("second") { // <- SUSPENDED + /// await #probe("1") // id: "second.1" + /// print("Not called until dispatch is given.") + /// await #probe("2") // id: "second.2" + /// print("Not called until dispatch is given.") + /// } + /// await #probe("2") // id: "2" + /// print("2") // <- COMPLETED + /// } dispatchedBy: { dispatcher in + /// try await dispatcher.runUntilExitOfBody() + /// // Always prints: + /// // 1 + /// // 2 + /// } + /// ``` + /// public func runUntilExitOfBody( sourceLocation: SourceLocation = #_sourceLocation, isolation: isolated (any Actor)? = #isolation @@ -153,7 +374,19 @@ extension ProbingDispatcher { } extension ProbingDispatcher { - + + /// Retrieves the return value of the specified effect, ensuring it has completed successfully. + /// + /// - Parameters: + /// - id: Identifier of the effect, which is expected to have completed successfully. + /// - successType: The expected type of the value returned by the effect. + /// + /// - Returns: The return value of the effect, if it completed successfully. + /// + /// - Important: This method does not resume the execution of `body`. You must ensure the effect has finished running through prior dispatches. + /// + /// - Throws: If the effect has not been completed successfully yet, was cancelled, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// public func getValue( fromEffect id: EffectIdentifier, as successType: Success.Type = Success.self, @@ -168,6 +401,18 @@ extension ProbingDispatcher { } } + /// Retrieves the return value of the specified effect, ensuring it has been cancelled. + /// + /// - Parameters: + /// - id: Identifier of the effect, which is expected to have been cancelled. + /// - successType: The expected type of the value returned by the effect. + /// + /// - Returns: The return value of the effect, if it was cancelled. + /// + /// - Important: This method does not resume the execution of `body`. You must ensure the effect has finished running through prior dispatches. + /// + /// - Throws: If the effect has not been cancelled yet, was completed successfully, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// public func getCancelledValue( fromEffect id: EffectIdentifier, as successType: Success.Type = Success.self, diff --git a/Sources/ProbeTesting/Dispatcher/RecordedError.swift b/Sources/ProbeTesting/Dispatcher/RecordedError.swift index 33636ee..939d730 100644 --- a/Sources/ProbeTesting/Dispatcher/RecordedError.swift +++ b/Sources/ProbeTesting/Dispatcher/RecordedError.swift @@ -11,19 +11,16 @@ import Testing internal struct RecordedError: Error { - let underlying: any Error + let underlying: any RecordableProbingError let sourceLocation: SourceLocation init( - underlying: some Error, + underlying: some RecordableProbingError, sourceLocation: SourceLocation ) { self.underlying = underlying self.sourceLocation = sourceLocation - - if underlying is any RecordableProbingError { - Issue.record(self, sourceLocation: sourceLocation) - } + Issue.record(self, sourceLocation: sourceLocation) } } diff --git a/Sources/ProbeTesting/Documentation.docc/ProbingDispatcher.md b/Sources/ProbeTesting/Documentation.docc/ProbingDispatcher.md new file mode 100644 index 0000000..32dc74a --- /dev/null +++ b/Sources/ProbeTesting/Documentation.docc/ProbingDispatcher.md @@ -0,0 +1,23 @@ +# ``ProbeTesting/ProbingDispatcher`` + +## Topics + +### Advancing Execution Through Probes + +- ``ProbingDispatcher/runUpToProbe(sourceLocation:isolation:)`` +- ``ProbingDispatcher/runUpToProbe(inEffect:sourceLocation:isolation:)`` +- ``ProbingDispatcher/runUpToProbe(_:sourceLocation:isolation:)`` + +### Awaiting Probed Body Completion + +- ``ProbingDispatcher/runUntilExitOfBody(sourceLocation:isolation:)`` + +### Awaiting Effects Completion + +- ``ProbingDispatcher/runUntilEffectCompleted(_:includingDescendants:sourceLocation:isolation:)`` +- ``ProbingDispatcher/runUntilEverythingCompleted(sourceLocation:isolation:)`` + +### Retrieving Effects Return Values + +- ``ProbingDispatcher/getValue(fromEffect:as:sourceLocation:)`` +- ``ProbingDispatcher/getCancelledValue(fromEffect:as:sourceLocation:)`` diff --git a/Sources/ProbeTesting/WithProbing/ProbingTerminatedError.swift b/Sources/ProbeTesting/WithProbing/ProbingTerminatedError.swift new file mode 100644 index 0000000..3e68594 --- /dev/null +++ b/Sources/ProbeTesting/WithProbing/ProbingTerminatedError.swift @@ -0,0 +1,17 @@ +// +// ProbingTerminatedError.swift +// Probing +// +// Created by Kamil Strzelecki on 11/05/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +internal struct ProbingTerminatedError: Error, CustomStringConvertible { + + var description: String { + """ + Probing was terminated because one of the dispatches couldn't be fulfilled. \ + See the issue reported in the `test` closure for more details. + """ + } +} diff --git a/Sources/ProbeTesting/WithProbing/WithProbing.swift b/Sources/ProbeTesting/WithProbing/WithProbing.swift index 85f3863..0d6a5ad 100644 --- a/Sources/ProbeTesting/WithProbing/WithProbing.swift +++ b/Sources/ProbeTesting/WithProbing/WithProbing.swift @@ -59,7 +59,11 @@ public func withProbing( } else { try? await bodyTask.value } - throw error + if error is RecordedError { + throw ProbingTerminatedError() + } else { + throw error + } } try await bodyTask.value @@ -91,7 +95,7 @@ private func makeTestTask( do { try coordinator.didCompleteTest() - } catch { + } catch let error as any RecordableProbingError { throw RecordedError( underlying: error, sourceLocation: sourceLocation diff --git a/Sources/Probing/Probes/Probe.swift b/Sources/Probing/Probes/Probe.swift index 53a75cb..f386b26 100644 --- a/Sources/Probing/Probes/Probe.swift +++ b/Sources/Probing/Probes/Probe.swift @@ -15,7 +15,7 @@ /// /// Think of a probe as a conditional “breakpoint” for tests, with two key differences: /// - It suspends only the current effect/task, not the entire program. -/// - It enables you to run test assertions at that point in execution, rather than debugging. +/// - It enables you to run test expectations at that point in execution, rather than debugging. /// /// When a probe is installed, it is uniquely identified at that point in execution by a ``ProbeIdentifier``. /// Subsequent probes with the same `name` in the same effect will be assigned the same identifier. From bf1f0a111ccbc9bd3c636b1128c3df647d2f9d23 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 11 May 2025 15:42:13 +0200 Subject: [PATCH 19/41] [SwiftFormat] Applied formatting --- Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift index 0646ff8..3c16921 100644 --- a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift +++ b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift @@ -72,7 +72,7 @@ extension ProbingDispatcher { extension ProbingDispatcher { // swiftlint:disable no_empty_block - + /// Resumes execution of `body`, performing the minimal necessary work to install the specified probe, and suspends `body` again before returning. /// /// - Parameter id: Identifier of the probe, which is guaranteed to be installed when this function returns. @@ -374,7 +374,7 @@ extension ProbingDispatcher { } extension ProbingDispatcher { - + /// Retrieves the return value of the specified effect, ensuring it has completed successfully. /// /// - Parameters: From bf7f8992f558ff7880c991e198dab390d39c0153 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 11 May 2025 15:48:39 +0200 Subject: [PATCH 20/41] - --- .spi.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.spi.yml b/.spi.yml index 973a240..3000c79 100644 --- a/.spi.yml +++ b/.spi.yml @@ -4,5 +4,3 @@ builder: - documentation_targets: - Probing - ProbeTesting - - DeeplyCopyable - - EquatableObject From 304e7aad4d09b1c311fbca62d7eceaa86f562ad7 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 11 May 2025 17:46:27 +0200 Subject: [PATCH 21/41] - --- Sources/Probing/TestingSupport/ProbingCoordinator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Probing/TestingSupport/ProbingCoordinator.swift b/Sources/Probing/TestingSupport/ProbingCoordinator.swift index 16f0b7b..0db4f44 100644 --- a/Sources/Probing/TestingSupport/ProbingCoordinator.swift +++ b/Sources/Probing/TestingSupport/ProbingCoordinator.swift @@ -51,7 +51,7 @@ extension ProbingCoordinator { @TaskLocal private static var _current: ProbingCoordinator? - internal static var current: ProbingCoordinator? { + static var current: ProbingCoordinator? { _current } From e6e9f70406fa9fe4371dfa02ccb1d6369815057e Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 10:17:45 +0200 Subject: [PATCH 22/41] - --- .../TestingSupport/ProbingCoordinator.swift | 40 ++++++------------- .../Probing/TestingSupport/ProbingState.swift | 30 +++++++++++--- .../Suites/TaskGroupTests.swift | 37 +++++++++++++++++ 3 files changed, 74 insertions(+), 33 deletions(-) create mode 100644 Tests/ProbeTestingTests/Suites/TaskGroupTests.swift diff --git a/Sources/Probing/TestingSupport/ProbingCoordinator.swift b/Sources/Probing/TestingSupport/ProbingCoordinator.swift index 0db4f44..a4bfb54 100644 --- a/Sources/Probing/TestingSupport/ProbingCoordinator.swift +++ b/Sources/Probing/TestingSupport/ProbingCoordinator.swift @@ -10,8 +10,8 @@ import Synchronization package final class ProbingCoordinator: Sendable { - private let state: Mutex let options: _ProbingOptions + private let state: Mutex package init( options: _ProbingOptions, @@ -20,14 +20,16 @@ package final class ProbingCoordinator: Sendable { column: Int ) { let initialState = ProbingState( + options: options, rootEffectLocation: ProbingLocation( fileID: fileID, line: line, column: column ) ) - self.state = .init(initialState) + self.options = options + self.state = .init(initialState) } deinit { @@ -172,16 +174,6 @@ extension ProbingCoordinator { extension ProbingCoordinator { - private func shouldProbeCurrentTask(state: ProbingState) -> Bool { - guard options.contains(.ignoreProbingInTasks) else { - return true - } - guard let taskID = Task.id else { - return false - } - return state.taskIDs.contains(taskID) - } - func installProbe( withName name: ProbeName, at location: ProbingLocation, @@ -201,6 +193,7 @@ extension ProbingCoordinator { ) guard state.isTracking, + state.shouldProbeCurrentTask(), let childEffect = state.childEffect(withID: id.effect) else { continuation.resume() @@ -224,11 +217,6 @@ extension ProbingCoordinator { ) } - guard shouldProbeCurrentTask(state: state) else { - continuation.resume() - return - } - state.preconditionTestPhase(\.isPaused) childEffect.probe(using: continuation) } @@ -256,15 +244,15 @@ extension ProbingCoordinator { return } - guard !state.testPhase.isRunning else { - throw ProbingErrors.EffectAPIMisuse(backtrace: backtrace) - } - - guard shouldProbeCurrentTask(state: state) else { + guard state.shouldProbeCurrentTask() else { shouldProbe = false return } + guard !state.testPhase.isRunning else { + throw ProbingErrors.EffectAPIMisuse(backtrace: backtrace) + } + state.preconditionTestPhase(\.isPaused) try state.rootEffect.createChild(withBacktrace: backtrace) } @@ -282,9 +270,7 @@ extension ProbingCoordinator { ) async { await withCheckedContinuation(isolation: isolation) { underlying in state.resumeTestIfPossible { state in - if options.contains(.ignoreProbingInTasks) { - state.registerCurrentTask() - } + state.registerCurrentTaskIfNeeded() guard state.isTracking, let childEffect = state.childEffect(withID: id) @@ -343,9 +329,7 @@ extension ProbingCoordinator { testPhasePrecondition precondition: TestPhase.Precondition ) { state.resumeTestIfPossible { state in - if options.contains(.ignoreProbingInTasks) { - state.unregisterCurrentTask() - } + state.unregisterCurrentTaskIfNeeded() guard state.isTracking, let childEffect = state.childEffect(withID: id) diff --git a/Sources/Probing/TestingSupport/ProbingState.swift b/Sources/Probing/TestingSupport/ProbingState.swift index 7c715be..9deb3a1 100644 --- a/Sources/Probing/TestingSupport/ProbingState.swift +++ b/Sources/Probing/TestingSupport/ProbingState.swift @@ -10,7 +10,9 @@ import Synchronization internal struct ProbingState { + let options: _ProbingOptions let rootEffect: EffectState + private(set) var testPhase = TestPhase.scheduled private(set) var taskIDs = Set() private(set) var errors = [any Error]() @@ -19,7 +21,11 @@ internal struct ProbingState { !testPhase.isCompleted && errors.isEmpty } - init(rootEffectLocation: ProbingLocation) { + init( + options: _ProbingOptions, + rootEffectLocation: ProbingLocation + ) { + self.options = options self.rootEffect = .root(location: rootEffectLocation) } } @@ -84,21 +90,35 @@ extension ProbingState { extension ProbingState { - mutating func registerCurrentTask() { - guard let taskID = Task.id else { + mutating func registerCurrentTaskIfNeeded() { + guard options.contains(.ignoreProbingInTasks), + let taskID = Task.id + else { return } let result = taskIDs.insert(taskID) precondition(result.inserted, "Task was already registered.") } - mutating func unregisterCurrentTask() { - guard let taskID = Task.id else { + mutating func unregisterCurrentTaskIfNeeded() { + guard options.contains(.ignoreProbingInTasks), + let taskID = Task.id + else { return } let result = taskIDs.remove(taskID) precondition(result != nil, "Task was never registered.") } + + func shouldProbeCurrentTask() -> Bool { + guard options.contains(.ignoreProbingInTasks) else { + return true + } + guard let taskID = Task.id else { + return false + } + return taskIDs.contains(taskID) + } } extension ProbingState { diff --git a/Tests/ProbeTestingTests/Suites/TaskGroupTests.swift b/Tests/ProbeTestingTests/Suites/TaskGroupTests.swift new file mode 100644 index 0000000..1248fef --- /dev/null +++ b/Tests/ProbeTestingTests/Suites/TaskGroupTests.swift @@ -0,0 +1,37 @@ +// +// TaskGroupTests.swift +// Probing +// +// Created by Kamil Strzelecki on 12/05/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import ProbeTesting +@testable import Probing +import Testing + +internal struct TaskGroupTests { + + private let childrenCount = 100 + + @Test + func testCollectingChildResults() async throws { + try await withProbing(options: .ignoreProbingInTasks) { + await withTaskGroup(of: Void.self) { group in + for _ in 0 ..< childrenCount { + group.addTask { + await #probe() + } + } + for await _ in group { + await #probe() + } + } + } dispatchedBy: { dispatcher in + for _ in 0 ..< childrenCount { + try await dispatcher.runUpToProbe() + } + try await dispatcher.runUntilExitOfBody() + } + } +} From 5cad705f787963048f78bb935e9838304c1af9ae Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 10:25:05 +0200 Subject: [PATCH 23/41] - --- Tests/ProbeTestingTests/Suites/TaskGroupTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ProbeTestingTests/Suites/TaskGroupTests.swift b/Tests/ProbeTestingTests/Suites/TaskGroupTests.swift index 1248fef..9bb8ffe 100644 --- a/Tests/ProbeTestingTests/Suites/TaskGroupTests.swift +++ b/Tests/ProbeTestingTests/Suites/TaskGroupTests.swift @@ -16,7 +16,7 @@ internal struct TaskGroupTests { @Test func testCollectingChildResults() async throws { - try await withProbing(options: .ignoreProbingInTasks) { + try await withProbing { await withTaskGroup(of: Void.self) { group in for _ in 0 ..< childrenCount { group.addTask { From a367f4ae6e3341e90b1e4f63ee783b8de2faf925 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 10:41:17 +0200 Subject: [PATCH 24/41] - --- .../Documentation.docc/EffectIdentifier.md | 2 +- .../Documentation.docc/ProbeIdentifier.md | 2 +- Sources/Probing/Documentation.docc/Probing.md | 26 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Sources/Probing/Documentation.docc/EffectIdentifier.md b/Sources/Probing/Documentation.docc/EffectIdentifier.md index 7b17c56..5fb5069 100644 --- a/Sources/Probing/Documentation.docc/EffectIdentifier.md +++ b/Sources/Probing/Documentation.docc/EffectIdentifier.md @@ -2,7 +2,7 @@ ## Topics -### Creating Identifiers For Lookup During Tests +### Creating Identifiers for Lookup During Tests - ``init(path:)`` - ``init(arrayLiteral:)`` diff --git a/Sources/Probing/Documentation.docc/ProbeIdentifier.md b/Sources/Probing/Documentation.docc/ProbeIdentifier.md index c1f9446..abf9f88 100644 --- a/Sources/Probing/Documentation.docc/ProbeIdentifier.md +++ b/Sources/Probing/Documentation.docc/ProbeIdentifier.md @@ -2,7 +2,7 @@ ## Topics -### Creating Identifiers For Lookup During Tests +### Creating Identifiers for Lookup During Tests - ``init(effect:name:)`` - ``init(rawValue:)`` diff --git a/Sources/Probing/Documentation.docc/Probing.md b/Sources/Probing/Documentation.docc/Probing.md index 4b6cd1e..54f7f49 100644 --- a/Sources/Probing/Documentation.docc/Probing.md +++ b/Sources/Probing/Documentation.docc/Probing.md @@ -46,11 +46,37 @@ standard Swift `Task` instances, so that your production code will not be affect - SeeAlso: Refer to the `ProbeTesting` documentation for details on accessing probes and controlling effects during tests. +## Integration + +To enable testing with `ProbeTesting`, you only need to adopt `Probing` APIs in the bodies of the functions +whose execution you want to control. Neither probes nor effects change the runtime characteristics of your code. +By default, probes are completely stripped from the application binary in release builds. +Effects, on the other hand, are replaced with regular Swift `Task` instances. + +```swift +func callWithTask() { + Task { + // Not controllable with `ProbeTesting`, but runs normally. + await callWithProbing() + } +} + +func callWithProbing() await { + // Controllable with `ProbeTesting` when invoked from the `body` + // of the `withProbing` function, either directly or from an effect. + await #probe() + #Effect("test") { ... } +} +``` + +- SeeAlso: Refer to the article to learn more on how `Probing` interacts with Swift Concurrency. + ## Topics ### Getting Started - +- ### Installing Probes From 909e1d6ba7d852651be6fce99db24136e29630e0 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 11:40:40 +0200 Subject: [PATCH 25/41] - --- .spi.yml | 2 ++ Package.swift | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/.spi.yml b/.spi.yml index 3000c79..973a240 100644 --- a/.spi.yml +++ b/.spi.yml @@ -4,3 +4,5 @@ builder: - documentation_targets: - Probing - ProbeTesting + - DeeplyCopyable + - EquatableObject diff --git a/Package.swift b/Package.swift index c5a44ba..196075e 100644 --- a/Package.swift +++ b/Package.swift @@ -68,6 +68,14 @@ let package = Package( .library( name: "ProbeTesting", targets: ["ProbeTesting"] + ), + .library( + name: "DeeplyCopyable", + targets: ["DeeplyCopyable"] + ), + .library( + name: "EquatableObject", + targets: ["EquatableObject"] ) ], dependencies: [ From 166cfbd7410fb0d295897460c8d11d26125422dd Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 11:41:39 +0200 Subject: [PATCH 26/41] Added ProbingOptions docs --- .../Documentation.docc/ProbingOptions.md | 13 +++++++++++ .../WithProbing/ProbingOptions.swift | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 Sources/ProbeTesting/Documentation.docc/ProbingOptions.md diff --git a/Sources/ProbeTesting/Documentation.docc/ProbingOptions.md b/Sources/ProbeTesting/Documentation.docc/ProbingOptions.md new file mode 100644 index 0000000..53006b1 --- /dev/null +++ b/Sources/ProbeTesting/Documentation.docc/ProbingOptions.md @@ -0,0 +1,13 @@ +# ``ProbeTesting/ProbingOptions`` + +## Topics + +### Available Options + +- ``ignoreProbingInTasks`` +- ``attemptProbingInTasks`` + +### OptionSet Implementations + +- ``rawValue`` +- ``init(rawValue:)`` diff --git a/Sources/ProbeTesting/WithProbing/ProbingOptions.swift b/Sources/ProbeTesting/WithProbing/ProbingOptions.swift index 4e5832a..0626a68 100644 --- a/Sources/ProbeTesting/WithProbing/ProbingOptions.swift +++ b/Sources/ProbeTesting/WithProbing/ProbingOptions.swift @@ -8,6 +8,10 @@ import Probing +/// Options that change the testability of probes and effects created from `Task` APIs. +/// +/// - SeeAlso: Refer to `Probing` documentation to learn more on interactions with Swift Concurrency. +/// public struct ProbingOptions: OptionSet, Sendable { public let rawValue: Int @@ -30,6 +34,24 @@ extension ProbingOptions { extension ProbingOptions { + /// Treats probes and effects created from `Task` APIs as if they were invoked + /// from the top effect on the execution stack. + /// + /// - Warning: Using this option may lead to reporting of API misuses. + /// + /// When using this option, you must ensure that no part of your code executes concurrently with the `test` closure + /// of the ``withProbing(options:sourceLocation:isolation:of:dispatchedBy:)`` function. + /// + /// In the simplest case, this means you would need to serialize calls to `Task` APIs and `await` each of them sequentially. + /// public static let attemptProbingInTasks = Self(.attemptProbingInTasks) + + /// Ignores probes and effects created from `Task` APIs. + /// + /// - Note: This is the default option and it's recommended in most cases, as it avoids reporting API misuses. + /// + /// When this option is enabled, probes created within `Task` APIs resume immediately, + /// and effects run as standard Swift `Task` instances, without any custom scheduling. + /// public static let ignoreProbingInTasks = Self(.ignoreProbingInTasks) } From 8dd7dc02eb017e59e39e1dea875627366e856bae Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 13:20:50 +0200 Subject: [PATCH 27/41] Added withProbing docs --- .../WithProbing/ProbingOptions.swift | 4 ++ .../WithProbing/WithProbing.swift | 57 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/Sources/ProbeTesting/WithProbing/ProbingOptions.swift b/Sources/ProbeTesting/WithProbing/ProbingOptions.swift index 0626a68..451642f 100644 --- a/Sources/ProbeTesting/WithProbing/ProbingOptions.swift +++ b/Sources/ProbeTesting/WithProbing/ProbingOptions.swift @@ -44,6 +44,8 @@ extension ProbingOptions { /// /// In the simplest case, this means you would need to serialize calls to `Task` APIs and `await` each of them sequentially. /// + /// - SeeAlso: Refer to `Probing` documentation to learn more on interactions with Swift Concurrency. + /// public static let attemptProbingInTasks = Self(.attemptProbingInTasks) /// Ignores probes and effects created from `Task` APIs. @@ -53,5 +55,7 @@ extension ProbingOptions { /// When this option is enabled, probes created within `Task` APIs resume immediately, /// and effects run as standard Swift `Task` instances, without any custom scheduling. /// + /// - SeeAlso: Refer to `Probing` documentation to learn more on interactions with Swift Concurrency. + /// public static let ignoreProbingInTasks = Self(.ignoreProbingInTasks) } diff --git a/Sources/ProbeTesting/WithProbing/WithProbing.swift b/Sources/ProbeTesting/WithProbing/WithProbing.swift index 0d6a5ad..155d96f 100644 --- a/Sources/ProbeTesting/WithProbing/WithProbing.swift +++ b/Sources/ProbeTesting/WithProbing/WithProbing.swift @@ -9,6 +9,63 @@ import Probing import Testing +/// Enables control over probes and effects, making asynchronous code testable. +/// +/// - Parameters: +/// - options: Controls testability of probes and effects created from `Task` APIs. Defaults to ``ProbingOptions/ignoreProbingInTasks``. +/// - body: A closure containing the code under test. Probes and effects invoked from this closure become controllable in `test`. +/// - test: A closure used to control the execution of `body` via ``ProbingDispatcher``, verify expectations, and interact with the system under test. +/// +/// - Throws: Rethrows any error thrown by either the `body` or `test` closure. +/// +/// - Returns: The result returned by the `body` closure. +/// +/// This function always begins by invoking `test`, suspending execution of `body` until the first dispatch is given. +/// Before returning, it awaits the completion of both `body` and `test`. +/// +/// ```swift +/// try await withProbing { +/// print("Body") +/// } dispatchedBy: { dispatcher in +/// // Noting is printed yet at this point +/// print("Test") +/// } +/// // Always prints: +/// // Test +/// // Body +/// ``` +/// +/// - Note: This does **not** guarantee that all effects created by the `body` invocation are completed when `withProbing` returns. +/// To ensure this, call the ``ProbingDispatcher/runUntilEverythingCompleted(sourceLocation:isolation:)`` method +/// on the dispatcher at the appropriate point within `test`. +/// +/// `ProbeTesting` guarantees that as long as your code uses `#Effect` macros instead of the `Task` APIs, +/// no part of code initiated by the `body` invocation will run concurrently with `test`. This is enforced by the ``ProbingDispatcher``, +/// which lets you deterministically advance execution to the desired state. This model allows you to reliably check expectations +/// and interact with the system under test. For example: +/// +/// ```swift +/// try await withProbing { +/// await viewModel.load() +/// } dispatchedBy: { dispatcher in +/// #expect(viewModel.isLoading == false) +/// #expect(viewModel.download == nil) +/// +/// try await dispatcher.runUpToProbe() +/// #expect(viewModel.isLoading == true) +/// #expect(viewModel.download == nil) +/// +/// downloaderMock.shouldFailDownload = false +/// try await dispatcher.runUntilExitOfBody() +/// #expect(viewModel.isLoading == false) +/// #expect(viewModel.download != nil) +/// +/// #expect(viewModel.prefetchedData == nil) +/// try await dispatcher.runUntilEffectCompleted("backgroundFetch") +/// #expect(viewModel.prefetchedData != nil) +/// } +/// ``` +/// public func withProbing( options: ProbingOptions = .ignoreProbingInTasks, sourceLocation: SourceLocation = #_sourceLocation, From c730fa0d7854f6957afb84eec9203be6ac1b735c Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 14:32:20 +0200 Subject: [PATCH 28/41] Added Examples to ProbeTesting --- .../Documentation.docc/Examples.md | 211 ++++++++++++++++++ .../Probing/Documentation.docc/Examples.md | 6 +- 2 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 Sources/ProbeTesting/Documentation.docc/Examples.md diff --git a/Sources/ProbeTesting/Documentation.docc/Examples.md b/Sources/ProbeTesting/Documentation.docc/Examples.md new file mode 100644 index 0000000..a901415 --- /dev/null +++ b/Sources/ProbeTesting/Documentation.docc/Examples.md @@ -0,0 +1,211 @@ +# Examples + +Explore simplified examples from the `ProbingPlayground` sample project to see `ProbeTesting` integration in action. + +## Download the Sample Project + +You can download the `ProbingPlayground` sample project from its [GitHub page](https://github.com/NSFatalError/ProbingPlayground). + +## Running Through Probes in Async Functions + +To verify that an object has changed state while an `async` function is running, +you can suspend the execution of `body` at declared probes using the ``ProbingDispatcher/runUpToProbe(sourceLocation:isolation:)`` +method. You don't need to declare a probe at the end of the `body`, since the +``ProbingDispatcher/runUntilExitOfBody(sourceLocation:isolation:)`` method suspends execution there for you: + +```swift +@Test +func testUploadingImage() async throws { + try await withProbing { + await viewModel.uploadImage(ImageMock()) + } dispatchedBy: { dispatcher in + #expect(viewModel.uploadState == nil) + + try await dispatcher.runUpToProbe() + #expect(uploader.uploadImageCallsCount == 0) + #expect(viewModel.uploadState == .uploading) + + try await dispatcher.runUpToProbe() + #expect(uploader.uploadImageCallsCount == 1) + #expect(viewModel.uploadState == .success) + + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.uploadState == nil) + } +} +``` + +This lets you reliably verify the `uploadState`, reproducing the exact sequence of events users experience in your app. + +## Controlling Mocks During Tests + +When testing asynchronous functions or side effects, you often have to set up mocks far in advance. +This can be problematic, as it decouples the setup from the actual invocation, obscuring its purpose. + +With `ProbeTesting`, you can control your objects directly in the `test` closure, +immediately before running a dispatch that triggers their usage. For example, you can yield elements to an `AsyncSequence` +or finish it at precise moments: + +```swift +@Test +func testUpdatingLocation() async throws { + try await withProbing { + await viewModel.beginUpdatingLocation() + } dispatchedBy: { dispatcher in + #expect(viewModel.locationState == nil) + + locationProvider.continuation.yield(.init(location: .sanFrancisco)) + try await dispatcher.runUpToProbe() + #expect(viewModel.locationState == .near) + + locationProvider.continuation.yield(.init(location: .init(latitude: 0, longitude: 0))) + try await dispatcher.runUpToProbe() + #expect(viewModel.locationState == .far) + + locationProvider.continuation.yield(.init(location: .sanFrancisco)) + try await dispatcher.runUpToProbe() + #expect(viewModel.locationState == .near) + + locationProvider.continuation.yield(.init(location: nil, authorizationDenied: true)) + try await dispatcher.runUpToProbe() + #expect(viewModel.locationState == .error) + + locationProvider.continuation.yield(.init(location: .sanFrancisco)) + try await dispatcher.runUpToProbe() + #expect(viewModel.locationState == .near) + + locationProvider.continuation.finish(throwing: ErrorMock()) + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.locationState == .error) + } +} +``` + +This allows you to reliably verify the `locationState` before entering the loop, after each iteration, +or after an error is thrown. + +## Controlling Execution of Effects + +To control the execution of effects, you have several options, starting with the +``ProbingDispatcher/runUntilEffectCompleted(_:includingDescendants:sourceLocation:isolation:)`` method. + +You can also retrieve the value returned by an effect using ``ProbingDispatcher/getValue(fromEffect:as:sourceLocation:)``, +or check if it was cancelled using ``ProbingDispatcher/getCancelledValue(fromEffect:as:sourceLocation:)``. + +None of these methods require you to store references to effects. `ProbeTesting` can identify and access them during testing: + +```swift +@Test +func testDownloadingImage() async throws { + try await withProbing { + await viewModel.downloadImage() + } dispatchedBy: { dispatcher in + await #expect(viewModel.downloadState == nil) + + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.downloadState?.isDownloading == true) + + try await dispatcher.runUntilEffectCompleted("low") + #expect(viewModel.downloadState?.quality == .low) + + try await dispatcher.runUntilEffectCompleted("high") + #expect(viewModel.downloadState?.quality == .high) + } +} +``` + +This enables you to reliably verify the `downloadState` before downloads start, +as well as verify its progression as effects complete. + +## Recreating Runtime Scenarios + +Even with full test coverage reported by Xcode, you can’t be certain that all execution paths have been exercised. +Each await introduces a suspension point where another side effect - isolated to the same actor - might interleave, +altering the sequence of events. + +By flattening the execution tree into a series of dispatches, `ProbeTesting` allows you to recreate specific runtime scenarios +that users might experience. Previously, this level of control and expressivity was nearly impossible to achieve in tests: + +```swift +@Test +func testDownloadingImageWhenLowQualityDownloadFailsFirst() async throws { + try await withProbing { + viewModel.downloadImage() + } dispatchedBy: { dispatcher in + #expect(viewModel.downloadState == nil) + + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.downloadState?.isDownloading == true) + + downloader.shouldFailDownload = true + try await dispatcher.runUntilEffectCompleted("low") + #expect(viewModel.downloadState?.isDownloading == true) + + downloader.shouldFailDownload = false + try await dispatcher.runUntilEffectCompleted("high") + #expect(viewModel.downloadState?.quality == .high) + } +} + +@Test +func testDownloadingImageWhenHighQualityDownloadFailsFirst() async throws { + // ... +} + +@Test +func testDownloadingImageWhenLowQualityDownloadFailsAfterHighQualityDownloadSucceeds() async throws { + try await withProbing { + viewModel.downloadImage() + } dispatchedBy: { dispatcher in + #expect(viewModel.downloadState == nil) + + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.downloadState?.isDownloading == true) + + try await dispatcher.runUntilEffectCompleted("high") + #expect(viewModel.downloadState?.quality == .high) + + downloader.shouldFailDownload = true + try await dispatcher.runUntilEffectCompleted("low") + try dispatcher.getCancelledValue(fromEffect: "low", as: Void.self) + #expect(viewModel.downloadState?.quality == .high) + } +} + +@Test +func testDownloadingImageWhenHighQualityDownloadFailsAfterLowQualityDownloadSucceeds() async throws { + // ... +} + +@Test +func testDownloadingImageRepeatedly() async throws { + try await withProbing { + viewModel.downloadImage() + viewModel.downloadImage() + } dispatchedBy: { dispatcher in + #expect(viewModel.downloadState == nil) + + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.downloadState?.isDownloading == true) + + try await dispatcher.runUntilEffectCompleted("low0") + try dispatcher.getCancelledValue(fromEffect: "low0", as: Void.self) + #expect(viewModel.downloadState?.isDownloading == true) + + try await dispatcher.runUntilEffectCompleted("high0") + try dispatcher.getCancelledValue(fromEffect: "high0", as: Void.self) + #expect(viewModel.downloadState?.isDownloading == true) + + try await dispatcher.runUntilEffectCompleted("low1") + #expect(viewModel.downloadState?.quality == .low) + + try await dispatcher.runUntilEffectCompleted("high1") + #expect(viewModel.downloadState?.quality == .high) + } +} + +// ... +``` + +It’s up to you to decide how much granularity your tests require, yet with `ProbeTesting` +you have full control over execution flow. diff --git a/Sources/Probing/Documentation.docc/Examples.md b/Sources/Probing/Documentation.docc/Examples.md index 2b76385..207922c 100644 --- a/Sources/Probing/Documentation.docc/Examples.md +++ b/Sources/Probing/Documentation.docc/Examples.md @@ -30,7 +30,7 @@ func uploadImage(_ item: ImageItem) async { } ``` -This lets you reliably check the `uploadState` before the image is processed and uploaded, +This lets you reliably verify the `uploadState` before the image is processed and uploaded, and before the presentation timer fires, reproducing the exact sequence of events users experience in your app. Similarly, you can install probes after each iteration of an `AsyncSequence` loop: @@ -59,7 +59,7 @@ func updateLocation() async { } ``` -This allows you to reliably verify `locationState` before entering the loop, +This allows you to reliably verify the `locationState` before entering the loop, after each iteration, or after an error is thrown. ## Probing with Tasks @@ -97,7 +97,7 @@ private func downloadImage(withQuality quality: ImageQuality) { } ``` -This enables you to reliably check the `downloadState` before the downloads start. +This enables you to reliably verify the `downloadState` before the downloads start. You can also precisely recreate various runtime scenarios, putting your objects into different states that users might experience, such as: - low quality download succeeded, while high quality download is pending From f335a82ea281bcfce4a154961b38e2e20589ff61 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 14:52:33 +0200 Subject: [PATCH 29/41] Completed ProbeTesting docs --- .../Documentation.docc/Examples.md | 2 +- .../Documentation.docc/ProbeTesting.md | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 Sources/ProbeTesting/Documentation.docc/ProbeTesting.md diff --git a/Sources/ProbeTesting/Documentation.docc/Examples.md b/Sources/ProbeTesting/Documentation.docc/Examples.md index a901415..f18d4b5 100644 --- a/Sources/ProbeTesting/Documentation.docc/Examples.md +++ b/Sources/ProbeTesting/Documentation.docc/Examples.md @@ -37,7 +37,7 @@ func testUploadingImage() async throws { This lets you reliably verify the `uploadState`, reproducing the exact sequence of events users experience in your app. -## Controlling Mocks During Tests +## Interacting with Mocks During Tests When testing asynchronous functions or side effects, you often have to set up mocks far in advance. This can be problematic, as it decouples the setup from the actual invocation, obscuring its purpose. diff --git a/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md b/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md new file mode 100644 index 0000000..a13f4e4 --- /dev/null +++ b/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md @@ -0,0 +1,49 @@ +# ``ProbeTesting`` + +## Overview + +`ProbeTesting` gives you precise control over the execution of asynchronous code, +making it fully predictable and testable. It also lets you reason about the flow +from the user’s perspective by reducing it to a clear, step-by-step sequence of dispatches. + +To use these features, wrap your test code with the +``withProbing(options:sourceLocation:isolation:of:dispatchedBy:)`` function: + +```swift +@Test +func testLoading() { + try await withProbing { + await viewModel.load() + } dispatchedBy: { dispatcher in + #expect(viewModel.isLoading == false) + #expect(viewModel.download == nil) + + try await dispatcher.runUpToProbe() + #expect(viewModel.isLoading == true) + #expect(viewModel.download == nil) + + downloaderMock.shouldFailDownload = false + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.isLoading == false) + #expect(viewModel.download != nil) + + #expect(viewModel.prefetchedData == nil) + try await dispatcher.runUntilEffectCompleted("backgroundFetch") + #expect(viewModel.prefetchedData != nil) + } +} +``` + +- SeeAlso: Refer to the `Probing` documentation for details on how to make your code controllable during tests. + +## Topics + +### Getting Started + +- + +### Enabling Probing In Tests + +- ``withProbing(options:sourceLocation:isolation:of:dispatchedBy:)`` +- ``ProbingDispatcher`` +- ``ProbingOptions`` From d44e9677ec6cd9b354d8b1a9aebde3249177c2bd Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 15:18:33 +0200 Subject: [PATCH 30/41] Added Concurrency article --- .../Probing/Documentation.docc/Concurrency.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 Sources/Probing/Documentation.docc/Concurrency.md diff --git a/Sources/Probing/Documentation.docc/Concurrency.md b/Sources/Probing/Documentation.docc/Concurrency.md new file mode 100644 index 0000000..57c3087 --- /dev/null +++ b/Sources/Probing/Documentation.docc/Concurrency.md @@ -0,0 +1,124 @@ +# Interactions with Swift Concurrency + +Learn how `Probing` interacts with Swift’s `Task` APIs and what limitations currently exist. + +## Installing Probes and Creating Effects from Tasks + +`Task.init`, `TaskGroup.addTask`, and `async let` declarations all start executing concurrent work immediately, +while unblocking the caller. `Probing` has no control over asynchronous tasks created through these APIs, +nor any visibility into their potential influence on your application’s state. + +Because of this, `ProbeTesting` cannot guarantee deterministic execution when such APIs are used. +By default, it treats probes and effects created from these APIs as if they were run outside the test scope. +This means: +- Probes resume immediately, without suspension. +- Effects behave like regular Swift `Task` instances and are not subjected to any scheduling. + +Nesting probes or effects within tasks does not make them testable again. For example: + +```swift +func example() async { + await #probe() + + #Effect("first") { + await #probe() + // All probes and effects are testable up to this point. + // State changes until here are predictable and observable. + + Task { + // Probes or effects nested here are not testable (by default). + // State changes inside a task cannot be reliably observed. + await #probe() + + #Effect("second") { + await #probe() + // ... + } + } + + // All probes and effects are testable from this point again. + // State changes here are predictable, unless they race + // with mutations from the task above. + await #probe() + // ... + } +} +``` + +- Important: Wherever applicable, use effect macros instead of `Task` APIs to ensure full test support. + +That said, calling `Probing` APIs from within `Task` APIs is completely safe — and vice versa. +Using `Task` APIs only affects testing support - it does not alter the runtime behavior of your app. + +It is fully supported and recommended to use `Task` APIs in non-testable contexts, even if those tasks invoke `Probing` APIs. +For example, you can continue using `Task.init` in a SwiftUI `View`: + +```swift +@Observable +final class MyViewModel { + private(set) var isDownloading = false + + func download() async { + isDownloading = true + await #probe() + // ... + } +} + +struct MyView: View { + @State private var viewModel = MyViewModel() + + var body: some View { + Button("Download") { + Task { + await viewModel.download() + } + } + // ... + } +} +``` + +- SeeAlso: Refer to the `ProbeTesting.ProbingOptions.attemptProbingFromTasks` documentation +if you need to control tasks using the `Probing` library. As the name of the option suggests, this is not always possible, +and is generally discouraged. + +## Parallel Processing + +While Probing offers macro equivalents to `Task.init`, it currently lacks counterparts for +`TaskGroup.addTask` functions. This is intentional - APIs like `withTaskGroup` are meant to introduce parallelism to your code. +Since one of `Probing`’s goals is to help you reason about execution flow as a sequence of events, +flattening parallel hierarchies provides limited value in such cases. + +That said, you can still use `Probing` before parallel processing begins, after it ends, and while collecting the results +from the group’s child tasks. For example: + +```swift +// ... +await #probe() + +await withTaskGroup(of: Void.self) { group in + let count = 100 + progress = 0.0 + + for _ in 0 ..< count { + group.addTask { ... } + } + + for await task in group { + progress += 1 / Double(count) + await #probe() + } +} + +await #probe() +// ... +``` + +To support `Probing` with `async let` declarations, you can leverage the ``Effect/value-677pr`` property of effects. +It allows them to execute concurrently while remaining controllable from tests: + +```swift +let effect = #Effect("itJustWorks") { ... } +async let value = effect.value +``` From d7c4662340e16ce92aae6f5296c94d98da5ab19d Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 15:26:52 +0200 Subject: [PATCH 31/41] - --- Sources/ProbeTesting/Documentation.docc/ProbeTesting.md | 2 +- Sources/Probing/Documentation.docc/Concurrency.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md b/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md index a13f4e4..37ac61e 100644 --- a/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md +++ b/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md @@ -11,7 +11,7 @@ To use these features, wrap your test code with the ```swift @Test -func testLoading() { +func testLoading() async throws { try await withProbing { await viewModel.load() } dispatchedBy: { dispatcher in diff --git a/Sources/Probing/Documentation.docc/Concurrency.md b/Sources/Probing/Documentation.docc/Concurrency.md index 57c3087..60a9f22 100644 --- a/Sources/Probing/Documentation.docc/Concurrency.md +++ b/Sources/Probing/Documentation.docc/Concurrency.md @@ -48,7 +48,7 @@ func example() async { - Important: Wherever applicable, use effect macros instead of `Task` APIs to ensure full test support. That said, calling `Probing` APIs from within `Task` APIs is completely safe — and vice versa. -Using `Task` APIs only affects testing support - it does not alter the runtime behavior of your app. +Using `Task` APIs only affects testing support, as it does not alter the runtime behavior of your app. It is fully supported and recommended to use `Task` APIs in non-testable contexts, even if those tasks invoke `Probing` APIs. For example, you can continue using `Task.init` in a SwiftUI `View`: @@ -85,8 +85,8 @@ and is generally discouraged. ## Parallel Processing -While Probing offers macro equivalents to `Task.init`, it currently lacks counterparts for -`TaskGroup.addTask` functions. This is intentional - APIs like `withTaskGroup` are meant to introduce parallelism to your code. +While `Probing` offers macro equivalents to `Task.init`, it currently lacks counterparts for `TaskGroup.addTask` +functions. This is intentional, as APIs like `withTaskGroup` are meant to introduce parallelism to your code. Since one of `Probing`’s goals is to help you reason about execution flow as a sequence of events, flattening parallel hierarchies provides limited value in such cases. From 7b7be37e958efefed37e3e185cc42542ffea6f18 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 15:58:20 +0200 Subject: [PATCH 32/41] Added Readme --- README.md | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/README.md b/README.md index 50e9619..36f0224 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,122 @@

+ +Breakpoints for Swift Testing - precise control over side effects and execution suspension at any point. + +## What Problem Probing Solves? + +Testing asynchronous code remains challenging, even with Swift Concurrency and Swift Testing. +Some of the persistent difficulties include: + +- **Hidden states**: When invoking methods on objects, often with complex dependencies between them, it’s not enough +to inspect just the final output of the function. Inspecting the internal state changes during execution, such as loading states in view models, +is equally important but notoriously difficult. +- **Non-determinism**: `Task`s run concurrently and may complete in different orders each time, leading to unpredictable states. +Even with full code coverage, there’s no guarantee that all execution paths have been reached, and it's' difficult to reason about what remains untested. +- **Limited runtime control**: Once an asynchronous function is running, influencing its behavior becomes nearly impossible. +This limitation pushes developers to rely on ahead-of-time setups, like intricate mocks, which add complexity and reduce clarity of the test. + +Over the years, the Swift community has introduced a number of tools to address these challenges, each with its own strengths: + +- **Quick/Nimble**: Polling with the designated matchers allows checking changes to hidden states, +but it can lead to flaky tests and is generally not concurrency-safe. +- **Combine/RxSwift**: Reactive paradigms are powerful, but they can be difficult to set up and may introduce unnecessary abstraction, +especially now that `AsyncSequence` covers many use cases natively. +- **ComposableArchitecture**: Provides a robust approach for testing UI logic, but it’s tightly coupled +to its own architectural patterns and isn’t suited for other application layers. + +These tools have pushed the ecosystem forward and work well within their intended contexts. +Still, none provide a lightweight, general-purpose way to tackle all of the listed problems that embraces the Swift Concurrency model. +That's why I have designed and developed `Probing`. + +## How Probing Works? + +The `Probing` package consists of two main modules: +- `Probing`, which you add as a dependency to the targets you want to test +- `ProbeTesting`, which you add as a dependency to your test targets + +With `Probing`, you can define **probes** — suspension points typically placed after a state change, +conceptually similar to breakpoints, but accessible and targetable from your tests. +You can also define **effects**, which make `Task` instances controllable and predictable. + +Then, with the help of `ProbeTesting`, you write a sequence of **dispatches** that advance your program to a desired state. +This flattens the execution hierarchy of side effects, allowing you to write tests from the user’s perspective, +as a clear and deterministic flow of events: + +```swift +@Test +func testLoading() async throws { + try await withProbing { + await viewModel.load() + } dispatchedBy: { dispatcher in + #expect(viewModel.isLoading == false) + #expect(viewModel.download == nil) + + try await dispatcher.runUpToProbe() + #expect(viewModel.isLoading == true) + #expect(viewModel.download == nil) + + downloaderMock.shouldFailDownload = false + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.isLoading == false) + #expect(viewModel.download != nil) + + #expect(viewModel.prefetchedData == nil) + try await dispatcher.runUntilEffectCompleted("backgroundFetch") + #expect(viewModel.prefetchedData != nil) + } +} +``` + +`ProbeTesting` also includes robust error handling. It provides recovery suggestions for every error it throws, +guiding you toward a solution and making it easier to get started with the API. + +## Documentation & Sample Project + +The public interface of `Probing` and `ProbeTesting` is relatively small, yet powerful. + +You can download the `ProbingPlayground` sample project from its [GitHub page](https://github.com/NSFatalError/ProbingPlayground). + +## Installation + +To use `Probing`, declare it as a dependency in your `Package.swift` or via Xcode project settings. +Add a dependency on `Probing` in the targets you want to test, and `ProbeTesting` in your test targets: + +```swift +let package = Package( + name: "MyPackage", + dependencies: [ + .package( + url: "https://github.com/NSFatalError/Probing", + from: "1.0.0" + ) + ], + targets: [ + .target( + name: "MyModule", + dependencies: [ + .product(name: "Probing", package: "Probing") + ] + ), + .testTarget( + name: "MyModuleTests", + dependencies: [ + "ModuleA", + .product(name: "ProbeTesting", package: "Probing") + ] + ) + ] +) +``` + +Supported platforms: +- macOS 15.0 or later +- iOS 18.0 or later +- tvOS 18.0 or later +- watchOS 11.0 or later +- visionOS 2.0 or later + +Other requirements: +- Swift 6.1 or later +- Xcode 16.3 or later From 9862123fd2d4da913d47fcff852e49dada2f820f Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 16:01:23 +0200 Subject: [PATCH 33/41] - --- README.md | 2 +- Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift | 8 -------- Sources/ProbeTesting/WithProbing/WithProbing.swift | 5 ++--- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 36f0224..9a01d3b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

-Breakpoints for Swift Testing - precise control over side effects and execution suspension at any point. +

Breakpoints for Swift Testing - precise control over side effects and execution suspension at any point.

## What Problem Probing Solves? diff --git a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift index 3c16921..6119c4d 100644 --- a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift +++ b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift @@ -71,8 +71,6 @@ extension ProbingDispatcher { extension ProbingDispatcher { - // swiftlint:disable no_empty_block - /// Resumes execution of `body`, performing the minimal necessary work to install the specified probe, and suspends `body` again before returning. /// /// - Parameter id: Identifier of the probe, which is guaranteed to be installed when this function returns. @@ -216,14 +214,10 @@ extension ProbingDispatcher { isolation: isolation ) } - - // swiftlint:enable no_empty_block } extension ProbingDispatcher { - // swiftlint:disable no_empty_block - /// Resumes execution of `body`, performing the minimal necessary work to complete the specified effect, and suspends `body` again before returning. /// /// - Parameters: @@ -369,8 +363,6 @@ extension ProbingDispatcher { isolation: isolation ) } - - // swiftlint:enable no_empty_block } extension ProbingDispatcher { diff --git a/Sources/ProbeTesting/WithProbing/WithProbing.swift b/Sources/ProbeTesting/WithProbing/WithProbing.swift index 155d96f..e2761ef 100644 --- a/Sources/ProbeTesting/WithProbing/WithProbing.swift +++ b/Sources/ProbeTesting/WithProbing/WithProbing.swift @@ -12,7 +12,7 @@ import Testing /// Enables control over probes and effects, making asynchronous code testable. /// /// - Parameters: -/// - options: Controls testability of probes and effects created from `Task` APIs. Defaults to ``ProbingOptions/ignoreProbingInTasks``. +/// - options: Controls testability of probes and effects created from `Task` APIs. Defaults to ``ProbingOptions/ignoreProbingInTasks``. /// - body: A closure containing the code under test. Probes and effects invoked from this closure become controllable in `test`. /// - test: A closure used to control the execution of `body` via ``ProbingDispatcher``, verify expectations, and interact with the system under test. /// @@ -118,9 +118,8 @@ public func withProbing( } if error is RecordedError { throw ProbingTerminatedError() - } else { - throw error } + throw error } try await bodyTask.value From df862cf422bb02d564dcf73f1ffc37a2722b922d Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 16:02:20 +0200 Subject: [PATCH 34/41] - --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a01d3b..cf36f4f 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@

Probing

+

Breakpoints for Swift Testing - precise control over side effects and execution suspension at any point.

@@ -11,7 +12,7 @@

-

Breakpoints for Swift Testing - precise control over side effects and execution suspension at any point.

+--- ## What Problem Probing Solves? From 92786dc6a0880c96eb1f87ef47d55cac8663a1e9 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 16:10:55 +0200 Subject: [PATCH 35/41] - --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cf36f4f..ef2d548 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ --- +#### Contents +- [What Problem Probing Solves?](#what-problem-probing-solves) +- [How Probing Works?](#how-probing-works) +- [Documentation & Sample Project](#documentation--sample-project) +- [Installation](#installation) + ## What Problem Probing Solves? Testing asynchronous code remains challenging, even with Swift Concurrency and Swift Testing. @@ -22,7 +28,7 @@ Some of the persistent difficulties include: - **Hidden states**: When invoking methods on objects, often with complex dependencies between them, it’s not enough to inspect just the final output of the function. Inspecting the internal state changes during execution, such as loading states in view models, is equally important but notoriously difficult. -- **Non-determinism**: `Task`s run concurrently and may complete in different orders each time, leading to unpredictable states. +- **Non-determinism**: `Task` instances run concurrently and may complete in different orders each time, leading to unpredictable states. Even with full code coverage, there’s no guarantee that all execution paths have been reached, and it's' difficult to reason about what remains untested. - **Limited runtime control**: Once an asynchronous function is running, influencing its behavior becomes nearly impossible. This limitation pushes developers to rely on ahead-of-time setups, like intricate mocks, which add complexity and reduce clarity of the test. From 0c01e95770d5c626285553380f21a2ae634cd50d Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 17:56:17 +0200 Subject: [PATCH 36/41] - --- .../ProbeTesting/Documentation.docc/ProbeTesting.md | 8 ++++++-- Sources/Probing/Documentation.docc/Concurrency.md | 4 ++-- Sources/Probing/Documentation.docc/Probing.md | 12 ++++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md b/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md index 37ac61e..fd2fcef 100644 --- a/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md +++ b/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md @@ -1,7 +1,13 @@ # ``ProbeTesting`` +Control the execution of asynchronous code during tests using a sequence of dispatches. + ## Overview +To use `ProbeTesting`, you first need to prepare your codebase using the `Probing` library. + +- SeeAlso: Refer to the `Probing` documentation for details on how to make your code controllable during tests. + `ProbeTesting` gives you precise control over the execution of asynchronous code, making it fully predictable and testable. It also lets you reason about the flow from the user’s perspective by reducing it to a clear, step-by-step sequence of dispatches. @@ -34,8 +40,6 @@ func testLoading() async throws { } ``` -- SeeAlso: Refer to the `Probing` documentation for details on how to make your code controllable during tests. - ## Topics ### Getting Started diff --git a/Sources/Probing/Documentation.docc/Concurrency.md b/Sources/Probing/Documentation.docc/Concurrency.md index 60a9f22..d9d3ba7 100644 --- a/Sources/Probing/Documentation.docc/Concurrency.md +++ b/Sources/Probing/Documentation.docc/Concurrency.md @@ -79,8 +79,8 @@ struct MyView: View { } ``` -- SeeAlso: Refer to the `ProbeTesting.ProbingOptions.attemptProbingFromTasks` documentation -if you need to control tasks using the `Probing` library. As the name of the option suggests, this is not always possible, +- SeeAlso: Refer to the `ProbeTesting.ProbingOptions.attemptProbingFromTasks` documentation if you need to control +the execution of probes and effects created from `Task` APIs. As the name of the option suggests, this is not always possible and is generally discouraged. ## Parallel Processing diff --git a/Sources/Probing/Documentation.docc/Probing.md b/Sources/Probing/Documentation.docc/Probing.md index 54f7f49..78999d9 100644 --- a/Sources/Probing/Documentation.docc/Probing.md +++ b/Sources/Probing/Documentation.docc/Probing.md @@ -4,10 +4,9 @@ Define suspension points accessible from tests with probes, and make side effect ## Overview -To use `ProbeTesting`, you first need to prepare your codebase using the `Probing` library. - -The `Probing` library lets you define suspension points using the ``probe(_:preprocessorFlag:)`` macro. -These are typically placed after a state change and before `await` statements, for example: +`Probing` lets you define suspension points for your test expectations +using the ``probe(_:preprocessorFlag:)`` macro. These are typically placed after a state change +and before `await` statements, for example: ```swift func load() async { @@ -22,8 +21,9 @@ func load() async { } ``` -If your code uses `Task` to create side effects, you can replace them with the ``Effect(_:preprocessorFlag:priority:operation:)`` -macro or one of its variants. For example: +If your code uses `Task` instances to create side effects, you can replace them with +the ``Effect(_:preprocessorFlag:priority:operation:)`` macro or one of its variants to make them +controllable during tests. For example: ```swift func load() { From f0c879c2aeb42b673eacf455678e4fb3382821ad Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 18:06:03 +0200 Subject: [PATCH 37/41] - --- .../Suites/EffectTests.swift | 671 +----------------- .../Suites/IndependentEffectsTests.swift | 305 ++++++++ .../Suites/NestedEffectsTests.swift | 376 ++++++++++ 3 files changed, 687 insertions(+), 665 deletions(-) create mode 100644 Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift create mode 100644 Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift diff --git a/Tests/ProbeTestingTests/Suites/EffectTests.swift b/Tests/ProbeTestingTests/Suites/EffectTests.swift index be38129..1515edd 100644 --- a/Tests/ProbeTestingTests/Suites/EffectTests.swift +++ b/Tests/ProbeTestingTests/Suites/EffectTests.swift @@ -6,8 +6,6 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -// swiftlint:disable file_length - @testable import ProbeTesting @testable import Probing import Algorithms @@ -15,8 +13,8 @@ import Testing internal class EffectTests { - private let model: IsolatedModel - private let interactor: IsolatedInteractor + let model: IsolatedModel + let interactor: IsolatedInteractor init() async { self.model = .init() @@ -179,627 +177,10 @@ extension EffectTests { } } -extension EffectTests { - - final class Independent: EffectTests { - - @Test - func testRunningThroughProbes() async throws { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe("1") - await #expect( - model.values == [ - .root: 1 - ] - ) - - try await dispatcher.runUpToProbe("2") - await #expect( - model.values == [ - .root: 2 - ] - ) - - try await dispatcher.runUntilExitOfBody() - await #expect( - model.values == [ - .root: 3 - ] - ) - - try await dispatcher.runUpToProbe("1.1") - await #expect( - model.values == [ - .root: 3, - "1": 1 - ] - ) - - try await dispatcher.runUpToProbe("1.2") - await #expect( - model.values == [ - .root: 3, - "1": 2 - ] - ) - - try await dispatcher.runUntilEffectCompleted("1") - await #expect( - model.values == [ - .root: 3, - "1": 3 - ] - ) - - try await dispatcher.runUpToProbe("2.1") - await #expect( - model.values == [ - .root: 3, - "1": 3, - "2": 1 - ] - ) - - try await dispatcher.runUpToProbe("2.2") - await #expect( - model.values == [ - .root: 3, - "1": 3, - "2": 2 - ] - ) - - try await dispatcher.runUntilEffectCompleted("2") - await #expect( - model.values == [ - .root: 3, - "1": 3, - "2": 3 - ] - ) - } - } - - @Test( - arguments: 1 ..< 3, - 1 ..< 3 - ) - func testRunningToProbe( - inEffect effect: Int, - withNumber number: Int - ) async throws { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe("\(effect).\(number)") - await #expect( - model.values == [ - .root: effect, - "\(effect)": number - ] - ) - - try await dispatcher.runUntilEffectCompleted("\(effect)") - await #expect( - model.values == [ - .root: effect, - "\(effect)": 3 - ] - ) - } - await #expect(model.values[.root, default: 0] == 3) - } - - @Test - func testNameEnumeration() async throws { - try await withProbing { - await interactor.callWithIndependentEnumeratedEffects() - } dispatchedBy: { dispatcher in - try await dispatcher.runUntilExitOfBody() - try await dispatcher.runUntilEffectCompleted("effect0") - await #expect(model.values == ["effect0": 3]) - - try await dispatcher.runUntilEffectCompleted("effect1") - await #expect(model.values == ["effect0": 3, "effect1": 3]) - } - } - } -} - -extension EffectTests.Independent { - - @Test - func testRunningWithoutDispatches() async throws { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { _ in - await #expect(model.values.isEmpty) - } - await #expect(model.values[.root] == 3) - } - - @Test - func testRunningUntilExitOfBody() async throws { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUntilExitOfBody() - await #expect(model.values == [.root: 3]) - } - } - - @Test - func testRunningUntilEverythingCompleted() async throws { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUntilEverythingCompleted() - await #expect( - model.values == [ - .root: 3, - "1": 3, - "2": 3 - ] - ) - } - } - - @Test - func testGettingMissingEffectValue() async throws { - try await withKnownIssue { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try dispatcher.getValue(fromEffect: "test", as: Void.self) - } catch { - await #expect(model.values.isEmpty) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.EffectNotFound.self) - } - await #expect(model.values[.root] == 3) - } - - @Test - func testGettingMissingEffectCancelledValue() async throws { - try await withKnownIssue { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try dispatcher.getCancelledValue(fromEffect: "test", as: Void.self) - } catch { - await #expect(model.values.isEmpty) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.EffectNotFound.self) - } - await #expect(model.values[.root] == 3) - } - - @Test(arguments: ProbingOptions.all) - func testRunningUpToMissingProbe(options: ProbingOptions) async throws { - try await withKnownIssue { - try await withProbing(options: options) { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe("test") - } catch { - await #expect(model.values[.root] == 3) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.ProbeNotInstalled.self) - } - } - - @Test(arguments: ProbingOptions.all) - func testRunningUpToMissingProbeInEffect(options: ProbingOptions) async throws { - try await withKnownIssue { - try await withProbing(options: options) { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe(inEffect: "test") - } catch { - await #expect(model.values[.root] == 3) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) - } - } - - @Test( - arguments: [true, false], - ProbingOptions.all - ) - func testRunningUntilMissingEffectCompleted( - includingDescendants: Bool, - options: ProbingOptions - ) async throws { - try await withKnownIssue { - try await withProbing(options: options) { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try await dispatcher.runUntilEffectCompleted( - "test", - includingDescendants: includingDescendants - ) - } catch { - await #expect(model.values[.root] == 3) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) - } - } -} - -extension EffectTests { - - final class Nested: EffectTests { - - @Test - func testRunningThroughProbes() async throws { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe("1") - await #expect( - model.values == [ - .root: 1 - ] - ) - - try await dispatcher.runUpToProbe("2") - await #expect( - model.values == [ - .root: 2 - ] - ) - - try await dispatcher.runUntilExitOfBody() - await #expect( - model.values == [ - .root: 3 - ] - ) - - try await dispatcher.runUpToProbe("1.1") - await #expect( - model.values == [ - .root: 3, - "1": 1 - ] - ) - - try await dispatcher.runUpToProbe("1.2") - await #expect( - model.values == [ - .root: 3, - "1": 2 - ] - ) - - try await dispatcher.runUntilEffectCompleted("1") - await #expect( - model.values == [ - .root: 3, - "1": 3 - ] - ) - - try await dispatcher.runUpToProbe("1.1.1") - await #expect( - model.values == [ - .root: 3, - "1": 3, - "1.1": 1 - ] - ) - - try await dispatcher.runUpToProbe("1.1.2") - await #expect( - model.values == [ - .root: 3, - "1": 3, - "1.1": 2 - ] - ) - - try await dispatcher.runUntilEffectCompleted("1.1") - await #expect( - model.values == [ - .root: 3, - "1": 3, - "1.1": 3 - ] - ) - } - } - - @Test( - arguments: Array(product(1 ..< 3, 1 ..< 3)), - 1 ..< 3 - ) - func testRunningToProbe( - inEffect effect: (parent: Int, child: Int), - withNumber number: Int - ) async throws { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe("\(effect.parent).\(effect.child).\(number)") - await #expect( - model.values == [ - .root: effect.parent, - "\(effect.parent)": effect.child, - "\(effect.parent).\(effect.child)": number - ] - ) - - try await dispatcher.runUntilEffectCompleted("\(effect.parent).\(effect.child)") - await #expect( - model.values == [ - .root: effect.parent, - "\(effect.parent)": effect.child, - "\(effect.parent).\(effect.child)": 3 - ] - ) - - try await dispatcher.runUntilEffectCompleted("\(effect.parent)") - await #expect( - model.values == [ - .root: effect.parent, - "\(effect.parent)": 3, - "\(effect.parent).\(effect.child)": 3 - ] - ) - - try await dispatcher.runUntilEffectCompleted( - "\(effect.parent)", - includingDescendants: true - ) - await #expect( - model.values == [ - .root: effect.parent, - "\(effect.parent)": 3, - "\(effect.parent).1": 3, - "\(effect.parent).2": 3 - ] - ) - } - await #expect(model.values[.root, default: 0] == 3) - } - - @Test - func testNameEnumeration() async throws { - try await withProbing { - await interactor.callWithNestedEnumeratedEffects() - } dispatchedBy: { dispatcher in - try await dispatcher.runUntilExitOfBody() - try await dispatcher.runUntilEffectCompleted("name0.effect0") - await #expect(model.values == ["name0.effect0": 3]) - - try await dispatcher.runUntilEffectCompleted("name1.effect1") - await #expect( - model.values == [ - "name0.effect0": 3, - "name1.effect1": 3 - ] - ) - - try await dispatcher.runUntilEffectCompleted("name0", includingDescendants: true) - await #expect( - model.values == [ - "name0.effect0": 3, - "name0.effect1": 3, - "name1.effect1": 3 - ] - ) - - try await dispatcher.runUntilEffectCompleted("name1", includingDescendants: true) - await #expect( - model.values == [ - "name0.effect0": 3, - "name0.effect1": 3, - "name1.effect0": 3, - "name1.effect1": 3 - ] - ) - } - } - } -} - -extension EffectTests.Nested { - - @Test - func testRunningWithoutDispatches() async throws { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { _ in - await #expect(model.values.isEmpty) - } - await #expect(model.values[.root] == 3) - } - - @Test - func testRunningUntilExitOfBody() async throws { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUntilExitOfBody() - await #expect(model.values == [.root: 3]) - } - } - - @Test - func testRunningUntilEverythingCompleted() async throws { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUntilEverythingCompleted() - await #expect( - model.values == [ - .root: 3, - "1": 3, - "1.1": 3, - "1.2": 3, - "2": 3, - "2.1": 3, - "2.2": 3 - ] - ) - } - } - - @Test - func testGettingMissingEffectValue() async throws { - try await withKnownIssue { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try dispatcher.getValue(fromEffect: "1.2.test", as: Void.self) - } catch { - await #expect(model.values.isEmpty) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.EffectNotFound.self) - } - await #expect(model.values[.root] == 3) - } - - @Test - func testGettingMissingEffectCancelledValue() async throws { - try await withKnownIssue { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try dispatcher.getCancelledValue(fromEffect: "1.2.test", as: Void.self) - } catch { - await #expect(model.values.isEmpty) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.EffectNotFound.self) - } - await #expect(model.values[.root] == 3) - } - - @Test(arguments: ProbingOptions.all) - func testRunningUpToMissingProbe(options: ProbingOptions) async throws { - try await withKnownIssue { - try await withProbing(options: options) { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe("1.2.test") - } catch { - await #expect(model.values[.root, default: 0] >= 1) - await #expect(model.values["1", default: 0] >= 2) - await #expect(model.values["1.2", default: 0] == 3) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.ProbeNotInstalled.self) - } - await #expect(model.values[.root] == 3) - } - - @Test(arguments: ProbingOptions.all) - func testRunningUpToMissingProbeInEffect(options: ProbingOptions) async throws { - try await withKnownIssue { - try await withProbing(options: options) { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe(inEffect: "1.2.test") - } catch { - await #expect(model.values[.root, default: 0] >= 1) - await #expect(model.values["1", default: 0] >= 2) - await #expect(model.values["1.2", default: 0] == 3) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) - } - await #expect(model.values[.root] == 3) - } - - @Test( - arguments: [true, false], - ProbingOptions.all - ) - func testRunningUntilMissingEffectCompleted( - includingDescendants: Bool, - options: ProbingOptions - ) async throws { - try await withKnownIssue { - try await withProbing(options: options) { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try await dispatcher.runUntilEffectCompleted( - "1.2.test", - includingDescendants: includingDescendants - ) - } catch { - await #expect(model.values[.root, default: 0] >= 1) - await #expect(model.values["1", default: 0] >= 2) - await #expect(model.values["1.2", default: 0] == 3) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) - } - await #expect(model.values[.root] == 3) - } -} - extension EffectTests { @MainActor - private final class IsolatedModel { + final class IsolatedModel { private(set) var values = [EffectIdentifier: Int]() @@ -809,16 +190,16 @@ extension EffectTests { } @MainActor - private final class IsolatedInteractor { + final class IsolatedInteractor { - private let model: IsolatedModel + let model: IsolatedModel init(model: IsolatedModel) { self.model = model } @discardableResult - private func makeEffect(_ name: EffectName) -> any Effect { + func makeEffect(_ name: EffectName) -> any Effect { #Effect(name) { self.model.tick() await #probe("1") @@ -843,46 +224,6 @@ extension EffectTests { effect.cancel() } - func callWithIndependentEffects() async { - model.tick() - makeEffect("1") - await #probe("1") - model.tick() - makeEffect("2") - await #probe("2") - model.tick() - } - - func callWithIndependentEnumeratedEffects() { - makeEffect(.enumerated("effect")) - makeEffect(.enumerated("effect")) - } - - func callWithNestedEffects() async { - model.tick() - #Effect("1") { - await self.callWithIndependentEffects() - } - - await #probe("1") - model.tick() - #ConcurrentEffect("2") { - await self.callWithIndependentEffects() - } - - await #probe("2") - model.tick() - } - - func callWithNestedEnumeratedEffects() { - #Effect(.enumerated("name")) { - self.callWithIndependentEnumeratedEffects() - } - #ConcurrentEffect(.enumerated("name")) { - await self.callWithIndependentEnumeratedEffects() - } - } - func callWithAmbiguousEffects() async { #Effect("ambiguous") { #Effect("nested") { -1 } diff --git a/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift b/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift new file mode 100644 index 0000000..2e1b53a --- /dev/null +++ b/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift @@ -0,0 +1,305 @@ +// +// EffectTests.swift +// Probing +// +// Created by Kamil Strzelecki on 26/04/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import ProbeTesting +@testable import Probing +import Algorithms +import Testing + +internal final class IndependentEffectTests: EffectTests { + + @Test + func testRunningThroughProbes() async throws { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe("1") + await #expect( + model.values == [ + .root: 1 + ] + ) + + try await dispatcher.runUpToProbe("2") + await #expect( + model.values == [ + .root: 2 + ] + ) + + try await dispatcher.runUntilExitOfBody() + await #expect( + model.values == [ + .root: 3 + ] + ) + + try await dispatcher.runUpToProbe("1.1") + await #expect( + model.values == [ + .root: 3, + "1": 1 + ] + ) + + try await dispatcher.runUpToProbe("1.2") + await #expect( + model.values == [ + .root: 3, + "1": 2 + ] + ) + + try await dispatcher.runUntilEffectCompleted("1") + await #expect( + model.values == [ + .root: 3, + "1": 3 + ] + ) + + try await dispatcher.runUpToProbe("2.1") + await #expect( + model.values == [ + .root: 3, + "1": 3, + "2": 1 + ] + ) + + try await dispatcher.runUpToProbe("2.2") + await #expect( + model.values == [ + .root: 3, + "1": 3, + "2": 2 + ] + ) + + try await dispatcher.runUntilEffectCompleted("2") + await #expect( + model.values == [ + .root: 3, + "1": 3, + "2": 3 + ] + ) + } + } + + @Test( + arguments: 1 ..< 3, + 1 ..< 3 + ) + func testRunningToProbe( + inEffect effect: Int, + withNumber number: Int + ) async throws { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe("\(effect).\(number)") + await #expect( + model.values == [ + .root: effect, + "\(effect)": number + ] + ) + + try await dispatcher.runUntilEffectCompleted("\(effect)") + await #expect( + model.values == [ + .root: effect, + "\(effect)": 3 + ] + ) + } + await #expect(model.values[.root, default: 0] == 3) + } + + @Test + func testNameEnumeration() async throws { + try await withProbing { + await interactor.callWithIndependentEnumeratedEffects() + } dispatchedBy: { dispatcher in + try await dispatcher.runUntilExitOfBody() + try await dispatcher.runUntilEffectCompleted("effect0") + await #expect(model.values == ["effect0": 3]) + + try await dispatcher.runUntilEffectCompleted("effect1") + await #expect(model.values == ["effect0": 3, "effect1": 3]) + } + } +} + +extension IndependentEffectTests { + + @Test + func testRunningWithoutDispatches() async throws { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { _ in + await #expect(model.values.isEmpty) + } + await #expect(model.values[.root] == 3) + } + + @Test + func testRunningUntilExitOfBody() async throws { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUntilExitOfBody() + await #expect(model.values == [.root: 3]) + } + } + + @Test + func testRunningUntilEverythingCompleted() async throws { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUntilEverythingCompleted() + await #expect( + model.values == [ + .root: 3, + "1": 3, + "2": 3 + ] + ) + } + } + + @Test + func testGettingMissingEffectValue() async throws { + try await withKnownIssue { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try dispatcher.getValue(fromEffect: "test", as: Void.self) + } catch { + await #expect(model.values.isEmpty) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.EffectNotFound.self) + } + await #expect(model.values[.root] == 3) + } + + @Test + func testGettingMissingEffectCancelledValue() async throws { + try await withKnownIssue { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try dispatcher.getCancelledValue(fromEffect: "test", as: Void.self) + } catch { + await #expect(model.values.isEmpty) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.EffectNotFound.self) + } + await #expect(model.values[.root] == 3) + } + + @Test(arguments: ProbingOptions.all) + func testRunningUpToMissingProbe(options: ProbingOptions) async throws { + try await withKnownIssue { + try await withProbing(options: options) { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe("test") + } catch { + await #expect(model.values[.root] == 3) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.ProbeNotInstalled.self) + } + } + + @Test(arguments: ProbingOptions.all) + func testRunningUpToMissingProbeInEffect(options: ProbingOptions) async throws { + try await withKnownIssue { + try await withProbing(options: options) { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe(inEffect: "test") + } catch { + await #expect(model.values[.root] == 3) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) + } + } + + @Test( + arguments: [true, false], + ProbingOptions.all + ) + func testRunningUntilMissingEffectCompleted( + includingDescendants: Bool, + options: ProbingOptions + ) async throws { + try await withKnownIssue { + try await withProbing(options: options) { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try await dispatcher.runUntilEffectCompleted( + "test", + includingDescendants: includingDescendants + ) + } catch { + await #expect(model.values[.root] == 3) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) + } + } +} + +extension EffectTests.IsolatedInteractor { + + func callWithIndependentEffects() async { + model.tick() + makeEffect("1") + await #probe("1") + model.tick() + makeEffect("2") + await #probe("2") + model.tick() + } + + func callWithIndependentEnumeratedEffects() { + makeEffect(.enumerated("effect")) + makeEffect(.enumerated("effect")) + } +} diff --git a/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift b/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift new file mode 100644 index 0000000..5a456c9 --- /dev/null +++ b/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift @@ -0,0 +1,376 @@ +// +// EffectTests.swift +// Probing +// +// Created by Kamil Strzelecki on 26/04/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import ProbeTesting +@testable import Probing +import Algorithms +import Testing + +internal final class NestedEffectsTests: EffectTests { + + @Test + func testRunningThroughProbes() async throws { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe("1") + await #expect( + model.values == [ + .root: 1 + ] + ) + + try await dispatcher.runUpToProbe("2") + await #expect( + model.values == [ + .root: 2 + ] + ) + + try await dispatcher.runUntilExitOfBody() + await #expect( + model.values == [ + .root: 3 + ] + ) + + try await dispatcher.runUpToProbe("1.1") + await #expect( + model.values == [ + .root: 3, + "1": 1 + ] + ) + + try await dispatcher.runUpToProbe("1.2") + await #expect( + model.values == [ + .root: 3, + "1": 2 + ] + ) + + try await dispatcher.runUntilEffectCompleted("1") + await #expect( + model.values == [ + .root: 3, + "1": 3 + ] + ) + + try await dispatcher.runUpToProbe("1.1.1") + await #expect( + model.values == [ + .root: 3, + "1": 3, + "1.1": 1 + ] + ) + + try await dispatcher.runUpToProbe("1.1.2") + await #expect( + model.values == [ + .root: 3, + "1": 3, + "1.1": 2 + ] + ) + + try await dispatcher.runUntilEffectCompleted("1.1") + await #expect( + model.values == [ + .root: 3, + "1": 3, + "1.1": 3 + ] + ) + } + } + + @Test( + arguments: Array(product(1 ..< 3, 1 ..< 3)), + 1 ..< 3 + ) + func testRunningToProbe( + inEffect effect: (parent: Int, child: Int), + withNumber number: Int + ) async throws { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe("\(effect.parent).\(effect.child).\(number)") + await #expect( + model.values == [ + .root: effect.parent, + "\(effect.parent)": effect.child, + "\(effect.parent).\(effect.child)": number + ] + ) + + try await dispatcher.runUntilEffectCompleted("\(effect.parent).\(effect.child)") + await #expect( + model.values == [ + .root: effect.parent, + "\(effect.parent)": effect.child, + "\(effect.parent).\(effect.child)": 3 + ] + ) + + try await dispatcher.runUntilEffectCompleted("\(effect.parent)") + await #expect( + model.values == [ + .root: effect.parent, + "\(effect.parent)": 3, + "\(effect.parent).\(effect.child)": 3 + ] + ) + + try await dispatcher.runUntilEffectCompleted( + "\(effect.parent)", + includingDescendants: true + ) + await #expect( + model.values == [ + .root: effect.parent, + "\(effect.parent)": 3, + "\(effect.parent).1": 3, + "\(effect.parent).2": 3 + ] + ) + } + await #expect(model.values[.root, default: 0] == 3) + } + + @Test + func testNameEnumeration() async throws { + try await withProbing { + await interactor.callWithNestedEnumeratedEffects() + } dispatchedBy: { dispatcher in + try await dispatcher.runUntilExitOfBody() + try await dispatcher.runUntilEffectCompleted("name0.effect0") + await #expect(model.values == ["name0.effect0": 3]) + + try await dispatcher.runUntilEffectCompleted("name1.effect1") + await #expect( + model.values == [ + "name0.effect0": 3, + "name1.effect1": 3 + ] + ) + + try await dispatcher.runUntilEffectCompleted("name0", includingDescendants: true) + await #expect( + model.values == [ + "name0.effect0": 3, + "name0.effect1": 3, + "name1.effect1": 3 + ] + ) + + try await dispatcher.runUntilEffectCompleted("name1", includingDescendants: true) + await #expect( + model.values == [ + "name0.effect0": 3, + "name0.effect1": 3, + "name1.effect0": 3, + "name1.effect1": 3 + ] + ) + } + } +} + +extension NestedEffectsTests { + + @Test + func testRunningWithoutDispatches() async throws { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { _ in + await #expect(model.values.isEmpty) + } + await #expect(model.values[.root] == 3) + } + + @Test + func testRunningUntilExitOfBody() async throws { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUntilExitOfBody() + await #expect(model.values == [.root: 3]) + } + } + + @Test + func testRunningUntilEverythingCompleted() async throws { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUntilEverythingCompleted() + await #expect( + model.values == [ + .root: 3, + "1": 3, + "1.1": 3, + "1.2": 3, + "2": 3, + "2.1": 3, + "2.2": 3 + ] + ) + } + } + + @Test + func testGettingMissingEffectValue() async throws { + try await withKnownIssue { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try dispatcher.getValue(fromEffect: "1.2.test", as: Void.self) + } catch { + await #expect(model.values.isEmpty) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.EffectNotFound.self) + } + await #expect(model.values[.root] == 3) + } + + @Test + func testGettingMissingEffectCancelledValue() async throws { + try await withKnownIssue { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try dispatcher.getCancelledValue(fromEffect: "1.2.test", as: Void.self) + } catch { + await #expect(model.values.isEmpty) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.EffectNotFound.self) + } + await #expect(model.values[.root] == 3) + } + + @Test(arguments: ProbingOptions.all) + func testRunningUpToMissingProbe(options: ProbingOptions) async throws { + try await withKnownIssue { + try await withProbing(options: options) { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe("1.2.test") + } catch { + await #expect(model.values[.root, default: 0] >= 1) + await #expect(model.values["1", default: 0] >= 2) + await #expect(model.values["1.2", default: 0] == 3) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.ProbeNotInstalled.self) + } + await #expect(model.values[.root] == 3) + } + + @Test(arguments: ProbingOptions.all) + func testRunningUpToMissingProbeInEffect(options: ProbingOptions) async throws { + try await withKnownIssue { + try await withProbing(options: options) { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe(inEffect: "1.2.test") + } catch { + await #expect(model.values[.root, default: 0] >= 1) + await #expect(model.values["1", default: 0] >= 2) + await #expect(model.values["1.2", default: 0] == 3) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) + } + await #expect(model.values[.root] == 3) + } + + @Test( + arguments: [true, false], + ProbingOptions.all + ) + func testRunningUntilMissingEffectCompleted( + includingDescendants: Bool, + options: ProbingOptions + ) async throws { + try await withKnownIssue { + try await withProbing(options: options) { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try await dispatcher.runUntilEffectCompleted( + "1.2.test", + includingDescendants: includingDescendants + ) + } catch { + await #expect(model.values[.root, default: 0] >= 1) + await #expect(model.values["1", default: 0] >= 2) + await #expect(model.values["1.2", default: 0] == 3) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) + } + await #expect(model.values[.root] == 3) + } +} + +extension EffectTests.IsolatedInteractor { + + func callWithNestedEffects() async { + model.tick() + #Effect("1") { + await self.callWithIndependentEffects() + } + + await #probe("1") + model.tick() + #ConcurrentEffect("2") { + await self.callWithIndependentEffects() + } + + await #probe("2") + model.tick() + } + + func callWithNestedEnumeratedEffects() { + #Effect(.enumerated("name")) { + self.callWithIndependentEnumeratedEffects() + } + #ConcurrentEffect(.enumerated("name")) { + await self.callWithIndependentEnumeratedEffects() + } + } +} From 3a32a1a925a29198e002f3cafc44a65e14933628 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 18:06:03 +0200 Subject: [PATCH 38/41] [SwiftFormat] Applied formatting --- Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift b/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift index 5a456c9..f5226ec 100644 --- a/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift +++ b/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift @@ -12,7 +12,7 @@ import Algorithms import Testing internal final class NestedEffectsTests: EffectTests { - + @Test func testRunningThroughProbes() async throws { try await withProbing { From 9be75040323e69ca02d04fef8ff491178fa8c643 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 18:13:01 +0200 Subject: [PATCH 39/41] - --- Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift | 4 ++-- Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift b/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift index 2e1b53a..2deeef2 100644 --- a/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift +++ b/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift @@ -1,5 +1,5 @@ // -// EffectTests.swift +// IndependentEffectsTests.swift // Probing // // Created by Kamil Strzelecki on 26/04/2025. @@ -11,7 +11,7 @@ import Algorithms import Testing -internal final class IndependentEffectTests: EffectTests { +internal final class IndependentEffectsTests: EffectTests { @Test func testRunningThroughProbes() async throws { diff --git a/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift b/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift index f5226ec..052e2b2 100644 --- a/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift +++ b/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift @@ -1,5 +1,5 @@ // -// EffectTests.swift +// NestedEffectsTests.swift // Probing // // Created by Kamil Strzelecki on 26/04/2025. From 4c4ed0a0da4e953c288d5459ffc8de3db9350b7d Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 18:15:47 +0200 Subject: [PATCH 40/41] - --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ef2d548..a4a80c1 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The `Probing` package consists of two main modules: - `Probing`, which you add as a dependency to the targets you want to test - `ProbeTesting`, which you add as a dependency to your test targets -With `Probing`, you can define **probes** — suspension points typically placed after a state change, +With `Probing`, you can define **probes** - suspension points typically placed after a state change, conceptually similar to breakpoints, but accessible and targetable from your tests. You can also define **effects**, which make `Task` instances controllable and predictable. @@ -118,7 +118,7 @@ let package = Package( .testTarget( name: "MyModuleTests", dependencies: [ - "ModuleA", + "MyModule", .product(name: "ProbeTesting", package: "Probing") ] ) From 54af143d1f6ec64a784313e2e7dac4578144271c Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 12 May 2025 18:20:03 +0200 Subject: [PATCH 41/41] - --- Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift b/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift index 2deeef2..443639f 100644 --- a/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift +++ b/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift @@ -139,7 +139,7 @@ internal final class IndependentEffectsTests: EffectTests { } } -extension IndependentEffectTests { +extension IndependentEffectsTests { @Test func testRunningWithoutDispatches() async throws {