From 3e830b575aa23346f6dd033be66e5e7d39dbcee9 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 22 Jul 2024 17:52:04 -0700 Subject: [PATCH] Swift Testing support (#3229) * wip * wip * Update Testing.md * wip * wip * wip * wip * wip: * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * disable library evolution * bump * wip --------- Co-authored-by: Brandon Williams --- .../xcshareddata/swiftpm/Package.resolved | 50 +- .github/workflows/ci.yml | 18 +- .../xcshareddata/swiftpm/Package.resolved | 50 +- .../xcschemes/ComposableArchitecture.xcscheme | 4 +- ...composable-architecture-benchmark.xcscheme | 2 +- .../xcschemes/CaseStudies (SwiftUI).xcscheme | 2 +- .../xcschemes/CaseStudies (UIKit).xcscheme | 2 +- .../xcschemes/tvOSCaseStudies.xcscheme | 2 +- .../Integration.xcodeproj/project.pbxproj | 2 +- .../xcschemes/Integration.xcscheme | 2 +- .../Internal/BaseIntegrationTests.swift | 11 +- .../Internal/TestHelpers.swift | 4 +- .../xcshareddata/xcschemes/Search.xcscheme | 2 +- .../xcschemes/SpeechRecognition.xcscheme | 2 +- .../SyncUps/SyncUps.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/xcschemes/SyncUps.xcscheme | 2 +- .../xcshareddata/xcschemes/TicTacToe.xcscheme | 2 +- .../Todos/Todos.xcodeproj/project.pbxproj | 13 +- .../xcshareddata/xcschemes/Todos.xcscheme | 2 +- .../xcschemes/VoiceMemos.xcscheme | 2 +- Package.resolved | 50 +- Package.swift | 19 +- .../Dependencies/Dismiss.swift | 36 +- .../Documentation.docc/Articles/Testing.md | 4 +- Sources/ComposableArchitecture/Effect.swift | 13 +- .../Effects/TaskResult.swift | 6 +- .../Internal/HashableStaticString.swift | 37 + .../Internal/RuntimeWarnings.swift | 76 +- .../IdentifiedArray+Observation.swift | 14 +- .../NavigationStack+Observation.swift | 103 ++- .../Observation/Store+Observation.swift | 76 +- .../Reducer/Reducers/ForEachReducer.swift | 36 +- .../Reducer/Reducers/IfCaseLetReducer.swift | 36 +- .../Reducer/Reducers/IfLetReducer.swift | 52 +- .../Reducers/PresentationReducer.swift | 64 +- .../Reducer/Reducers/Scope.swift | 38 +- .../Reducer/Reducers/StackReducer.swift | 118 ++- .../ComposableArchitecture/RootStore.swift | 15 +- .../SharedState/Shared.swift | 31 +- .../SwiftUI/Binding.swift | 40 +- .../SwiftUI/NavigationStackStore.swift | 34 +- .../SwiftUI/SwitchStore.swift | 18 +- .../ComposableArchitecture/TestStore.swift | 729 ++++++++++++------ .../UIKit/NSObject+Observation.swift | 16 +- .../PresentsMacroTests.swift | 3 - .../EffectFailureTests.swift | 2 +- .../EffectRunTests.swift | 4 +- .../ObserveTests.swift | 2 +- .../Reducers/ForEachReducerTests.swift | 3 +- .../Reducers/IfCaseLetReducerTests.swift | 4 +- .../Reducers/IfLetReducerTests.swift | 4 +- .../Reducers/PresentationReducerTests.swift | 16 +- .../Reducers/StackReducerTests.swift | 28 +- .../RuntimeWarningTests.swift | 40 +- .../ScopeCacheTests.swift | 6 +- .../ScopeTests.swift | 4 +- .../SharedAppStorageTests.swift | 2 +- .../SharedInMemoryTests.swift | 2 +- .../SharedTests.swift | 16 +- .../TaskResultTests.swift | 6 +- .../TestStoreFailureTests.swift | 26 +- .../TestStoreNonExhaustiveTests.swift | 16 +- .../TestStoreTests.swift | 6 +- 63 files changed, 1370 insertions(+), 657 deletions(-) create mode 100644 Sources/ComposableArchitecture/Internal/HashableStaticString.swift diff --git a/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved b/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2eae2d6a0b2c..984607182e02 100644 --- a/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", - "version" : "1.0.0" + "revision" : "487a4d151e795a5e076a7e7aedcd13c2ebff6c31", + "version" : "1.0.1" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", - "version" : "1.4.0" + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "b9ad2661b6e8fb411fef6a441c9955c3413afac0", - "version" : "1.5.0" + "revision" : "031704ba0634b45e02fe875b8ddddc7f30a07f49", + "version" : "1.5.3" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", - "version" : "1.0.2" + "revision" : "eb64eacfed55635a771e3410f9c91de46cf5c6a0", + "version" : "1.0.3" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", - "version" : "1.3.0" + "revision" : "d237304f42af07f22563aa4cc2d7e2cfb25da82e", + "version" : "1.3.1" } }, { @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "9085501f168b08f5205b68f1b8a0d56bb52b8c1a", - "version" : "1.3.2" + "revision" : "52018827ce21e482a36e3795bea2666b3898164c", + "version" : "1.3.4" } }, { @@ -108,6 +108,15 @@ "version" : "1.1.0" } }, + { + "identity" : "swift-issue-reporting", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-issue-reporting", + "state" : { + "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f", + "version" : "1.2.0" + } + }, { "identity" : "swift-macro-testing", "kind" : "remoteSourceControl", @@ -122,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "68901eac31c13c7d1ffef8e1bd8c3870ca2eaa95", - "version" : "1.3.2" + "revision" : "2c75ce556a6fc106721b0dadc2c7327244ad3999", + "version" : "1.3.3" } }, { @@ -149,17 +158,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation", "state" : { - "revision" : "b7c9a79f6f6b1fefb87d3e5a83a9c2fe7cdc9720", - "version" : "1.5.0" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", - "version" : "1.1.2" + "revision" : "97f854044356ac082e7e698f39264cc035544d77", + "version" : "1.5.2" } } ], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e186dd4516e9..fb5cb7574d00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,15 +31,15 @@ jobs: - name: Run ${{ matrix.config }} tests run: make CONFIG=${{ matrix.config }} test-library - library-evolution: - name: Library (evolution) - runs-on: macos-14 - steps: - - uses: actions/checkout@v4 - - name: Select Xcode 15.4 - run: sudo xcode-select -s /Applications/Xcode_15.4.app - - name: Build for library evolution - run: make build-for-library-evolution + # library-evolution: + # name: Library (evolution) + # runs-on: macos-14 + # steps: + # - uses: actions/checkout@v4 + # - name: Select Xcode 15.4 + # run: sudo xcode-select -s /Applications/Xcode_15.4.app + # - name: Build for library evolution + # run: make build-for-library-evolution library-compatibility: name: Library (Swift 5.9) diff --git a/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6603cde8b0a2..b2fd4087e20e 100644 --- a/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", - "version" : "1.0.0" + "revision" : "487a4d151e795a5e076a7e7aedcd13c2ebff6c31", + "version" : "1.0.1" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", - "version" : "1.4.0" + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "b9ad2661b6e8fb411fef6a441c9955c3413afac0", - "version" : "1.5.0" + "revision" : "031704ba0634b45e02fe875b8ddddc7f30a07f49", + "version" : "1.5.3" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", - "version" : "1.0.2" + "revision" : "eb64eacfed55635a771e3410f9c91de46cf5c6a0", + "version" : "1.0.3" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", - "version" : "1.3.0" + "revision" : "d237304f42af07f22563aa4cc2d7e2cfb25da82e", + "version" : "1.3.1" } }, { @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "d80613633e76d1ef86f41926e72fbef6a2f77d9c", - "version" : "1.3.3" + "revision" : "52018827ce21e482a36e3795bea2666b3898164c", + "version" : "1.3.4" } }, { @@ -108,6 +108,15 @@ "version" : "1.1.0" } }, + { + "identity" : "swift-issue-reporting", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-issue-reporting", + "state" : { + "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f", + "version" : "1.2.0" + } + }, { "identity" : "swift-macro-testing", "kind" : "remoteSourceControl", @@ -122,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "68901eac31c13c7d1ffef8e1bd8c3870ca2eaa95", - "version" : "1.3.2" + "revision" : "2c75ce556a6fc106721b0dadc2c7327244ad3999", + "version" : "1.3.3" } }, { @@ -158,17 +167,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation", "state" : { - "revision" : "b7c9a79f6f6b1fefb87d3e5a83a9c2fe7cdc9720", - "version" : "1.5.0" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", - "version" : "1.1.2" + "revision" : "97f854044356ac082e7e698f39264cc035544d77", + "version" : "1.5.2" } } ], diff --git a/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme b/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme index f106309cef8e..cb624bd2d2e8 100644 --- a/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme +++ b/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme @@ -1,6 +1,6 @@ + isEnabled = "YES"> diff --git a/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme b/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme index 781d50f306ad..36ec563ba89f 100644 --- a/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme +++ b/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme @@ -1,6 +1,6 @@ String)? = nil, - file: StaticString = #file, + filePath: StaticString = #filePath, function: StaticString = #function, line: UInt = #line, column: UInt = #column @@ -78,7 +78,8 @@ class BaseIntegrationTests: XCTestCase { of: logs, as: ._lines, matches: expectedLogs, - file: file, + fileID: fileID, + file: filePath, function: function, line: line, column: column diff --git a/Examples/Integration/IntegrationUITests/Internal/TestHelpers.swift b/Examples/Integration/IntegrationUITests/Internal/TestHelpers.swift index f5e265694766..2db36187c6d0 100644 --- a/Examples/Integration/IntegrationUITests/Internal/TestHelpers.swift +++ b/Examples/Integration/IntegrationUITests/Internal/TestHelpers.swift @@ -12,11 +12,11 @@ func XCTTODO(_ message: String) { extension XCUIElement { func find( timeout: TimeInterval = 0.3, - file: StaticString = #file, + filePath: StaticString = #filePath, line: UInt = #line ) -> XCUIElement { if !self.waitForExistence(timeout: timeout) { - XCTFail("Failed to find \(self).", file: file, line: line) + XCTFail("Failed to find \(self).", file: filePath, line: line) } return self } diff --git a/Examples/Search/Search.xcodeproj/xcshareddata/xcschemes/Search.xcscheme b/Examples/Search/Search.xcodeproj/xcshareddata/xcschemes/Search.xcscheme index 0006af14e1ea..06276c44c608 100644 --- a/Examples/Search/Search.xcodeproj/xcshareddata/xcschemes/Search.xcscheme +++ b/Examples/Search/Search.xcodeproj/xcshareddata/xcschemes/Search.xcscheme @@ -1,6 +1,6 @@ { @usableFromInline @@ -86,7 +85,9 @@ extension Effect { operation: @escaping @Sendable (_ send: Send) async throws -> Void, catch handler: (@Sendable (_ error: Error, _ send: Send) async -> Void)? = nil, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> Self { withEscapedDependencies { escaped in Self( @@ -98,7 +99,7 @@ extension Effect { return } catch { guard let handler else { - runtimeWarn( + reportIssue( """ An "Effect.run" returned from "\(fileID):\(line)" threw an unhandled error. … @@ -106,7 +107,11 @@ extension Effect { All non-cancellation errors must be explicitly handled via the "catch" parameter \ on "Effect.run", or via a "do" block. - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return } diff --git a/Sources/ComposableArchitecture/Effects/TaskResult.swift b/Sources/ComposableArchitecture/Effects/TaskResult.swift index 7ef1ae47a74a..5c865d57af80 100644 --- a/Sources/ComposableArchitecture/Effects/TaskResult.swift +++ b/Sources/ComposableArchitecture/Effects/TaskResult.swift @@ -1,5 +1,3 @@ -import XCTestDynamicOverlay - /// A value that represents either a success or a failure. This type differs from Swift's `Result` /// type in that it uses only one generic for the success case, leaving the failure case as an /// untyped `Error`. @@ -271,7 +269,7 @@ extension TaskResult: Equatable where Success: Equatable { let lhsType = type(of: lhs) if TaskResultDebugging.emitRuntimeWarnings, lhsType == type(of: rhs) { let lhsTypeName = typeName(lhsType) - runtimeWarn( + reportIssue( """ "\(lhsTypeName)" is not equatable. … @@ -307,7 +305,7 @@ extension TaskResult: Hashable where Success: Hashable { #if DEBUG if TaskResultDebugging.emitRuntimeWarnings { let errorType = typeName(type(of: error)) - runtimeWarn( + reportIssue( """ "\(errorType)" is not hashable. … diff --git a/Sources/ComposableArchitecture/Internal/HashableStaticString.swift b/Sources/ComposableArchitecture/Internal/HashableStaticString.swift new file mode 100644 index 000000000000..2a1fbeae3deb --- /dev/null +++ b/Sources/ComposableArchitecture/Internal/HashableStaticString.swift @@ -0,0 +1,37 @@ +public struct _HashableStaticString: RawRepresentable { + public let rawValue: StaticString + + public init(rawValue: StaticString) { + self.rawValue = rawValue + } +} + +extension _HashableStaticString: ExpressibleByStringLiteral { + public init(stringLiteral value: StaticString) { + self.init(rawValue: value) + } +} + +extension _HashableStaticString: CustomStringConvertible { + public var description: String { + rawValue.description + } +} + +extension _HashableStaticString: CustomDebugStringConvertible { + public var debugDescription: String { + rawValue.debugDescription + } +} + +extension _HashableStaticString: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.description == rhs.description + } +} + +extension _HashableStaticString: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(description) + } +} diff --git a/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift b/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift index 05efbfc208c8..1370525142b4 100644 --- a/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift +++ b/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift @@ -1,4 +1,5 @@ import Foundation +import IssueReporting extension Notification.Name { @_documentation(visibility:private) @@ -8,78 +9,3 @@ extension Notification.Name { /// A notification that is posted when a runtime warning is emitted. public static let _runtimeWarning = Self("ComposableArchitecture.runtimeWarning") } - -@_transparent -@usableFromInline -@inline(__always) -func runtimeWarn( - _ message: @autoclosure () -> String, - category: String? = "ComposableArchitecture" -) { - #if DEBUG - let message = message() - NotificationCenter.default.post( - name: ._runtimeWarning, - object: nil, - userInfo: ["message": message] - ) - let category = category ?? "Runtime Warning" - if _XCTIsTesting { - XCTFail(message) - } else { - #if canImport(os) - os_log( - .fault, - dso: dso, - log: OSLog(subsystem: "com.apple.runtime-issues", category: category), - "%@", - message - ) - #else - fputs("\(formatter.string(from: Date())) [\(category)] \(message)\n", stderr) - #endif - } - #else - if _XCTIsTesting { - XCTFail(message()) - } - #endif -} - -#if DEBUG - import XCTestDynamicOverlay - - #if canImport(os) - import os - - // NB: Xcode runtime warnings offer a much better experience than traditional assertions and - // breakpoints, but Apple provides no means of creating custom runtime warnings ourselves. - // To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead. - // - // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc - @usableFromInline - let dso = { () -> UnsafeMutableRawPointer in - let count = _dyld_image_count() - for i in 0..( state: KeyPath>, - action: CaseKeyPath> + action: CaseKeyPath>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some RandomAccessCollection> { if !self.canCacheChildren { - runtimeWarn(uncachedStoreWarning(self)) + reportIssue( + uncachedStoreWarning(self), + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) } return _StoreCollection(self.scope(state: state, action: action)) } diff --git a/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift b/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift index 0639d3e5199b..c0de53de7eea 100644 --- a/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift @@ -111,16 +111,32 @@ import SwiftUI root: () -> R, @ViewBuilder destination: @escaping (Store) -> Destination, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) where Data == StackState.PathView, Root == ModifiedContent> { - self.init(path: path[fileID: "\(fileID)", line: line]) { + self.init( + path: path[ + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ] + ) { root() .modifier( - _NavigationDestinationViewModifier(store: path.wrappedValue, destination: destination) + _NavigationDestinationViewModifier( + store: path.wrappedValue, + destination: destination, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) ) } } @@ -134,6 +150,10 @@ import SwiftUI { @SwiftUI.State var store: Store, StackAction> fileprivate let destination: (Store) -> Destination + fileprivate let fileID: StaticString + fileprivate let filePath: StaticString + fileprivate let line: UInt + fileprivate let column: UInt public func body(content: Content) -> some View { content @@ -143,7 +163,16 @@ import SwiftUI self .destination( self.store.scope( - id: self.store.id(state: \.[id:component.id], action: \.[id:component.id]), + id: self.store.id( + state: \.[ + id:component.id, + fileID:_HashableStaticString(rawValue: fileID), + filePath:_HashableStaticString(rawValue: filePath), + line:line, + column:column + ], + action: \.[id:component.id] + ), state: ToState { element = $0[id: component.id] ?? element return element @@ -176,13 +205,20 @@ import SwiftUI state: P?, @ViewBuilder label: () -> L, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) where Label == _NavigationLinkStoreContent { @Dependency(\.stackElementID) var stackElementID self.init(value: state.map { StackState.Component(id: stackElementID(), element: $0) }) { _NavigationLinkStoreContent( - state: state, label: { label() }, fileID: fileID, line: line + state: state, + label: label, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } } @@ -234,43 +270,58 @@ import SwiftUI let state: State? @ViewBuilder let label: Label let fileID: StaticString + let filePath: StaticString let line: UInt + let column: UInt @Environment(\.navigationDestinationType) var navigationDestinationType @_spi(Internals) - public init(state: State?, @ViewBuilder label: () -> Label, fileID: StaticString, line: UInt) { + public init( + state: State?, + @ViewBuilder label: () -> Label, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { self.state = state self.label = label() self.fileID = fileID + self.filePath = filePath self.line = line + self.column = column } public var body: some View { #if DEBUG - self.label.onAppear { - if self.navigationDestinationType != State.self { + label.onAppear { + if navigationDestinationType != State.self { let elementType = - self.navigationDestinationType.map { typeName($0) } + navigationDestinationType.map { typeName($0) } ?? """ (None found in view hierarchy. Is this link inside a store-powered \ 'NavigationStack'?) """ - runtimeWarn( + reportIssue( """ - A navigation link at "\(self.fileID):\(self.line)" is unpresentable. … + A navigation link at "\(fileID):\(line)" is unpresentable. … NavigationStack state element type: \(elementType) NavigationLink state type: \(typeName(State.self)) NavigationLink state value: - \(String(customDumping: self.state).indent(by: 2)) - """ + \(String(customDumping: state).indent(by: 2)) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } } #else - self.label + label #endif } } @@ -297,20 +348,22 @@ import SwiftUI extension Store { @_spi(Internals) public subscript( - fileID fileID: String, - line line: UInt + fileID fileID: _HashableStaticString, + filePath filePath: _HashableStaticString, + line line: UInt, + column column: UInt ) -> StackState.PathView where State == StackState, Action == StackAction { get { self.currentState.path } set { let newCount = newValue.count guard newCount != self.currentState.count else { - runtimeWarn( + reportIssue( """ - SwiftUI wrote to a "NavigationStack" binding at "\(fileID):\(line)" with a path that \ - has the same number of elements that already exist in the store. SwiftUI should only \ - write to this binding with a path that has pushed a new element onto the stack, or \ - popped one or more elements from the stack. + SwiftUI wrote to a "NavigationStack" binding at "\(fileID.rawValue):\(line)" with a \ + path that has the same number of elements that already exist in the store. SwiftUI \ + should only write to this binding with a path that has pushed a new element onto the \ + stack, or popped one or more elements from the stack. This usually means the "forEach" has not been integrated with the reducer powering the \ store, and this reducer is responsible for handling stack actions. @@ -326,7 +379,11 @@ import SwiftUI And ensure that every parent reducer is integrated into the root reducer that powers \ the store. - """ + """, + fileID: fileID.rawValue, + filePath: filePath.rawValue, + line: line, + column: column ) return } diff --git a/Sources/ComposableArchitecture/Observation/Store+Observation.swift b/Sources/ComposableArchitecture/Observation/Store+Observation.swift index d06254bcb661..4cd935093843 100644 --- a/Sources/ComposableArchitecture/Observation/Store+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/Store+Observation.swift @@ -79,10 +79,20 @@ /// - Returns: An optional store of non-optional child state and actions. public func scope( state: KeyPath, - action: CaseKeyPath + action: CaseKeyPath, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> Store? { if !self.canCacheChildren { - runtimeWarn(uncachedStoreWarning(self)) + reportIssue( + uncachedStoreWarning(self), + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) } guard var childState = self.state[keyPath: state] else { return nil } @@ -149,15 +159,19 @@ state: KeyPath, action: CaseKeyPath>, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #fileID, + line: UInt = #line, + column: UInt = #column ) -> Binding?> where Value == Store { self[ state: state, action: action, isInViewBody: _isInPerceptionTracking, - fileID: "\(fileID)", - line: line + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column ] } } @@ -214,15 +228,19 @@ state: KeyPath, action: CaseKeyPath>, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #fileID, + line: UInt = #line, + column: UInt = #column ) -> Binding?> where Value == Store { self[ state: state, action: action, isInViewBody: _isInPerceptionTracking, - fileID: "\(fileID)", - line: line + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column ] } } @@ -282,15 +300,19 @@ state: KeyPath, action: CaseKeyPath>, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> Binding?> where Value == Store { self[ state: state, action: action, isInViewBody: _isInPerceptionTracking, - fileID: "\(fileID)", - line: line + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column ] } } @@ -301,23 +323,39 @@ state state: KeyPath, action action: CaseKeyPath>, isInViewBody isInViewBody: Bool, - fileID fileID: String, - line line: UInt + fileID fileID: _HashableStaticString, + filePath filePath: _HashableStaticString, + line line: UInt, + column column: UInt ) -> Store? { get { #if DEBUG && !os(visionOS) _PerceptionLocals.$isInPerceptionTracking.withValue(isInViewBody) { - self.scope(state: state, action: action.appending(path: \.presented)) + self.scope( + state: state, + action: action.appending(path: \.presented), + fileID: fileID.rawValue, + filePath: filePath.rawValue, + line: line, + column: column + ) } #else - self.scope(state: state, action: action.appending(path: \.presented)) + self.scope( + state: state, + action: action.appending(path: \.presented), + fileID: fileID.rawValue, + filePath: filePath.rawValue, + line: line, + column: column + ) #endif } set { if newValue == nil, self.state[keyPath: state] != nil, !self._isInvalidated() { self.send(action(.dismiss)) if self.state[keyPath: state] != nil { - runtimeWarn( + reportIssue( """ SwiftUI dismissed a view through a binding at "\(fileID):\(line)", but the store \ destination wasn't set to "nil". @@ -336,7 +374,11 @@ And ensure that every parent reducer is integrated into the root reducer that powers \ the store. - """ + """, + fileID: fileID.rawValue, + filePath: filePath.rawValue, + line: line, + column: column ) return } diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/ForEachReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/ForEachReducer.swift index 6fb739ddf7d9..048bc15e0f33 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/ForEachReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/ForEachReducer.swift @@ -120,7 +120,9 @@ extension Reducer { action toElementAction: CaseKeyPath>, @ReducerBuilder element: () -> Element, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some Reducer { _ForEachReducer( parent: self, @@ -128,7 +130,9 @@ extension Reducer { toElementAction: AnyCasePath(toElementAction.appending(path: \.element)), element: element(), fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } @@ -165,7 +169,9 @@ extension Reducer { action toElementAction: AnyCasePath, @ReducerBuilder element: () -> Element, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some Reducer { _ForEachReducer( parent: self, @@ -176,7 +182,9 @@ extension Reducer { ), element: element(), fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } } @@ -199,9 +207,15 @@ public struct _ForEachReducer< @usableFromInline let fileID: StaticString + @usableFromInline + let filePath: StaticString + @usableFromInline let line: UInt + @usableFromInline + let column: UInt + @Dependency(\.navigationIDPath) var navigationIDPath @usableFromInline @@ -211,14 +225,18 @@ public struct _ForEachReducer< toElementAction: AnyCasePath, element: Element, fileID: StaticString, - line: UInt + filePath: StaticString, + line: UInt, + column: UInt ) { self.parent = parent self.toElementsState = toElementsState self.toElementAction = toElementAction self.element = element self.fileID = fileID + self.filePath = filePath self.line = line + self.column = column } public func reduce( @@ -254,7 +272,7 @@ public struct _ForEachReducer< ) -> Effect { guard let (id, elementAction) = self.toElementAction.extract(from: action) else { return .none } if state[keyPath: self.toElementsState][id: id] == nil { - runtimeWarn( + reportIssue( """ A "forEach" at "\(self.fileID):\(self.line)" received an action for a missing element. … @@ -274,7 +292,11 @@ public struct _ForEachReducer< • This action was sent to the store while its state contained no element at this ID. To \ fix this make sure that actions for this reducer can only be sent from a view store when \ its state contains an element at this id. In SwiftUI applications, use "ForEachStore". - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return .none } diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift index 13922df6d5db..e89a7a906392 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift @@ -57,7 +57,9 @@ extension Reducer { action toCaseAction: CaseKeyPath, @ReducerBuilder then case: () -> Case, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> _IfCaseLetReducer where State: CasePathable, @@ -71,7 +73,9 @@ extension Reducer { toChildState: AnyCasePath(toCaseState), toChildAction: AnyCasePath(toCaseAction), fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } @@ -106,7 +110,9 @@ extension Reducer { action toCaseAction: AnyCasePath, @ReducerBuilder then case: () -> Case, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some Reducer { _IfCaseLetReducer( parent: self, @@ -114,7 +120,9 @@ extension Reducer { toChildState: toCaseState, toChildAction: toCaseAction, fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } } @@ -135,9 +143,15 @@ public struct _IfCaseLetReducer: Reducer { @usableFromInline let fileID: StaticString + @usableFromInline + let filePath: StaticString + @usableFromInline let line: UInt + @usableFromInline + let column: UInt + @Dependency(\.navigationIDPath) var navigationIDPath @usableFromInline @@ -147,14 +161,18 @@ public struct _IfCaseLetReducer: Reducer { toChildState: AnyCasePath, toChildAction: AnyCasePath, fileID: StaticString, - line: UInt + filePath: StaticString, + line: UInt, + column: UInt ) { self.parent = parent self.child = child self.toChildState = toChildState self.toChildAction = toChildAction self.fileID = fileID + self.filePath = filePath self.line = line + self.column = column } public func reduce( @@ -190,7 +208,7 @@ public struct _IfCaseLetReducer: Reducer { guard let childAction = self.toChildAction.extract(from: action) else { return .none } guard var childState = self.toChildState.extract(from: state) else { - runtimeWarn( + reportIssue( """ An "ifCaseLet" at "\(self.fileID):\(self.line)" received a child action when child state \ was set to a different case. … @@ -214,7 +232,11 @@ public struct _IfCaseLetReducer: Reducer { • This action was sent to the store while state was another case. Make sure that actions \ for this reducer can only be sent from a view store when state is set to the appropriate \ case. In SwiftUI applications, use "SwitchStore". - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return .none } diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/IfLetReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/IfLetReducer.swift index ae7c3e840ea0..5cfcc8042640 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/IfLetReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/IfLetReducer.swift @@ -57,7 +57,9 @@ extension Reducer { action toWrappedAction: CaseKeyPath, @ReducerBuilder then wrapped: () -> Wrapped, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some Reducer { _IfLetReducer( parent: self, @@ -65,7 +67,9 @@ extension Reducer { toChildState: toWrappedState, toChildAction: AnyCasePath(toWrappedAction), fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } @@ -77,7 +81,9 @@ extension Reducer { _ toWrappedState: WritableKeyPath, action toWrappedAction: CaseKeyPath, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some Reducer { _IfLetReducer( parent: self, @@ -85,7 +91,9 @@ extension Reducer { toChildState: toWrappedState, toChildAction: AnyCasePath(toWrappedAction), fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } @@ -120,7 +128,9 @@ extension Reducer { action toWrappedAction: AnyCasePath, @ReducerBuilder then wrapped: () -> Wrapped, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some Reducer { _IfLetReducer( parent: self, @@ -128,7 +138,9 @@ extension Reducer { toChildState: toWrappedState, toChildAction: toWrappedAction, fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } @@ -162,7 +174,9 @@ extension Reducer { _ toWrappedState: WritableKeyPath, action toWrappedAction: AnyCasePath, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> _IfLetReducer> { .init( parent: self, @@ -170,7 +184,9 @@ extension Reducer { toChildState: toWrappedState, toChildAction: toWrappedAction, fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } } @@ -191,9 +207,15 @@ public struct _IfLetReducer: Reducer { @usableFromInline let fileID: StaticString + @usableFromInline + let filePath: StaticString + @usableFromInline let line: UInt + @usableFromInline + let column: UInt + @Dependency(\.navigationIDPath) var navigationIDPath @usableFromInline @@ -203,14 +225,18 @@ public struct _IfLetReducer: Reducer { toChildState: WritableKeyPath, toChildAction: AnyCasePath, fileID: StaticString, - line: UInt + filePath: StaticString, + line: UInt, + column: UInt ) { self.parent = parent self.child = child self.toChildState = toChildState self.toChildAction = toChildAction self.fileID = fileID + self.filePath = filePath self.line = line + self.column = column } public func reduce( @@ -254,7 +280,7 @@ public struct _IfLetReducer: Reducer { guard let childAction = self.toChildAction.extract(from: action) else { return .none } guard state[keyPath: self.toChildState] != nil else { - runtimeWarn( + reportIssue( """ An "ifLet" at "\(self.fileID):\(self.line)" received a child action when child state was \ "nil". … @@ -275,7 +301,11 @@ public struct _IfLetReducer: Reducer { • This action was sent to the store while state was "nil". Make sure that actions for this \ reducer can only be sent from a view store when state is non-"nil". In SwiftUI \ applications, use "IfLetStore". - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return .none } diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift index fd41ac5c2133..f4780a885182 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift @@ -145,7 +145,13 @@ public struct PresentationState { message: "Use the version of this subscript with case key paths, instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) - public subscript(case path: AnyCasePath) -> Case? { + public subscript( + case path: AnyCasePath, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> Case? { _read { yield self.wrappedValue.flatMap(path.extract) } _modify { let root = self.wrappedValue @@ -160,10 +166,14 @@ public struct PresentationState { { description = caseName } - runtimeWarn( + reportIssue( """ Can't modify unrelated case\(description.map { " \($0.debugDescription)" } ?? "") - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return } @@ -398,7 +408,9 @@ extension Reducer { action toPresentationAction: CaseKeyPath>, @ReducerBuilder destination: () -> Destination, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some Reducer { _PresentationReducer( base: self, @@ -406,7 +418,9 @@ extension Reducer { toPresentationAction: AnyCasePath(toPresentationAction), destination: destination(), fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } @@ -418,14 +432,18 @@ extension Reducer { _ toPresentationState: WritableKeyPath>, action toPresentationAction: CaseKeyPath>, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some Reducer { self.ifLet( toPresentationState, action: toPresentationAction, destination: {}, fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } @@ -462,7 +480,9 @@ extension Reducer { action toPresentationAction: AnyCasePath>, @ReducerBuilder destination: () -> Destination, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some Reducer { _PresentationReducer( base: self, @@ -470,7 +490,9 @@ extension Reducer { toPresentationAction: toPresentationAction, destination: destination(), fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } @@ -504,14 +526,18 @@ extension Reducer { _ toPresentationState: WritableKeyPath>, action toPresentationAction: AnyCasePath>, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some Reducer { self.ifLet( toPresentationState, action: toPresentationAction, destination: {}, fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } } @@ -524,7 +550,9 @@ public struct _PresentationReducer: Reducer AnyCasePath> @usableFromInline let destination: Destination @usableFromInline let fileID: StaticString + @usableFromInline let filePath: StaticString @usableFromInline let line: UInt + @usableFromInline let column: UInt @Dependency(\.navigationIDPath) var navigationIDPath @@ -535,14 +563,18 @@ public struct _PresentationReducer: Reducer toPresentationAction: AnyCasePath>, destination: Destination, fileID: StaticString, - line: UInt + filePath: StaticString, + line: UInt, + column: UInt ) { self.base = base self.toPresentationState = toPresentationState self.toPresentationAction = toPresentationAction self.destination = destination self.fileID = fileID + self.filePath = filePath self.line = line + self.column = column } public func reduce(into state: inout Base.State, action: Base.Action) -> Effect { @@ -591,7 +623,7 @@ public struct _PresentationReducer: Reducer baseEffects = self.base.reduce(into: &state, action: action) case (.none, .some): - runtimeWarn( + reportIssue( """ An "ifLet" at "\(self.fileID):\(self.line)" received a presentation action when \ destination state was absent. … @@ -610,7 +642,11 @@ public struct _PresentationReducer: Reducer actions for this reducer can only be sent from a view store when state is present, or \ from effects that start from this reducer. In SwiftUI applications, use a Composable \ Architecture view modifier like "sheet(store:…)". - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) destinationEffects = .none baseEffects = self.base.reduce(into: &state, action: action) diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/Scope.swift b/Sources/ComposableArchitecture/Reducer/Reducers/Scope.swift index 24237061f129..ba2f6cf32d15 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/Scope.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/Scope.swift @@ -104,7 +104,9 @@ public struct Scope: Reducer { case casePath( AnyCasePath, fileID: StaticString, - line: UInt + filePath: StaticString, + line: UInt, + column: UInt ) case keyPath(WritableKeyPath) } @@ -228,10 +230,18 @@ public struct Scope: Reducer { action toChildAction: CaseKeyPath, @ReducerBuilder child: () -> Child, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) where ChildState == Child.State, ChildAction == Child.Action { self.init( - toChildState: .casePath(AnyCasePath(toChildState), fileID: fileID, line: line), + toChildState: .casePath( + AnyCasePath(toChildState), + fileID: fileID, + filePath: filePath, + line: line, + column: column + ), toChildAction: AnyCasePath(toChildAction), child: child() ) @@ -304,10 +314,18 @@ public struct Scope: Reducer { action toChildAction: AnyCasePath, @ReducerBuilder child: () -> Child, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) where ChildState == Child.State, ChildAction == Child.Action { self.init( - toChildState: .casePath(toChildState, fileID: fileID, line: line), + toChildState: .casePath( + toChildState, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ), toChildAction: toChildAction, child: child() ) @@ -320,9 +338,9 @@ public struct Scope: Reducer { guard let childAction = self.toChildAction.extract(from: action) else { return .none } switch self.toChildState { - case let .casePath(toChildState, fileID, line): + case let .casePath(toChildState, fileID, filePath, line, column): guard var childState = toChildState.extract(from: state) else { - runtimeWarn( + reportIssue( """ A "Scope" at "\(fileID):\(line)" received a child action when child state was set to a \ different case. … @@ -349,7 +367,11 @@ public struct Scope: Reducer { • This action was sent to the store while state was another case. Make sure that actions \ for this reducer can only be sent from a view store when state is set to the appropriate \ case. In SwiftUI applications, use "SwitchStore". - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return .none } diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift index 2496f39fe268..ab6d0d08ad26 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift @@ -36,7 +36,13 @@ public struct StackState { } /// Accesses the value associated with the given id for reading and writing. - public subscript(id id: StackElementID) -> Element? { + public subscript( + id id: StackElementID, + fileID fileID: _HashableStaticString = #fileID, + filePath filePath: _HashableStaticString = #filePath, + line line: UInt = #line, + column column: UInt = #column + ) -> Element? { _read { yield self._dictionary[id] } _modify { yield &self._dictionary[id] } set { @@ -45,7 +51,13 @@ public struct StackState { self._dictionary[id] = newValue case (false, .some, false): if !_XCTIsTesting { - runtimeWarn("Can't assign element at missing ID.") + reportIssue( + "Can't assign element at missing ID.", + fileID: fileID.rawValue, + filePath: filePath.rawValue, + line: line, + column: column + ) } case (false, .none, _): break @@ -53,6 +65,35 @@ public struct StackState { } } +// subscript( +// id id: StackElementID, +// fileID fileID: HashableStaticString, +// filePath filePath: HashableStaticString, +// line line: UInt = #line, +// column column: UInt = #column +// ) -> Element? { +// _read { yield self._dictionary[id] } +// _modify { yield &self._dictionary[id] } +// set { +// switch (self.ids.contains(id), newValue, _XCTIsTesting) { +// case (true, _, _), (false, .some, true): +// self._dictionary[id] = newValue +// case (false, .some, false): +// if !_XCTIsTesting { +// reportIssue( +// "Can't assign element at missing ID.", +// fileID: fileID.rawValue, +// filePath: filePath.rawValue, +// line: line, +// column: column +// ) +// } +// case (false, .none, _): +// break +// } +// } +// } + /// Accesses the value associated with the given id and case for reading and writing. /// /// When using stack-based navigation (see ) you will typically have a @@ -105,7 +146,14 @@ public struct StackState { message: "Use the version of this subscript with case key paths, instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) - public subscript(id id: StackElementID, case path: AnyCasePath) -> Case? { + public subscript( + id id: StackElementID, + case path: AnyCasePath, + fileID fileID: _HashableStaticString = #fileID, + filePath filePath: _HashableStaticString = #filePath, + line line: UInt = #line, + column column: UInt = #column + ) -> Case? { _read { yield self[id: id].flatMap(path.extract) } _modify { let root = self[id: id] @@ -120,10 +168,14 @@ public struct StackState { { description = caseName } - runtimeWarn( + reportIssue( """ Can't modify unrelated case\(description.map { " \($0.debugDescription)" } ?? "") - """ + """, + fileID: fileID.rawValue, + filePath: filePath.rawValue, + line: line, + column: column ) return } @@ -356,7 +408,9 @@ extension Reducer { action toStackAction: CaseKeyPath>, @ReducerBuilder destination: () -> Destination, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some Reducer { _StackReducer( base: self, @@ -364,7 +418,9 @@ extension Reducer { toStackAction: AnyCasePath(toStackAction), destination: destination(), fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } @@ -401,7 +457,9 @@ extension Reducer { action toStackAction: AnyCasePath>, @ReducerBuilder destination: () -> Destination, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) -> some Reducer { _StackReducer( base: self, @@ -409,7 +467,9 @@ extension Reducer { toStackAction: toStackAction, destination: destination(), fileID: fileID, - line: line + filePath: filePath, + line: line, + column: column ) } } @@ -435,7 +495,9 @@ public struct _StackReducer: Reducer { let toStackAction: AnyCasePath> let destination: Destination let fileID: StaticString + let filePath: StaticString let line: UInt + let column: UInt @Dependency(\.navigationIDPath) var navigationIDPath @@ -446,14 +508,18 @@ public struct _StackReducer: Reducer { toStackAction: AnyCasePath>, destination: Destination, fileID: StaticString, - line: UInt + filePath: StaticString, + line: UInt, + column: UInt ) { self.base = base self.toStackState = toStackState self.toStackAction = toStackAction self.destination = destination self.fileID = fileID + self.filePath = filePath self.line = line + self.column = column } public func reduce(into state: inout Base.State, action: Base.Action) -> Effect { @@ -483,7 +549,7 @@ public struct _StackReducer: Reducer { .map { toStackAction.embed(.element(id: elementID, action: $0)) } ._cancellable(navigationIDPath: elementNavigationIDPath) } else { - runtimeWarn( + reportIssue( """ A "forEach" at "\(self.fileID):\(self.line)" received an action for a missing element. … @@ -504,7 +570,11 @@ public struct _StackReducer: Reducer { fix this make sure that actions for this reducer can only be sent from a view store when \ its state contains an element at this id. In SwiftUI applications, use \ "NavigationStack.init(path:)" with a binding to a store. - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) destinationEffects = .none } @@ -518,7 +588,7 @@ public struct _StackReducer: Reducer { if canPop { state[keyPath: self.toStackState].pop(from: id) } else { - runtimeWarn( + reportIssue( """ A "forEach" at "\(self.fileID):\(self.line)" received a "popFrom" action for a missing \ element. … @@ -527,14 +597,18 @@ public struct _StackReducer: Reducer { \(id) Path IDs: \(state[keyPath: self.toStackState].ids) - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } case let .push(id, element): destinationEffects = .none if state[keyPath: self.toStackState].ids.contains(id) { - runtimeWarn( + reportIssue( """ A "forEach" at "\(self.fileID):\(self.line)" received a "push" action for an element it \ already contains. … @@ -543,14 +617,18 @@ public struct _StackReducer: Reducer { \(id) Path IDs: \(state[keyPath: self.toStackState].ids) - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) baseEffects = self.base.reduce(into: &state, action: action) break } else if DependencyValues._current.context == .test { let nextID = DependencyValues._current.stackElementID.peek() if id.generation > nextID.generation { - runtimeWarn( + reportIssue( """ A "forEach" at "\(self.fileID):\(self.line)" received a "push" action with an \ unexpected generational ID. … @@ -559,7 +637,11 @@ public struct _StackReducer: Reducer { \(id) Expected ID: \(nextID) - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } else if id.generation == nextID.generation { _ = DependencyValues._current.stackElementID.next() diff --git a/Sources/ComposableArchitecture/RootStore.swift b/Sources/ComposableArchitecture/RootStore.swift index 59eeba253325..b0a174ada3b4 100644 --- a/Sources/ComposableArchitecture/RootStore.swift +++ b/Sources/ComposableArchitecture/RootStore.swift @@ -106,7 +106,7 @@ public final class RootStore { await operation( Send { effectAction in if isCompleted.value { - runtimeWarn( + reportIssue( """ An action was sent from a completed effect: @@ -173,7 +173,7 @@ public final class RootStore { switch status { case let .effectCompletion(action): - runtimeWarn( + reportIssue( """ An effect completed on a non-main thread. … @@ -190,7 +190,7 @@ public final class RootStore { ) case .`init`: - runtimeWarn( + reportIssue( """ A store initialized on a non-main thread. … @@ -201,7 +201,7 @@ public final class RootStore { ) case .scope: - runtimeWarn( + reportIssue( """ "Store.scope" was called on a non-main thread. … @@ -212,7 +212,7 @@ public final class RootStore { ) case let .send(action, originatingAction: nil): - runtimeWarn( + reportIssue( """ "Store.send" was called on a non-main thread with: \(debugCaseOutput(action)) … @@ -223,7 +223,7 @@ public final class RootStore { ) case let .send(action, originatingAction: .some(originatingAction)): - runtimeWarn( + reportIssue( """ An effect published an action on a non-main thread. … @@ -243,7 +243,7 @@ public final class RootStore { ) case .state: - runtimeWarn( + reportIssue( """ Store state was accessed on a non-main thread. … @@ -260,6 +260,7 @@ public final class RootStore { } #endif +// TODO: Should this traffic file/line through to `reportIssue`? enum ThreadCheckStatus { case effectCompletion(Any) case `init` diff --git a/Sources/ComposableArchitecture/SharedState/Shared.swift b/Sources/ComposableArchitecture/SharedState/Shared.swift index 28d6a7645bb1..0f0d2690b478 100644 --- a/Sources/ComposableArchitecture/SharedState/Shared.swift +++ b/Sources/ComposableArchitecture/SharedState/Shared.swift @@ -1,6 +1,6 @@ import CustomDump import Dependencies -import XCTestDynamicOverlay +import IssueReporting #if canImport(Combine) import Combine @@ -185,8 +185,10 @@ public struct Shared { public func assert( _ updateValueToExpectedResult: (inout Value) throws -> Void, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) rethrows where Value: Equatable { @Dependency(\.sharedChangeTrackers) var changeTrackers guard @@ -194,18 +196,35 @@ public struct Shared { changeTrackers .first(where: { $0.changes[ObjectIdentifier(self.reference)] != nil }) else { - XCTFail("Expected changes, but none occurred.", file: file, line: line) + reportIssue( + "Expected changes, but none occurred.", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) return } try changeTracker.assert { guard var snapshot = self.snapshot, snapshot != self.currentValue else { - XCTFail("Expected changes, but none occurred.", file: file, line: line) + reportIssue( + "Expected changes, but none occurred.", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) return } try updateValueToExpectedResult(&snapshot) self.snapshot = snapshot // TODO: Finesse error more than `XCTAssertNoDifference` - XCTAssertNoDifference(self.currentValue, self.snapshot, file: file, line: line) + XCTAssertNoDifference( + self.currentValue, + self.snapshot, + file: filePath, + line: line + ) self.snapshot = nil } } diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index b21d23fbe37b..2ae3ae5a0253 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -45,19 +45,25 @@ public struct BindingState { public var wrappedValue: Value #if DEBUG let fileID: StaticString + let filePath: StaticString let line: UInt + let column: UInt #endif /// Creates bindable state from the value of another bindable state. public init( wrappedValue: Value, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { self.wrappedValue = wrappedValue #if DEBUG self.fileID = fileID + self.filePath = filePath self.line = line + self.column = column #endif } @@ -309,7 +315,9 @@ extension ViewStore where ViewAction: BindableAction, ViewAction.State == ViewSt context: .bindingState, isInvalidated: self.store._isInvalidated, fileID: bindingState.fileID, - line: bindingState.line + filePath: bindingState.filePath, + line: bindingState.line, + column: bindingState.column ) let set: @Sendable (inout ViewState) -> Void = { $0[keyPath: keyPath].wrappedValue = value @@ -399,13 +407,17 @@ public struct BindingViewStore { #if DEBUG let bindableActionType: Any.Type let fileID: StaticString + let filePath: StaticString let line: UInt + let column: UInt #endif init>( store: Store, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { self.store = store.scope( id: nil, @@ -416,7 +428,9 @@ public struct BindingViewStore { #if DEBUG self.bindableActionType = type(of: Action.self) self.fileID = fileID + self.filePath = filePath self.line = line + self.column = column #endif } @@ -451,7 +465,9 @@ public struct BindingViewStore { context: .bindingStore, isInvalidated: self.store._isInvalidated, fileID: self.fileID, - line: self.line + filePath: self.filePath, + line: self.line, + column: self.column ) let set: @Sendable (inout State) -> Void = { $0[keyPath: keyPath].wrappedValue = value @@ -734,7 +750,9 @@ extension WithViewStore where ViewState: Equatable, Content: View { let context: Context let isInvalidated: () -> Bool let fileID: StaticString + let filePath: StaticString let line: UInt + let column: UInt var wasCalled = false init( @@ -743,14 +761,18 @@ extension WithViewStore where ViewState: Equatable, Content: View { context: Context, isInvalidated: @escaping () -> Bool, fileID: StaticString, - line: UInt + filePath: StaticString, + line: UInt, + column: UInt ) { self.value = value self.bindableActionType = bindableActionType self.context = context self.isInvalidated = isInvalidated self.fileID = fileID + self.filePath = filePath self.line = line + self.column = column } deinit { @@ -764,7 +786,7 @@ extension WithViewStore where ViewState: Equatable, Content: View { guard self.wasCalled else { var value = "" customDump(self.value, to: &value, maxDepth: 0) - runtimeWarn( + reportIssue( """ A binding action sent from a store \ \(self.context == .bindingState ? "for binding state defined " : "")at \ @@ -774,7 +796,11 @@ extension WithViewStore where ViewState: Equatable, Content: View { \(typeName(self.bindableActionType)).binding(.set(_, \(value))) To fix this, invoke "BindingReducer()" from your feature reducer's "body". - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return } diff --git a/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift b/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift index 4bec158d1f1b..71044e6a4429 100644 --- a/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift @@ -45,7 +45,11 @@ public struct NavigationStackStore public init( _ store: Store, StackAction>, @ViewBuilder root: () -> Root, - @ViewBuilder destination: @escaping (_ store: Store) -> Destination + @ViewBuilder destination: @escaping (_ store: Store) -> Destination, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { self.root = root() self.destination = { component in @@ -53,7 +57,16 @@ public struct NavigationStackStore return destination( store .scope( - id: store.id(state: \.[id:component.id]!, action: \.[id:component.id]), + id: store.id( + state: \.[ + id:component.id, + fileID:_HashableStaticString(rawValue: fileID), + filePath:_HashableStaticString(rawValue: filePath), + line:line, + column:column + ]!, + action: \.[id:component.id] + ), state: ToState { element = $0[id: component.id] ?? element return element @@ -84,7 +97,11 @@ public struct NavigationStackStore public init( _ store: Store, StackAction>, @ViewBuilder root: () -> Root, - @ViewBuilder destination: @escaping (_ initialState: State) -> D + @ViewBuilder destination: @escaping (_ initialState: State) -> D, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) where Destination == SwitchStore { self.root = root() self.destination = { component in @@ -92,7 +109,16 @@ public struct NavigationStackStore return SwitchStore( store .scope( - id: store.id(state: \.[id:component.id]!, action: \.[id:component.id]), + id: store.id( + state: \.[ + id:component.id, + fileID:_HashableStaticString(rawValue: fileID), + filePath:_HashableStaticString(rawValue: filePath), + line:line, + column:column + ]!, + action: \.[id:component.id] + ), state: ToState { element = $0[id: component.id] ?? element return element diff --git a/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift b/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift index ead7ea532fa5..aa5009adcff2 100644 --- a/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift @@ -122,7 +122,9 @@ public struct CaseLet) -> Content private let fileID: StaticString + private let filePath: StaticString private let line: UInt + private let column: UInt @EnvironmentObject private var store: StoreObservableObject @@ -140,13 +142,17 @@ public struct CaseLet EnumAction, @ViewBuilder then content: @escaping (_ store: Store) -> Content, fileID: StaticString = #fileID, - line: UInt = #line + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { self.toCaseState = toCaseState self.fromCaseAction = fromCaseAction self.content = content self.fileID = fileID + self.filePath = filePath self.line = line + self.column = column } public var body: some View { @@ -161,7 +167,9 @@ public struct CaseLet( fileID: self.fileID, - line: self.line + filePath: self.filePath, + line: self.line, + column: self.column ) } ) @@ -192,7 +200,9 @@ extension CaseLet where EnumAction == CaseAction { public struct _CaseLetMismatchView: View { @EnvironmentObject private var store: StoreObservableObject let fileID: StaticString + let filePath: StaticString let line: UInt + let column: UInt public var body: some View { #if DEBUG @@ -229,7 +239,9 @@ public struct _CaseLetMismatchView: View { .foregroundColor(.white) .padding() .background(Color.red.edgesIgnoringSafeArea(.all)) - .onAppear { runtimeWarn(message) } + .onAppear { + reportIssue(message, fileID: fileID, filePath: filePath, line: line, column: column) + } #else return EmptyView() #endif diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index 8e4e367ec8bc..0f01fdaa2f18 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -2,8 +2,9 @@ import Combine import ConcurrencyExtras import CustomDump +@_spi(Beta) import Dependencies +import IssueReporting import Foundation -import XCTestDynamicOverlay /// A testable runtime for a reducer. /// @@ -487,8 +488,10 @@ public final class TestStore { /// ``receive(_:timeout:assert:file:line:)-6325h`` and ``finish(timeout:file:line:)-53gi5``. public var timeout: UInt64 - private let file: StaticString + private let fileID: StaticString + private let filePath: StaticString private let line: UInt + private let column: UInt let reducer: TestReducer private let sharedChangeTracker: SharedChangeTracker private let store: Store.TestAction> @@ -516,21 +519,26 @@ public final class TestStore { reducer: () -> R, withDependencies prepareDependencies: (inout DependencyValues) -> Void = { _ in }, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) where State: Equatable, R.State == State, R.Action == Action { let sharedChangeTracker = SharedChangeTracker() - let reducer = XCTFailContext.$current.withValue(XCTFailContext(file: file, line: line)) { - Dependencies.withDependencies { - prepareDependencies(&$0) - $0.sharedChangeTrackers.insert(sharedChangeTracker) - } operation: { - TestReducer(Reduce(reducer()), initialState: initialState()) + let reducer = Dependencies.withDependencies { + if TestContext.current == .swiftTesting { + $0.resetCache() } + prepareDependencies(&$0) + $0.sharedChangeTrackers.insert(sharedChangeTracker) + } operation: { + TestReducer(Reduce(reducer()), initialState: initialState()) } - self.file = file + self.fileID = fileID + self.filePath = filePath self.line = line + self.column = column self.reducer = reducer self.store = Store(initialState: reducer.state) { reducer } self.timeout = 1 * NSEC_PER_SEC @@ -548,10 +556,13 @@ public final class TestStore { @MainActor public func finish( timeout duration: Duration, - file: StaticString = #file, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, line: UInt = #line ) async { - await self.finish(timeout: duration.nanoseconds, file: file, line: line) + await self.finish( + timeout: duration.nanoseconds, fileID: fileID, file: filePath, line: line, column: column + ) } /// Suspends until all in-flight effects have finished, or until it times out. @@ -566,10 +577,12 @@ public final class TestStore { @MainActor public func finish( timeout nanoseconds: UInt64? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { - self.assertNoReceivedActions(file: file, line: line) + self.assertNoReceivedActions(fileID: fileID, filePath: filePath, line: line, column: column) Task.cancel(id: OnFirstAppearID()) let nanoseconds = nanoseconds ?? self.timeout let start = DispatchTime.now().uptimeNanoseconds @@ -591,21 +604,23 @@ public final class TestStore { If you are not yet using a clock/scheduler, or can not use a clock/scheduler, \ \(timeoutMessage). """ - XCTFailHelper( + reportIssueHelper( """ Expected effects to finish, but there are still effects in-flight\ \(nanoseconds > 0 ? " after \(Double(nanoseconds)/Double(NSEC_PER_SEC)) seconds" : ""). \(suggestion) """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return } await Task.yield() } - self.assertNoSharedChanges(file: file, line: line) + self.assertNoSharedChanges(fileID: fileID, filePath: filePath, line: line, column: column) } deinit { @@ -614,10 +629,12 @@ public final class TestStore { } func completed() { - self.assertNoReceivedActions(file: self.file, line: self.line) + self.assertNoReceivedActions( + fileID: self.fileID, filePath: self.filePath, line: self.line, column: self.column + ) Task.cancel(id: OnFirstAppearID()) for effect in self.reducer.inFlightEffects { - XCTFailHelper( + reportIssueHelper( """ An effect returned for this action is still running. It must complete before the end of \ the test. … @@ -643,20 +660,32 @@ public final class TestStore { store.skipInFlightEffects()", or consider using a non-exhaustive test store: \ "store.exhaustivity = .off". """, - file: effect.action.file, - line: effect.action.line + fileID: effect.action.fileID, + filePath: effect.action.filePath, + line: effect.action.line, + column: effect.action.column ) } - self.assertNoSharedChanges(file: self.file, line: self.line) + self.assertNoSharedChanges( + fileID: self.fileID, + filePath: self.filePath, + line: self.line, + column: self.column + ) } - private func assertNoReceivedActions(file: StaticString, line: UInt) { + private func assertNoReceivedActions( + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { if !self.reducer.receivedActions.isEmpty { let actions = self.reducer.receivedActions .map(\.action) .map { " • " + debugCaseOutput($0, abbreviated: true) } .joined(separator: "\n") - XCTFailHelper( + reportIssueHelper( """ The store received \(self.reducer.receivedActions.count) unexpected \ action\(self.reducer.receivedActions.count == 1 ? "" : "s"): … @@ -668,13 +697,20 @@ public final class TestStore { by performing "await store.skipReceivedActions()", or consider using a non-exhaustive test \ store: "store.exhaustivity = .off". """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } } - private func assertNoSharedChanges(file: StaticString, line: UInt) { + private func assertNoSharedChanges( + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { // NB: This existential opening can go away if we can constrain 'State: Equatable' at the // 'TestStore' level, but for some reason this breaks DocC. if self.sharedChangeTracker.hasChanges, let stateType = State.self as? any Equatable.Type { @@ -690,8 +726,10 @@ public final class TestStore { actual: store.state, updateStateToExpectedResult: nil, skipUnnecessaryModifyFailure: true, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } open(stateType) @@ -860,26 +898,36 @@ extension TestStore where State: Equatable { public func send( _ action: Action, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async -> TestStoreTask { - await XCTFailContext.$current.withValue(XCTFailContext(file: file, line: line)) { + await withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.isDismissed else { - XCTFail("Can't send action to dismissed test store.", file: file, line: line) + reportIssue( + "Can't send action to dismissed test store.", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) return TestStoreTask(rawValue: nil, timeout: self.timeout) } if !self.reducer.receivedActions.isEmpty { var actions = "" customDump(self.reducer.receivedActions.map(\.action), to: &actions) - XCTFailHelper( + reportIssueHelper( """ Must handle \(self.reducer.receivedActions.count) received \ action\(self.reducer.receivedActions.count == 1 ? "" : "s") before sending an action: … Unhandled actions: \(actions) """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } @@ -897,7 +945,9 @@ extension TestStore where State: Equatable { let previousStackElementID = self.reducer.dependencies.stackElementID.incrementingCopy() let task = self.sharedChangeTracker.track { self.store.send( - .init(origin: .send(action), file: file, line: line), + .init( + origin: .send(action), fileID: fileID, filePath: filePath, line: line, column: column + ), originatingFrom: nil ) } @@ -922,11 +972,19 @@ extension TestStore where State: Equatable { expected: expectedState, actual: currentState, updateStateToExpectedResult: updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } catch { - XCTFail("Threw error: \(error)", file: file, line: line) + reportIssue( + "Threw error: \(error)", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) } // NB: Give concurrency runtime more time to kick off effects so users don't need to manually // instrument their effects. @@ -969,24 +1027,32 @@ extension TestStore where State: Equatable { @MainActor public func assert( _ updateStateToExpectedResult: @escaping (_ state: inout State) throws -> Void, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { - XCTFailContext.$current.withValue(XCTFailContext(file: file, line: line)) { - let expectedState = self.state - let currentState = self.reducer.state - do { - try self.expectedStateShouldMatch( - expected: expectedState, - actual: currentState, - updateStateToExpectedResult: updateStateToExpectedResult, - skipUnnecessaryModifyFailure: true, - file: file, - line: line - ) - } catch { - XCTFail("Threw error: \(error)", file: file, line: line) - } + let expectedState = self.state + let currentState = self.reducer.state + do { + try self.expectedStateShouldMatch( + expected: expectedState, + actual: currentState, + updateStateToExpectedResult: updateStateToExpectedResult, + skipUnnecessaryModifyFailure: true, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } catch { + reportIssue( + "Threw error: \(error)", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) } } @@ -997,8 +1063,10 @@ extension TestStore where State: Equatable { actual: State, updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, skipUnnecessaryModifyFailure: Bool = false, - file: StaticString, - line: UInt + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt ) throws { try self.sharedChangeTracker.assert { let skipUnnecessaryModifyFailure = @@ -1066,7 +1134,7 @@ extension TestStore where State: Equatable { { var expectedWhenGivenPreviousState = current if let updateStateToExpectedResult { - XCTExpectFailure(strict: false) { + withExpectedIssue(isIntermittent: true) { do { try Dependencies.withDependencies { $0 = self.reducer.dependencies @@ -1075,14 +1143,16 @@ extension TestStore where State: Equatable { try updateStateToExpectedResult(&expectedWhenGivenPreviousState) } } catch { - XCTFail( + reportIssue( """ Skipped assertions: … Threw error: \(error) """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } } @@ -1116,14 +1186,16 @@ extension TestStore where State: Equatable { : updateStateToExpectedResult != nil ? "A state change does not match expectation" : "State was not expected to change, but a change occurred" - XCTFailHelper( + reportIssueHelper( """ \(messageHeading): … \(difference)\(postamble.isEmpty ? "" : "\n\n\(postamble)") """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } @@ -1134,15 +1206,17 @@ extension TestStore where State: Equatable { updateStateToExpectedResult != nil else { return } - XCTFailHelper( + reportIssueHelper( """ Expected state to change, but no change occurred. The trailing closure made no observable modifications to state. If no change to state is \ expected, omit the trailing closure. """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } self.sharedChangeTracker.resetChanges() @@ -1154,8 +1228,10 @@ extension TestStore where State: Equatable, Action: Equatable { private func _receive( _ expectedAction: Action, assert updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { var expectedActionDump = "" customDump(expectedAction, to: &expectedActionDump, indent: 2) @@ -1180,8 +1256,10 @@ extension TestStore where State: Equatable, Action: Equatable { } }, updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } @@ -1220,15 +1298,18 @@ extension TestStore where State: Equatable, Action: Equatable { _ expectedAction: Action, timeout duration: Duration, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, line: UInt = #line ) async { await self.receive( expectedAction, timeout: duration.nanoseconds, assert: updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + file: filePath, + line: line, + column: column ) } @@ -1267,26 +1348,43 @@ extension TestStore where State: Equatable, Action: Equatable { _ expectedAction: Action, timeout nanoseconds: UInt64? = nil, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { - await XCTFailContext.$current.withValue(XCTFailContext(file: file, line: line)) { + await withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.reducer.inFlightEffects.isEmpty else { _ = { self._receive( - expectedAction, assert: updateStateToExpectedResult, file: file, line: line) + expectedAction, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) }() return } await self.receiveAction( matching: { expectedAction == $0 }, timeout: nanoseconds, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) _ = { - self._receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line) + self._receive( + expectedAction, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) }() await Task.megaYield() } @@ -1297,8 +1395,10 @@ extension TestStore where State: Equatable { private func _receive( _ isMatching: (Action) -> Bool, assert updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { self.receiveAction( matching: isMatching, @@ -1309,16 +1409,20 @@ extension TestStore where State: Equatable { return action }, updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } private func _receive( _ actionCase: AnyCasePath, assert updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { self.receiveAction( matching: { actionCase.extract(from: $0) != nil }, @@ -1329,8 +1433,10 @@ extension TestStore where State: Equatable { return action }, updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } @@ -1338,8 +1444,10 @@ extension TestStore where State: Equatable { _ actionCase: AnyCasePath, _ value: Value, assert updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { self.receiveAction( matching: { actionCase.extract(from: $0) == value }, @@ -1362,8 +1470,10 @@ extension TestStore where State: Equatable { return action }, updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } @@ -1405,15 +1515,19 @@ extension TestStore where State: Equatable { _ isMatching: (_ action: Action) -> Bool, timeout duration: Duration, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { await self.receive( isMatching, timeout: duration.nanoseconds, assert: updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + file: filePath, + line: line, + column: column ) } @@ -1455,20 +1569,43 @@ extension TestStore where State: Equatable { _ isMatching: (_ action: Action) -> Bool, timeout nanoseconds: UInt64? = nil, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { - await XCTFailContext.$current.withValue(XCTFailContext(file: file, line: line)) { + await withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.reducer.inFlightEffects.isEmpty else { _ = { - self._receive(isMatching, assert: updateStateToExpectedResult, file: file, line: line) + self._receive( + isMatching, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) }() return } - await self.receiveAction(matching: isMatching, timeout: nanoseconds, file: file, line: line) + await self.receiveAction( + matching: isMatching, + timeout: nanoseconds, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) _ = { - self._receive(isMatching, assert: updateStateToExpectedResult, file: file, line: line) + self._receive( + isMatching, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) }() await Task.megaYield() } @@ -1510,15 +1647,19 @@ extension TestStore where State: Equatable { _ actionCase: CaseKeyPath, timeout nanoseconds: UInt64? = nil, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { await self.receive( AnyCasePath(actionCase), timeout: nanoseconds, assert: updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + file: filePath, + line: line, + column: column ) } @@ -1553,17 +1694,25 @@ extension TestStore where State: Equatable { _ value: Value, timeout nanoseconds: UInt64? = nil, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async where Action: CasePathable { let actionCase = AnyCasePath(actionCase) - await XCTFailContext.$current.withValue(XCTFailContext(file: file, line: line)) { + await withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.reducer.inFlightEffects.isEmpty else { _ = { self._receive( - actionCase, value, assert: updateStateToExpectedResult, file: file, line: line + actionCase, + value, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) }() return @@ -1571,12 +1720,20 @@ extension TestStore where State: Equatable { await self.receiveAction( matching: { actionCase.extract(from: $0) != nil }, timeout: nanoseconds, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) _ = { self._receive( - actionCase, value, assert: updateStateToExpectedResult, file: file, line: line + actionCase, + value, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) }() await Task.megaYield() @@ -1613,25 +1770,43 @@ extension TestStore where State: Equatable { _ actionCase: AnyCasePath, timeout nanoseconds: UInt64? = nil, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { - await XCTFailContext.$current.withValue(XCTFailContext(file: file, line: line)) { + await withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.reducer.inFlightEffects.isEmpty else { _ = { - self._receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line) + self._receive( + actionCase, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) }() return } await self.receiveAction( matching: { actionCase.extract(from: $0) != nil }, timeout: nanoseconds, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) _ = { - self._receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line) + self._receive( + actionCase, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) }() await Task.megaYield() } @@ -1674,15 +1849,19 @@ extension TestStore where State: Equatable { _ actionCase: CaseKeyPath, timeout duration: Duration, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { await self.receive( AnyCasePath(actionCase), timeout: duration, assert: updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + file: filePath, + line: line, + column: column ) } @@ -1718,8 +1897,10 @@ extension TestStore where State: Equatable { _ value: Value, timeout duration: Duration, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async where Action: CasePathable { await self.receive( @@ -1731,8 +1912,10 @@ extension TestStore where State: Equatable { ), timeout: duration, assert: updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + file: filePath, + line: line, + column: column ) } @@ -1770,15 +1953,22 @@ extension TestStore where State: Equatable { _ actionCase: AnyCasePath, timeout duration: Duration, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { - await XCTFailContext.$current.withValue(XCTFailContext(file: file, line: line)) { + await withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.reducer.inFlightEffects.isEmpty else { _ = { self._receive( - actionCase, assert: updateStateToExpectedResult, file: file, line: line + actionCase, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) }() return @@ -1786,11 +1976,20 @@ extension TestStore where State: Equatable { await self.receiveAction( matching: { actionCase.extract(from: $0) != nil }, timeout: duration.nanoseconds, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) _ = { - self._receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line) + self._receive( + actionCase, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) }() await Task.megaYield() } @@ -1801,8 +2000,10 @@ extension TestStore where State: Equatable { failureMessage: @autoclosure () -> String, unexpectedActionDescription: (Action) -> String, _ updateStateToExpectedResult: ((inout State) throws -> Void)?, - file: StaticString, - line: UInt + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt ) { let updateStateToExpectedResult = updateStateToExpectedResult.map { original in { (state: inout State) in @@ -1813,20 +2014,24 @@ extension TestStore where State: Equatable { } guard !self.reducer.receivedActions.isEmpty else { - XCTFail( + reportIssue( failureMessage(), - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return } if self.exhaustivity != .on { guard self.reducer.receivedActions.contains(where: { predicate($0.action) }) else { - XCTFail( + reportIssue( failureMessage(), - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return } @@ -1843,15 +2048,17 @@ extension TestStore where State: Equatable { if !actions.isEmpty { var actionsDump = "" customDump(actions, to: &actionsDump) - XCTFailHelper( + reportIssueHelper( """ \(actions.count) received action\ \(actions.count == 1 ? " was" : "s were") skipped: \(actionsDump) """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } } @@ -1860,14 +2067,16 @@ extension TestStore where State: Equatable { if !predicate(receivedAction) { let receivedActionLater = self.reducer.receivedActions .contains(where: { action, _ in predicate(receivedAction) }) - XCTFailHelper( + reportIssueHelper( """ Received unexpected action\(receivedActionLater ? " before this one" : ""): … \(unexpectedActionDescription(receivedAction)) """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } else { let expectedState = self.state @@ -1876,11 +2085,19 @@ extension TestStore where State: Equatable { expected: expectedState, actual: state, updateStateToExpectedResult: updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } catch { - XCTFail("Threw error: \(error)", file: file, line: line) + reportIssue( + "Threw error: \(error)", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) } } self.reducer.state = state @@ -1890,8 +2107,10 @@ extension TestStore where State: Equatable { private func receiveAction( matching predicate: (Action) -> Bool, timeout nanoseconds: UInt64?, - file: StaticString, - line: UInt + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt ) async { let nanoseconds = nanoseconds ?? self.timeout @@ -1933,7 +2152,7 @@ extension TestStore where State: Equatable { \(timeoutMessage). """ } - XCTFail( + reportIssue( """ Expected to receive \(self.exhaustivity == .on ? "an action" : "a matching action"), but \ received none\ @@ -1941,8 +2160,10 @@ extension TestStore where State: Equatable { \(suggestion) """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return } @@ -1981,10 +2202,19 @@ extension TestStore where State: Equatable { public func send( _ action: CaseKeyPath, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async -> TestStoreTask { - await self.send(action(), assert: updateStateToExpectedResult, file: file, line: line) + await self.send( + action(), + assert: updateStateToExpectedResult, + fileID: fileID, + file: filePath, + line: line, + column: column + ) } /// Sends an action to the store and asserts when state changes. @@ -2020,10 +2250,19 @@ extension TestStore where State: Equatable { _ action: CaseKeyPath, _ value: Value, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async -> TestStoreTask { - await self.send(action(value), assert: updateStateToExpectedResult, file: file, line: line) + await self.send( + action(value), + assert: updateStateToExpectedResult, + fileID: fileID, + file: filePath, + line: line, + column: column + ) } } @@ -2052,20 +2291,34 @@ extension TestStore { @MainActor public func skipReceivedActions( strict: Bool = true, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { await Task.megaYield() - _ = { self._skipReceivedActions(strict: strict, file: file, line: line) }() + _ = { + self._skipReceivedActions( + strict: strict, fileID: fileID, file: filePath, line: line, column: column + ) + }() } private func _skipReceivedActions( strict: Bool = true, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { if strict && self.reducer.receivedActions.isEmpty { - XCTFail("There were no received actions to skip.") + reportIssue( + "There were no received actions to skip.", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) return } guard !self.reducer.receivedActions.isEmpty @@ -2076,7 +2329,7 @@ extension TestStore { } else { customDump(self.reducer.receivedActions.map { $0.action }, to: &actions) } - XCTFailHelper( + reportIssueHelper( """ \(self.reducer.receivedActions.count) received action\ \(self.reducer.receivedActions.count == 1 ? " was" : "s were") skipped: @@ -2086,8 +2339,10 @@ extension TestStore { overrideExhaustivity: self.exhaustivity == .on ? .off(showSkippedAssertions: true) : self.exhaustivity, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) self.reducer.state = self.reducer.receivedActions.last!.state self.reducer.receivedActions = [] @@ -2117,20 +2372,34 @@ extension TestStore { @MainActor public func skipInFlightEffects( strict: Bool = true, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { await Task.megaYield() - _ = { self._skipInFlightEffects(strict: strict, file: file, line: line) }() + _ = { + self._skipInFlightEffects( + strict: strict, fileID: fileID, filePath: filePath, line: line, column: column + ) + }() } fileprivate func _skipInFlightEffects( strict: Bool = true, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { if strict && self.reducer.inFlightEffects.isEmpty { - XCTFail("There were no in-flight effects to skip.") + reportIssue( + "There were no in-flight effects to skip.", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) return } guard !self.reducer.inFlightEffects.isEmpty @@ -2143,7 +2412,7 @@ extension TestStore { customDump(self.reducer.inFlightEffects.map { $0.action.origin.action }, to: &actions) } - XCTFailHelper( + reportIssueHelper( """ \(self.reducer.inFlightEffects.count) in-flight effect\ \(self.reducer.inFlightEffects.count == 1 ? " was" : "s were") cancelled, originating from: @@ -2153,36 +2422,42 @@ extension TestStore { overrideExhaustivity: self.exhaustivity == .on ? .off(showSkippedAssertions: true) : self.exhaustivity, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) self.reducer.inFlightEffects = [] } - private func XCTFailHelper( + private func reportIssueHelper( _ message: String = "", overrideExhaustivity exhaustivity: Exhaustivity? = nil, - file: StaticString, - line: UInt + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt ) { let exhaustivity = exhaustivity ?? self.exhaustivity switch exhaustivity { case .on: - XCTFail(message, file: file, line: line) - case .off(showSkippedAssertions: true): - XCTExpectFailure { - XCTFail( - """ - Skipped assertions: … + reportIssue(message, fileID: fileID, filePath: filePath, line: line, column: column) + case let .off(showSkippedAssertions): + if showSkippedAssertions { + withExpectedIssue { + reportIssue( + """ + Skipped assertions: … - \(message) - """, - file: file, - line: line - ) + \(message) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } } - case .off(showSkippedAssertions: false): - break } } } @@ -2359,10 +2634,18 @@ public struct TestStoreTask: Hashable, Sendable { @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) public func finish( timeout duration: Duration, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { - await self.finish(timeout: duration.nanoseconds, file: file, line: line) + await self.finish( + timeout: duration.nanoseconds, + fileID: fileID, + file: filePath, + line: line, + column: column + ) } /// Asserts the underlying task finished. @@ -2371,8 +2654,10 @@ public struct TestStoreTask: Hashable, Sendable { @_disfavoredOverload public func finish( timeout nanoseconds: UInt64? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { let nanoseconds = nanoseconds ?? self.timeout await Task.megaYield() @@ -2402,15 +2687,17 @@ public struct TestStoreTask: Hashable, Sendable { \(timeoutMessage). """ - XCTFail( + reportIssue( """ Expected task to finish, but it is still in-flight\ \(nanoseconds > 0 ? " after \(Double(nanoseconds)/Double(NSEC_PER_SEC)) seconds" : ""). \(suggestion) """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } } @@ -2490,7 +2777,15 @@ class TestReducer: Reducer { self?.inFlightEffects.remove(effect) } ) - .map { .init(origin: .receive($0), file: action.file, line: action.line) } + .map { + .init( + origin: .receive($0), + fileID: action.fileID, + filePath: action.filePath, + line: action.line, + column: action.column + ) + } } } } @@ -2510,8 +2805,10 @@ class TestReducer: Reducer { struct TestAction { let origin: Origin - let file: StaticString + let fileID: StaticString + let filePath: StaticString let line: UInt + let column: UInt fileprivate var action: Action { self.origin.action @@ -2585,8 +2882,10 @@ extension TestStore { public func receive( _ expectedAction: Action, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { fatalError() } diff --git a/Sources/ComposableArchitecture/UIKit/NSObject+Observation.swift b/Sources/ComposableArchitecture/UIKit/NSObject+Observation.swift index f04c096279cf..5ea6cc3f8f86 100644 --- a/Sources/ComposableArchitecture/UIKit/NSObject+Observation.swift +++ b/Sources/ComposableArchitecture/UIKit/NSObject+Observation.swift @@ -138,15 +138,25 @@ /// } /// ``` @discardableResult - public func observe(_ apply: @escaping () -> Void) -> ObservationToken { + public func observe( + _ apply: @escaping () -> Void, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> ObservationToken { if ObserveLocals.isApplying { - runtimeWarn( + reportIssue( """ An "observe" was called from another "observe" closure, which can lead to \ over-observation and unintended side effects. Avoid nested closures by moving child observation into their own lifecycle methods. - """ + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } let token = ObservationToken() diff --git a/Tests/ComposableArchitectureMacrosTests/PresentsMacroTests.swift b/Tests/ComposableArchitectureMacrosTests/PresentsMacroTests.swift index 0894b8305846..715d48517f9c 100644 --- a/Tests/ComposableArchitectureMacrosTests/PresentsMacroTests.swift +++ b/Tests/ComposableArchitectureMacrosTests/PresentsMacroTests.swift @@ -165,7 +165,6 @@ } expansion: { #""" struct State: Equatable { - var destination: Destination.State? { @storageRestrictions(initializes: _destination) init(initialValue) { @@ -190,8 +189,6 @@ } } - - private var _destination = ComposableArchitecture.PresentationState(wrappedValue: nil) var _$observationRegistrar = ComposableArchitecture.ObservationStateRegistrar() diff --git a/Tests/ComposableArchitectureTests/EffectFailureTests.swift b/Tests/ComposableArchitectureTests/EffectFailureTests.swift index cd9a8b9bb743..dfaa74bdc1ba 100644 --- a/Tests/ComposableArchitectureTests/EffectFailureTests.swift +++ b/Tests/ComposableArchitectureTests/EffectFailureTests.swift @@ -11,7 +11,7 @@ var line: UInt! XCTExpectFailure { $0.compactDescription == """ - An "Effect.run" returned from "\(#fileID):\(line+1)" threw an unhandled error. … + failed - An "Effect.run" returned from "\(#fileID):\(line+1)" threw an unhandled error. … EffectFailureTests.Unexpected() diff --git a/Tests/ComposableArchitectureTests/EffectRunTests.swift b/Tests/ComposableArchitectureTests/EffectRunTests.swift index a203f4829050..1efa2b826ea4 100644 --- a/Tests/ComposableArchitectureTests/EffectRunTests.swift +++ b/Tests/ComposableArchitectureTests/EffectRunTests.swift @@ -50,7 +50,7 @@ final class EffectRunTests: BaseTCATestCase { var line: UInt! XCTExpectFailure(nil, enabled: nil, strict: nil) { $0.compactDescription == """ - An "Effect.run" returned from "\(#fileID):\(line+1)" threw an unhandled error. … + failed - An "Effect.run" returned from "\(#fileID):\(line+1)" threw an unhandled error. … EffectRunTests.Failure() @@ -131,7 +131,7 @@ final class EffectRunTests: BaseTCATestCase { func testRunEscapeFailure() async { XCTExpectFailure { $0.compactDescription == """ - An action was sent from a completed effect: + failed - An action was sent from a completed effect: Action: EffectRunTests.Action.response diff --git a/Tests/ComposableArchitectureTests/ObserveTests.swift b/Tests/ComposableArchitectureTests/ObserveTests.swift index ab67d89b1a94..6c70b874a592 100644 --- a/Tests/ComposableArchitectureTests/ObserveTests.swift +++ b/Tests/ComposableArchitectureTests/ObserveTests.swift @@ -38,7 +38,7 @@ final class ObserveTests: BaseTCATestCase { func testNestedObservation() async throws { XCTExpectFailure { $0.compactDescription == """ - An "observe" was called from another "observe" closure, which can lead to \ + failed - An "observe" was called from another "observe" closure, which can lead to \ over-observation and unintended side effects. Avoid nested closures by moving child observation into their own lifecycle methods. diff --git a/Tests/ComposableArchitectureTests/Reducers/ForEachReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/ForEachReducerTests.swift index 0c0747f4699b..2f0fb1efcc72 100644 --- a/Tests/ComposableArchitectureTests/Reducers/ForEachReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/ForEachReducerTests.swift @@ -45,7 +45,8 @@ final class ForEachReducerTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - A "forEach" at "\(#fileID):\(#line - 5)" received an action for a missing element. … + failed - A "forEach" at "\(#fileID):\(#line - 5)" received an action for a missing \ + element. … Action: Elements.Action.rows(.element(id:, action:)) diff --git a/Tests/ComposableArchitectureTests/Reducers/IfCaseLetReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/IfCaseLetReducerTests.swift index 54e6cd5860b5..97cfc86076a5 100644 --- a/Tests/ComposableArchitectureTests/Reducers/IfCaseLetReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/IfCaseLetReducerTests.swift @@ -42,8 +42,8 @@ final class IfCaseLetReducerTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - An "ifCaseLet" at "\(#fileID):\(#line - 5)" received a child action when child state was \ - set to a different case. … + failed - An "ifCaseLet" at "\(#fileID):\(#line - 5)" received a child action when child \ + state was set to a different case. … Action: Result.success(1) diff --git a/Tests/ComposableArchitectureTests/Reducers/IfLetReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/IfLetReducerTests.swift index 435708740ecd..4cea60553c63 100644 --- a/Tests/ComposableArchitectureTests/Reducers/IfLetReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/IfLetReducerTests.swift @@ -12,8 +12,8 @@ final class IfLetReducerTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - An "ifLet" at "\(#fileID):\(#line - 5)" received a child action when child state was \ - "nil". … + failed - An "ifLet" at "\(#fileID):\(#line - 5)" received a child action when child state \ + was "nil". … Action: () diff --git a/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift index dd10124942f8..08503cb364f2 100644 --- a/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift @@ -40,7 +40,7 @@ final class PresentationReducerTests: BaseTCATestCase { parent.$child[case: /Child.text]?.append("!") } issueMatcher: { $0.compactDescription == """ - Can't modify unrelated case "int" + failed - Can't modify unrelated case "int" """ } @@ -48,7 +48,7 @@ final class PresentationReducerTests: BaseTCATestCase { parent.$child[case: /Child.text] = nil } issueMatcher: { $0.compactDescription == """ - Can't modify unrelated case "int" + failed - Can't modify unrelated case "int" """ } @@ -1749,7 +1749,7 @@ final class PresentationReducerTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - An "ifLet" at \ + failed - An "ifLet" at \ "ComposableArchitectureTests/PresentationReducerTests.swift:\(#line - 13)" received a \ presentation action when destination state was absent. … @@ -1807,7 +1807,7 @@ final class PresentationReducerTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - An "ifLet" at \ + failed - An "ifLet" at \ "ComposableArchitectureTests/PresentationReducerTests.swift:\(#line - 13)" received a \ presentation action when destination state was absent. … @@ -2295,8 +2295,8 @@ final class PresentationReducerTests: BaseTCATestCase { $0.sourceCodeContext.location?.fileURL.absoluteString.contains("BaseTCATestCase") == true || $0.sourceCodeContext.location?.lineNumber == line + 1 && $0.compactDescription == """ - An effect returned for this action is still running. It must complete before the end \ - of the test. … + failed - An effect returned for this action is still running. It must complete before \ + the end of the test. … To fix, inspect any effects the reducer returns for this action and ensure that all of \ them complete by the end of the test. There are a few reasons why an effect may not \ @@ -2612,8 +2612,8 @@ final class PresentationReducerTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription.hasPrefix( """ - A "Scope" at "\(#fileID):\(line)" received a child action when child state was set to a \ - different case. … + failed - A "Scope" at "\(#fileID):\(line)" received a child action when child state was \ + set to a different case. … """ ) } diff --git a/Tests/ComposableArchitectureTests/Reducers/StackReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/StackReducerTests.swift index b14912231d2e..a72a0238e1af 100644 --- a/Tests/ComposableArchitectureTests/Reducers/StackReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/StackReducerTests.swift @@ -30,7 +30,7 @@ final class StackReducerTests: BaseTCATestCase { stack[id: 0, case: /Element.text]?.append("!") } issueMatcher: { $0.compactDescription == """ - Can't modify unrelated case "int" + failed - Can't modify unrelated case "int" """ } @@ -38,7 +38,7 @@ final class StackReducerTests: BaseTCATestCase { stack[id: 0, case: /Element.text] = nil } issueMatcher: { $0.compactDescription == """ - Can't modify unrelated case "int" + failed - Can't modify unrelated case "int" """ } @@ -268,7 +268,7 @@ final class StackReducerTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - Received unexpected action: … + failed - Received unexpected action: …   StackReducerTests.Parent.Action.children( − .popFrom(id: #1) @@ -787,8 +787,8 @@ final class StackReducerTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" received an \ - action for a missing element. … + failed - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" \ + received an action for a missing element. … Action: () @@ -836,8 +836,8 @@ final class StackReducerTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" received a \ - "popFrom" action for a missing element. … + failed - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" \ + received a "popFrom" action for a missing element. … ID: #999 @@ -888,8 +888,8 @@ final class StackReducerTests: BaseTCATestCase { $0.sourceCodeContext.location?.fileURL.absoluteString.contains("BaseTCATestCase") == true || $0.sourceCodeContext.location?.lineNumber == line + 1 && $0.compactDescription == """ - An effect returned for this action is still running. It must complete before the end \ - of the test. … + failed - An effect returned for this action is still running. It must complete before \ + the end of the test. … To fix, inspect any effects the reducer returns for this action and ensure that all \ of them complete by the end of the test. There are a few reasons why an effect may \ @@ -1102,8 +1102,8 @@ final class StackReducerTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" received a \ - "push" action for an element it already contains. … + failed - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" \ + received a "push" action for an element it already contains. … ID: #0 @@ -1147,8 +1147,8 @@ final class StackReducerTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" received a \ - "push" action with an unexpected generational ID. … + failed - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" \ + received a "push" action with an unexpected generational ID. … Received ID: #1 @@ -1189,7 +1189,7 @@ final class StackReducerTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - A state change does not match expectation: … + failed - A state change does not match expectation: …   StackReducerTests.Parent.State(   children: [ diff --git a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift index 6127192bf642..48ff5609a6bf 100644 --- a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift +++ b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift @@ -9,7 +9,7 @@ uncheckedUseMainSerialExecutor = false XCTExpectFailure { $0.compactDescription == """ - A store initialized on a non-main thread. … + failed - A store initialized on a non-main thread. … The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \ (including all of its scopes and derived view stores) must be done on the main thread. @@ -26,7 +26,7 @@ func testEffectFinishedMainThread() async throws { XCTExpectFailure { $0.compactDescription == """ - An effect completed on a non-main thread. … + failed - An effect completed on a non-main thread. … Effect returned from: RuntimeWarningTests.Action.tap @@ -62,14 +62,14 @@ XCTExpectFailure { [ """ - "Store.scope" was called on a non-main thread. … + failed - "Store.scope" was called on a non-main thread. … The "Store" class is not thread-safe, and so all interactions with an instance of \ "Store" (including all of its scopes and derived view stores) must be done on the main \ thread. """, """ - A store initialized on a non-main thread. … + failed - A store initialized on a non-main thread. … The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \ (including all of its scopes and derived view stores) must be done on the main thread. @@ -89,7 +89,7 @@ uncheckedUseMainSerialExecutor = false XCTExpectFailure { $0.compactDescription == """ - "Store.send" was called on a non-main thread with: () … + failed - "Store.send" was called on a non-main thread with: () … The "Store" class is not thread-safe, and so all interactions with an instance of \ "Store" (including all of its scopes and derived view stores) must be done on the main \ @@ -109,7 +109,7 @@ XCTExpectFailure { [ """ - An effect completed on a non-main thread. … + failed - An effect completed on a non-main thread. … Effect returned from: RuntimeWarningTests.Action.response @@ -122,7 +122,7 @@ thread. """, """ - An effect completed on a non-main thread. … + failed - An effect completed on a non-main thread. … Effect returned from: RuntimeWarningTests.Action.tap @@ -135,7 +135,7 @@ thread. """, """ - An effect published an action on a non-main thread. … + failed - An effect published an action on a non-main thread. … Effect published: RuntimeWarningTests.Action.response @@ -190,7 +190,7 @@ ViewStore(store, observe: { $0 }).$value.wrappedValue = 42 } issueMatcher: { $0.compactDescription == """ - A binding action sent from a store for binding state defined at \ + failed - A binding action sent from a store for binding state defined at \ "\(#fileID):\(line)" was not handled. … Action: @@ -216,7 +216,7 @@ ViewStore(store, observe: { $0 }).$value.wrappedValue = 42 } issueMatcher: { $0.compactDescription == """ - A binding action sent from a store for binding state defined at \ + failed - A binding action sent from a store for binding state defined at \ "\(#fileID):\(line)" was not handled. … Action: @@ -244,13 +244,15 @@ } XCTExpectFailure { - store.scope(state: \.path, action: \.path)[fileID: "file.swift", line: 1] = .init() + store.scope(state: \.path, action: \.path)[ + fileID: "file.swift", filePath: "/file.swift", line: 1, column: 1 + ] = .init() } issueMatcher: { $0.compactDescription == """ - SwiftUI wrote to a "NavigationStack" binding at "file.swift:1" with a path that has the \ - same number of elements that already exist in the store. SwiftUI should only write to \ - this binding with a path that has pushed a new element onto the stack, or popped one or \ - more elements from the stack. + failed - SwiftUI wrote to a "NavigationStack" binding at "file.swift:1" with a path that \ + has the same number of elements that already exist in the store. SwiftUI should only \ + write to this binding with a path that has pushed a new element onto the stack, or \ + popped one or more elements from the stack. This usually means the "forEach" has not been integrated with the reducer powering the \ store, and this reducer is responsible for handling stack actions. @@ -296,12 +298,14 @@ action: \.destination, isInViewBody: false, fileID: "file.swift", - line: 1 + filePath: "/file.swift", + line: 1, + column: 1 ] = nil } issueMatcher: { $0.compactDescription == """ - SwiftUI dismissed a view through a binding at "file.swift:1", but the store destination \ - wasn't set to "nil". + failed - SwiftUI dismissed a view through a binding at "file.swift:1", but the store \ + destination wasn't set to "nil". This usually means an "ifLet" has not been integrated with the reducer powering the \ store, and this reducer is responsible for handling presentation actions. diff --git a/Tests/ComposableArchitectureTests/ScopeCacheTests.swift b/Tests/ComposableArchitectureTests/ScopeCacheTests.swift index 2d068babf4c8..e4b288a95b65 100644 --- a/Tests/ComposableArchitectureTests/ScopeCacheTests.swift +++ b/Tests/ComposableArchitectureTests/ScopeCacheTests.swift @@ -16,7 +16,7 @@ final class ScopeCacheTests: BaseTCATestCase { .send(.show) } issueMatcher: { $0.compactDescription == """ - Scoping from uncached StoreOf is not compatible with observation. + failed - Scoping from uncached StoreOf is not compatible with observation. This can happen for one of two reasons: @@ -78,7 +78,7 @@ final class ScopeCacheTests: BaseTCATestCase { _ = cancellable } issueMatcher: { $0.compactDescription == """ - Scoping from uncached StoreOf is not compatible with observation. + failed - Scoping from uncached StoreOf is not compatible with observation. This can happen for one of two reasons: @@ -125,7 +125,7 @@ final class ScopeCacheTests: BaseTCATestCase { ) } issueMatcher: { $0.compactDescription == """ - Scoping from uncached StoreOf is not compatible with observation. + failed - Scoping from uncached StoreOf is not compatible with observation. This can happen for one of two reasons: diff --git a/Tests/ComposableArchitectureTests/ScopeTests.swift b/Tests/ComposableArchitectureTests/ScopeTests.swift index 98813f863dcd..35e10da318f3 100644 --- a/Tests/ComposableArchitectureTests/ScopeTests.swift +++ b/Tests/ComposableArchitectureTests/ScopeTests.swift @@ -48,8 +48,8 @@ final class ScopeTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - A "Scope" at "\(#fileID):\(#line - 5)" received a child action when child state was set to \ - a different case. … + failed - A "Scope" at "\(#fileID):\(#line - 5)" received a child action when child state \ + was set to a different case. … Action: Child2.Action.name diff --git a/Tests/ComposableArchitectureTests/SharedAppStorageTests.swift b/Tests/ComposableArchitectureTests/SharedAppStorageTests.swift index 985336d997e5..fb9086b1ac09 100644 --- a/Tests/ComposableArchitectureTests/SharedAppStorageTests.swift +++ b/Tests/ComposableArchitectureTests/SharedAppStorageTests.swift @@ -63,7 +63,7 @@ final class SharedAppStorageTests: XCTestCase { XCTExpectFailure { $0.compactDescription == """ - State was not expected to change, but a change occurred: … + failed - State was not expected to change, but a change occurred: …   ParentFeature.State(   _child1: Feature.State( diff --git a/Tests/ComposableArchitectureTests/SharedInMemoryTests.swift b/Tests/ComposableArchitectureTests/SharedInMemoryTests.swift index 62bb7bf9bde9..0212c8edbbfe 100644 --- a/Tests/ComposableArchitectureTests/SharedInMemoryTests.swift +++ b/Tests/ComposableArchitectureTests/SharedInMemoryTests.swift @@ -55,7 +55,7 @@ final class SharedInMemoryTests: XCTestCase { XCTExpectFailure { $0.compactDescription == """ - State was not expected to change, but a change occurred: … + failed - State was not expected to change, but a change occurred: …   ParentFeature.State(   _child1: Feature.State( diff --git a/Tests/ComposableArchitectureTests/SharedTests.swift b/Tests/ComposableArchitectureTests/SharedTests.swift index bfc0876acc0b..75dd38cfb159 100644 --- a/Tests/ComposableArchitectureTests/SharedTests.swift +++ b/Tests/ComposableArchitectureTests/SharedTests.swift @@ -38,7 +38,7 @@ final class SharedTests: XCTestCase { } XCTExpectFailure { $0.compactDescription == """ - A state change does not match expectation: … + failed - A state change does not match expectation: …   SharedFeature.State(   _count: 0, @@ -76,7 +76,7 @@ final class SharedTests: XCTestCase { XCTExpectFailure { $0.compactDescription == """ - A state change does not match expectation: … + failed - A state change does not match expectation: …   SharedFeature.State(   _count: 0, @@ -145,7 +145,7 @@ final class SharedTests: XCTestCase { } XCTExpectFailure { $0.compactDescription == """ - A state change does not match expectation: … + failed - A state change does not match expectation: …   SharedFeature.State(   _count: 0, @@ -194,7 +194,7 @@ final class SharedTests: XCTestCase { } XCTExpectFailure { $0.compactDescription == """ - State was not expected to change, but a change occurred: … + failed - State was not expected to change, but a change occurred: …   SharedFeature.State(   _count: 0, @@ -246,7 +246,7 @@ final class SharedTests: XCTestCase { } XCTExpectFailure { $0.compactDescription == """ - Test store finished before asserting against changes to shared state: … + failed - Test store finished before asserting against changes to shared state: …   SharedFeature.State(   _count: 0, @@ -280,7 +280,7 @@ final class SharedTests: XCTestCase { } XCTExpectFailure { $0.compactDescription == """ - A state change does not match expectation: … + failed - A state change does not match expectation: …   SharedFeature.State(   _count: 0, @@ -434,7 +434,7 @@ final class SharedTests: XCTestCase { XCTExpectFailure { $0.compactDescription == """ - State was not expected to change, but a change occurred: … + failed - State was not expected to change, but a change occurred: …   SimpleFeature.State( − _count: #1 0 @@ -496,7 +496,7 @@ final class SharedTests: XCTestCase { } XCTExpectFailure { $0.compactDescription == """ - Expected changes, but none occurred. + failed - Expected changes, but none occurred. """ } store.state.$count.assert { diff --git a/Tests/ComposableArchitectureTests/TaskResultTests.swift b/Tests/ComposableArchitectureTests/TaskResultTests.swift index 22c88c1e95d1..1c382025a4e2 100644 --- a/Tests/ComposableArchitectureTests/TaskResultTests.swift +++ b/Tests/ComposableArchitectureTests/TaskResultTests.swift @@ -15,7 +15,7 @@ final class TaskResultTests: BaseTCATestCase { ) } issueMatcher: { $0.compactDescription == """ - "TaskResultTests.Failure" is not equatable. … + failed - "TaskResultTests.Failure" is not equatable. … To test two values of this type, it must conform to the "Equatable" protocol. For example: @@ -41,7 +41,7 @@ final class TaskResultTests: BaseTCATestCase { ) } issueMatcher: { $0.compactDescription == """ - XCTAssertNoDifference failed: … + failed - XCTAssertNoDifference failed: …   TaskResult.failure( − TaskResultTests.Failure1(message: "Something went wrong") @@ -62,7 +62,7 @@ final class TaskResultTests: BaseTCATestCase { _ = TaskResult.failure(Failure(message: "Something went wrong")).hashValue } issueMatcher: { $0.compactDescription == """ - "TaskResultTests.Failure" is not hashable. … + failed - "TaskResultTests.Failure" is not hashable. … To hash a value of this type, it must conform to the "Hashable" protocol. For example: diff --git a/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift b/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift index af02290f114a..a890f486a8eb 100644 --- a/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift @@ -16,7 +16,7 @@ final class TestStoreFailureTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - Expected state to change, but no change occurred. + failed - Expected state to change, but no change occurred. The trailing closure made no observable modifications to state. If no change to state is \ expected, omit the trailing closure. @@ -26,7 +26,7 @@ final class TestStoreFailureTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - Expected state to change, but no change occurred. + failed - Expected state to change, but no change occurred. The trailing closure made no observable modifications to state. If no change to state is \ expected, omit the trailing closure. @@ -47,7 +47,7 @@ final class TestStoreFailureTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - A state change does not match expectation: … + failed - A state change does not match expectation: … − TestStoreFailureTests.State(count: 0) + TestStoreFailureTests.State(count: 1) @@ -70,7 +70,7 @@ final class TestStoreFailureTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - State was not expected to change, but a change occurred: … + failed - State was not expected to change, but a change occurred: … − TestStoreFailureTests.State(count: 0) + TestStoreFailureTests.State(count: 1) @@ -99,7 +99,7 @@ final class TestStoreFailureTests: BaseTCATestCase { await store.send(.first) XCTExpectFailure { $0.compactDescription == """ - State was not expected to change, but a change occurred: … + failed - State was not expected to change, but a change occurred: … − TestStoreFailureTests.State(count: 0) + TestStoreFailureTests.State(count: 1) @@ -124,7 +124,7 @@ final class TestStoreFailureTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - The store received 1 unexpected action: … + failed - The store received 1 unexpected action: … Unhandled actions: • .second @@ -153,7 +153,7 @@ final class TestStoreFailureTests: BaseTCATestCase { await store.send(.first) XCTExpectFailure { $0.compactDescription == """ - The store received 1 unexpected action: … + failed - The store received 1 unexpected action: … Unhandled actions: • .second @@ -177,8 +177,8 @@ final class TestStoreFailureTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - An effect returned for this action is still running. It must complete before the end of \ - the test. … + failed - An effect returned for this action is still running. It must complete before the \ + end of the test. … To fix, inspect any effects the reducer returns for this action and ensure that all of \ them complete by the end of the test. There are a few reasons why an effect may not have \ @@ -221,7 +221,7 @@ final class TestStoreFailureTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - Must handle 1 received action before sending an action: … + failed - Must handle 1 received action before sending an action: … Unhandled actions: [ [0]: .second @@ -243,7 +243,7 @@ final class TestStoreFailureTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - Expected to receive the following action, but didn't: … + failed - Expected to receive the following action, but didn't: … TestStoreFailureTests.Action.action """ @@ -270,7 +270,7 @@ final class TestStoreFailureTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - Received unexpected action: … + failed - Received unexpected action: … − TestStoreFailureTests.Action.first + TestStoreFailureTests.Action.second @@ -288,7 +288,7 @@ final class TestStoreFailureTests: BaseTCATestCase { } XCTExpectFailure { - $0.compactDescription == "Threw error: SomeError()" + $0.compactDescription == "failed - Threw error: SomeError()" } await store.send(()) { _ in struct SomeError: Error {} diff --git a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift index 841e08fa61cb..3860bda2e360 100644 --- a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift @@ -41,7 +41,7 @@ final class TestStoreNonExhaustiveTests: BaseTCATestCase { await store.receive(false) { $0 = 2 } XCTAssertEqual(store.state, 2) XCTExpectFailure { - $0.compactDescription == "There were no received actions to skip." + $0.compactDescription == "failed - There were no received actions to skip." } await store.skipReceivedActions(strict: true) } @@ -111,7 +111,7 @@ final class TestStoreNonExhaustiveTests: BaseTCATestCase { let task = await store.send(true) await task.finish(timeout: NSEC_PER_SEC / 2) XCTExpectFailure { - $0.compactDescription == "There were no in-flight effects to skip." + $0.compactDescription == "failed - There were no in-flight effects to skip." } await store.skipInFlightEffects(strict: true) } @@ -240,7 +240,7 @@ final class TestStoreNonExhaustiveTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - A state change does not match expectation: … + failed - A state change does not match expectation: …   Counter.State( − count: 0, @@ -342,7 +342,7 @@ final class TestStoreNonExhaustiveTests: BaseTCATestCase { } XCTExpectFailure { $0.compactDescription == """ - A state change does not match expectation: … + failed - A state change does not match expectation: …   TestStoreNonExhaustiveTests.State( − count: 2, @@ -630,7 +630,7 @@ final class TestStoreNonExhaustiveTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - Expected to receive an action matching case path, but didn't get one. + failed - Expected to receive an action matching case path, but didn't get one. """ } @@ -651,7 +651,7 @@ final class TestStoreNonExhaustiveTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - Expected to receive an action matching case path, but didn't get one. + failed - Expected to receive an action matching case path, but didn't get one. """ } @@ -684,7 +684,7 @@ final class TestStoreNonExhaustiveTests: BaseTCATestCase { XCTModify(&state.child) { _ in } } issueMatcher: { $0.compactDescription == """ - XCTModify failed: expected "Int" value to be modified but it was unchanged. + failed - XCTModify: Expected "Int" value to be modified but it was unchanged. """ } } @@ -694,7 +694,7 @@ final class TestStoreNonExhaustiveTests: BaseTCATestCase { XCTModify(&state.child) { _ in } } issueMatcher: { $0.compactDescription == """ - XCTModify failed: expected "Int" value to be modified but it was unchanged. + failed - XCTModify: Expected "Int" value to be modified but it was unchanged. """ } } diff --git a/Tests/ComposableArchitectureTests/TestStoreTests.swift b/Tests/ComposableArchitectureTests/TestStoreTests.swift index b0ea8e181a46..584e6ec4c1a2 100644 --- a/Tests/ComposableArchitectureTests/TestStoreTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreTests.swift @@ -469,7 +469,7 @@ final class TestStoreTests: BaseTCATestCase { } } issueMatcher: { $0.compactDescription == """ - A state change does not match expectation: … + failed - A state change does not match expectation: … − 1 + 0 @@ -564,7 +564,7 @@ final class TestStoreTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - Received unexpected action: … + failed - Received unexpected action: …   Action.delegate( − .success(43) @@ -669,7 +669,7 @@ final class TestStoreTests: BaseTCATestCase { await store.send(.dismiss) XCTExpectFailure { $0.compactDescription == """ - Can't send action to dismissed test store. + failed - Can't send action to dismissed test store. """ } await store.send(.onTask)