diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56fbf934ca50..54306c79e0ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: matrix: command: [test, ''] platform: [IOS, MACOS] - xcode: ['16.2'] + xcode: ['16.4'] steps: - uses: actions/checkout@v4 - name: Select Xcode ${{ matrix.xcode }} @@ -49,6 +49,9 @@ jobs: examples: name: Examples runs-on: macos-15 + strategy: + matrix: + xcode: ['16.4'] steps: - uses: actions/checkout@v4 - name: Cache derived data @@ -60,7 +63,7 @@ jobs: restore-keys: | deriveddata-examples- - name: Select Xcode 16 - run: sudo xcode-select -s /Applications/Xcode_16.2.app + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: Update xcbeautify run: brew update && brew upgrade xcbeautify - name: Set IgnoreFileSystemDeviceInodeChanges flag diff --git a/Sources/ComposableArchitecture/Macros.swift b/Sources/ComposableArchitecture/Macros.swift index d738d7c99326..0b1227ab1187 100644 --- a/Sources/ComposableArchitecture/Macros.swift +++ b/Sources/ComposableArchitecture/Macros.swift @@ -128,7 +128,7 @@ public macro ReducerCaseIgnored() = /// Defines and implements conformance of the Observable protocol. @attached(extension, conformances: Observable, ObservableState) -@attached(member, names: named(_$id), named(_$observationRegistrar), named(_$willModify)) +@attached(member, names: named(_$id), named(_$observationRegistrar), named(_$willModify), named(shouldNotifyObservers)) @attached(memberAttribute) public macro ObservableState() = #externalMacro(module: "ComposableArchitectureMacros", type: "ObservableStateMacro") diff --git a/Sources/ComposableArchitecture/Observation/ObservationStateRegistrar.swift b/Sources/ComposableArchitecture/Observation/ObservationStateRegistrar.swift index f76508c3ae27..9ef00366aa21 100644 --- a/Sources/ComposableArchitecture/Observation/ObservationStateRegistrar.swift +++ b/Sources/ComposableArchitecture/Observation/ObservationStateRegistrar.swift @@ -51,9 +51,10 @@ extension ObservationStateRegistrar: Equatable, Hashable, Codable { keyPath: KeyPath, _ value: inout Value, _ newValue: Value, - _ isIdentityEqual: (Value, Value) -> Bool + _ isIdentityEqual: (Value, Value) -> Bool, + _ shouldNotifyObservers: (Value, Value) -> Bool = { _, _ in true } ) { - if isIdentityEqual(value, newValue) { + if isIdentityEqual(value, newValue) || !shouldNotifyObservers(value, newValue) { value = newValue } else { self.registrar.withMutation(of: subject, keyPath: keyPath) { @@ -101,12 +102,20 @@ extension ObservationStateRegistrar: Equatable, Hashable, Codable { keyPath: KeyPath, _ member: inout Member, _ oldValue: Member, - _ isIdentityEqual: (Member, Member) -> Bool + _ isIdentityEqual: (Member, Member) -> Bool, + _ shouldNotifyObservers: (Member, Member) -> Bool = { _, _ in true } ) { if !isIdentityEqual(oldValue, member) { let newValue = member member = oldValue - self.mutate(subject, keyPath: keyPath, &member, newValue, isIdentityEqual) + self.mutate( + subject, + keyPath: keyPath, + &member, + newValue, + isIdentityEqual, + shouldNotifyObservers + ) } } } @@ -130,9 +139,10 @@ extension ObservationStateRegistrar: Equatable, Hashable, Codable { keyPath: KeyPath, _ value: inout Value, _ newValue: Value, - _ isIdentityEqual: (Value, Value) -> Bool + _ isIdentityEqual: (Value, Value) -> Bool, + _ shouldNotifyObservers: (Value, Value) -> Bool = { _, _ in true } ) { - if isIdentityEqual(value, newValue) { + if isIdentityEqual(value, newValue) || !shouldNotifyObservers(value, newValue) { value = newValue } else { self.registrar.withMutation(of: subject, keyPath: keyPath) { @@ -169,12 +179,20 @@ extension ObservationStateRegistrar: Equatable, Hashable, Codable { keyPath: KeyPath, _ member: inout Member, _ oldValue: Member, - _ isIdentityEqual: (Member, Member) -> Bool + _ isIdentityEqual: (Member, Member) -> Bool, + _ shouldNotifyObservers: (Member, Member) -> Bool = { _, _ in true } ) { if !isIdentityEqual(oldValue, member) { let newValue = member member = oldValue - self.mutate(subject, keyPath: keyPath, &member, newValue, isIdentityEqual) + self.mutate( + subject, + keyPath: keyPath, + &member, + newValue, + isIdentityEqual, + shouldNotifyObservers + ) } } } diff --git a/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift b/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift index c2d03d80b27e..d57dc03432eb 100644 --- a/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift +++ b/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift @@ -77,6 +77,38 @@ public struct ObservableStateMacro { """ } + static func shouldNotifyObserversNonEquatableFunction(_ perceptibleType: TokenSyntax, context: some MacroExpansionContext) -> DeclSyntax { + let memberGeneric = context.makeUniqueName("Member") + return + """ + private nonisolated func shouldNotifyObservers<\(memberGeneric)>(_ lhs: \(memberGeneric), _ rhs: \(memberGeneric)) -> Bool { true } + """ + } + + static func shouldNotifyObserversEquatableFunction(_ perceptibleType: TokenSyntax, context: some MacroExpansionContext) -> DeclSyntax { + let memberGeneric = context.makeUniqueName("Member") + return + """ + private nonisolated func shouldNotifyObservers<\(memberGeneric): Equatable>(_ lhs: \(memberGeneric), _ rhs: \(memberGeneric)) -> Bool { lhs != rhs } + """ + } + + static func shouldNotifyObserversNonEquatableObjectFunction(_ perceptibleType: TokenSyntax, context: some MacroExpansionContext) -> DeclSyntax { + let memberGeneric = context.makeUniqueName("Member") + return + """ + private nonisolated func shouldNotifyObservers<\(memberGeneric): AnyObject>(_ lhs: \(memberGeneric), _ rhs: \(memberGeneric)) -> Bool { lhs !== rhs } + """ + } + + static func shouldNotifyObserversEquatableObjectFunction(_ perceptibleType: TokenSyntax, context: some MacroExpansionContext) -> DeclSyntax { + let memberGeneric = context.makeUniqueName("Member") + return + """ + private nonisolated func shouldNotifyObservers<\(memberGeneric): Equatable & AnyObject>(_ lhs: \(memberGeneric), _ rhs: \(memberGeneric)) -> Bool { lhs != rhs } + """ + } + static var ignoredAttribute: AttributeSyntax { AttributeSyntax( leadingTrivia: .space, @@ -298,6 +330,10 @@ extension ObservableStateMacro: MemberMacro { ) declaration.addIfNeeded(ObservableStateMacro.idVariable(), to: &declarations) declaration.addIfNeeded(ObservableStateMacro.willModifyFunction(), to: &declarations) + declaration.addIfNeeded(ObservableStateMacro.shouldNotifyObserversNonEquatableFunction(observableType, context: context), to: &declarations) + declaration.addIfNeeded(ObservableStateMacro.shouldNotifyObserversEquatableFunction(observableType, context: context), to: &declarations) + declaration.addIfNeeded(ObservableStateMacro.shouldNotifyObserversNonEquatableObjectFunction(observableType, context: context), to: &declarations) + declaration.addIfNeeded(ObservableStateMacro.shouldNotifyObserversEquatableObjectFunction(observableType, context: context), to: &declarations) return declarations } @@ -611,7 +647,7 @@ public struct ObservationStateTrackedMacro: AccessorMacro { let setAccessor: AccessorDeclSyntax = """ set { - \(raw: ObservableStateMacro.registrarVariableName).mutate(self, keyPath: \\.\(identifier), &_\(identifier), newValue, _$isIdentityEqual) + \(raw: ObservableStateMacro.registrarVariableName).mutate(self, keyPath: \\.\(identifier), &_\(identifier), newValue, _$isIdentityEqual, shouldNotifyObservers) } """ let modifyAccessor: AccessorDeclSyntax = """ diff --git a/Tests/ComposableArchitectureMacrosTests/MacroBaseTestCase.swift b/Tests/ComposableArchitectureMacrosTests/MacroBaseTestCase.swift index 0237efe096f2..442f9a32df29 100644 --- a/Tests/ComposableArchitectureMacrosTests/MacroBaseTestCase.swift +++ b/Tests/ComposableArchitectureMacrosTests/MacroBaseTestCase.swift @@ -8,7 +8,7 @@ class MacroBaseTestCase: XCTestCase { override func invokeTest() { MacroTesting.withMacroTesting( - //isRecording: true, + record: .failed, macros: [ ObservableStateMacro.self, ObservationStateTrackedMacro.self, diff --git a/Tests/ComposableArchitectureMacrosTests/ObservableStateMacroTests.swift b/Tests/ComposableArchitectureMacrosTests/ObservableStateMacroTests.swift index 32f04cd3482b..23496bc0ba85 100644 --- a/Tests/ComposableArchitectureMacrosTests/ObservableStateMacroTests.swift +++ b/Tests/ComposableArchitectureMacrosTests/ObservableStateMacroTests.swift @@ -7,7 +7,9 @@ final class ObservableStateMacroTests: MacroBaseTestCase { override func invokeTest() { - withMacroTesting { + withMacroTesting( + // record: .failed, + ) { super.invokeTest() } } @@ -35,7 +37,7 @@ return _count } set { - _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual) + _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual, shouldNotifyObservers) } _modify { let oldValue = _$observationRegistrar.willModify(self, keyPath: \.count, &_count) @@ -55,6 +57,22 @@ public mutating func _$willModify() { _$observationRegistrar._$willModify() } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu_>(_ lhs: __macro_local_6MemberfMu_, _ rhs: __macro_local_6MemberfMu_) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu0_: Equatable>(_ lhs: __macro_local_6MemberfMu0_, _ rhs: __macro_local_6MemberfMu0_) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu1_: AnyObject>(_ lhs: __macro_local_6MemberfMu1_, _ rhs: __macro_local_6MemberfMu1_) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu2_: Equatable & AnyObject>(_ lhs: __macro_local_6MemberfMu2_, _ rhs: __macro_local_6MemberfMu2_) -> Bool { + lhs != rhs + } } """# } @@ -81,7 +99,7 @@ return _count } set { - _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual) + _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual, shouldNotifyObservers) } _modify { let oldValue = _$observationRegistrar.willModify(self, keyPath: \.count, &_count) @@ -101,6 +119,22 @@ public mutating func _$willModify() { _$observationRegistrar._$willModify() } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu_>(_ lhs: __macro_local_6MemberfMu_, _ rhs: __macro_local_6MemberfMu_) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu0_: Equatable>(_ lhs: __macro_local_6MemberfMu0_, _ rhs: __macro_local_6MemberfMu0_) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu1_: AnyObject>(_ lhs: __macro_local_6MemberfMu1_, _ rhs: __macro_local_6MemberfMu1_) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu2_: Equatable & AnyObject>(_ lhs: __macro_local_6MemberfMu2_, _ rhs: __macro_local_6MemberfMu2_) -> Bool { + lhs != rhs + } } """# } @@ -127,7 +161,7 @@ return _count } set { - _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual) + _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual, shouldNotifyObservers) } _modify { let oldValue = _$observationRegistrar.willModify(self, keyPath: \.count, &_count) @@ -147,6 +181,22 @@ public mutating func _$willModify() { _$observationRegistrar._$willModify() } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu_>(_ lhs: __macro_local_6MemberfMu_, _ rhs: __macro_local_6MemberfMu_) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu0_: Equatable>(_ lhs: __macro_local_6MemberfMu0_, _ rhs: __macro_local_6MemberfMu0_) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu1_: AnyObject>(_ lhs: __macro_local_6MemberfMu1_, _ rhs: __macro_local_6MemberfMu1_) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu2_: Equatable & AnyObject>(_ lhs: __macro_local_6MemberfMu2_, _ rhs: __macro_local_6MemberfMu2_) -> Bool { + lhs != rhs + } } """# } @@ -170,7 +220,7 @@ return _count } set { - _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual) + _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual, shouldNotifyObservers) } _modify { let oldValue = _$observationRegistrar.willModify(self, keyPath: \.count, &_count) @@ -190,6 +240,22 @@ public mutating func _$willModify() { _$observationRegistrar._$willModify() } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu_>(_ lhs: __macro_local_6MemberfMu_, _ rhs: __macro_local_6MemberfMu_) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu0_: Equatable>(_ lhs: __macro_local_6MemberfMu0_, _ rhs: __macro_local_6MemberfMu0_) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu1_: AnyObject>(_ lhs: __macro_local_6MemberfMu1_, _ rhs: __macro_local_6MemberfMu1_) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu2_: Equatable & AnyObject>(_ lhs: __macro_local_6MemberfMu2_, _ rhs: __macro_local_6MemberfMu2_) -> Bool { + lhs != rhs + } } """# } @@ -218,6 +284,22 @@ public mutating func _$willModify() { _$observationRegistrar._$willModify() } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu_>(_ lhs: __macro_local_6MemberfMu_, _ rhs: __macro_local_6MemberfMu_) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu0_: Equatable>(_ lhs: __macro_local_6MemberfMu0_, _ rhs: __macro_local_6MemberfMu0_) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu1_: AnyObject>(_ lhs: __macro_local_6MemberfMu1_, _ rhs: __macro_local_6MemberfMu1_) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu2_: Equatable & AnyObject>(_ lhs: __macro_local_6MemberfMu2_, _ rhs: __macro_local_6MemberfMu2_) -> Bool { + lhs != rhs + } } """ } @@ -673,6 +755,22 @@ public mutating func _$willModify() { _$observationRegistrar._$willModify() } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu_>(_ lhs: __macro_local_6MemberfMu_, _ rhs: __macro_local_6MemberfMu_) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu0_: Equatable>(_ lhs: __macro_local_6MemberfMu0_, _ rhs: __macro_local_6MemberfMu0_) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu1_: AnyObject>(_ lhs: __macro_local_6MemberfMu1_, _ rhs: __macro_local_6MemberfMu1_) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu2_: Equatable & AnyObject>(_ lhs: __macro_local_6MemberfMu2_, _ rhs: __macro_local_6MemberfMu2_) -> Bool { + lhs != rhs + } } """ } diff --git a/Tests/ComposableArchitectureMacrosTests/PresentsMacroTests.swift b/Tests/ComposableArchitectureMacrosTests/PresentsMacroTests.swift index 715d48517f9c..e677326741d4 100644 --- a/Tests/ComposableArchitectureMacrosTests/PresentsMacroTests.swift +++ b/Tests/ComposableArchitectureMacrosTests/PresentsMacroTests.swift @@ -8,7 +8,7 @@ final class PresentsMacroTests: XCTestCase { override func invokeTest() { withMacroTesting( - // isRecording: true, + // record: .failed, macros: [PresentsMacro.self] ) { super.invokeTest() @@ -200,6 +200,22 @@ public mutating func _$willModify() { _$observationRegistrar._$willModify() } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu_>(_ lhs: __macro_local_6MemberfMu_, _ rhs: __macro_local_6MemberfMu_) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu0_: Equatable>(_ lhs: __macro_local_6MemberfMu0_, _ rhs: __macro_local_6MemberfMu0_) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu1_: AnyObject>(_ lhs: __macro_local_6MemberfMu1_, _ rhs: __macro_local_6MemberfMu1_) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_6MemberfMu2_: Equatable & AnyObject>(_ lhs: __macro_local_6MemberfMu2_, _ rhs: __macro_local_6MemberfMu2_) -> Bool { + lhs != rhs + } } """# } diff --git a/Tests/ComposableArchitectureTests/ObservableTests.swift b/Tests/ComposableArchitectureTests/ObservableTests.swift index a6ba76cec0a0..d905cd25ef8e 100644 --- a/Tests/ComposableArchitectureTests/ObservableTests.swift +++ b/Tests/ComposableArchitectureTests/ObservableTests.swift @@ -18,6 +18,21 @@ final class ObservableTests: BaseTCATestCase { XCTAssertEqual(state.count, 1) } + func testAssignEqualValue() async { + var state = ChildState() + let didChange = LockIsolated(false) + + withPerceptionTracking { + _ = state.count + } onChange: { + didChange.withValue { $0 = true } + } + + state.count = state.count + XCTAssertEqual(state.count, 0) + XCTAssert(!didChange.withValue { $0 }) + } + func testCopyMutation() async { XCTTODO( """ @@ -63,78 +78,89 @@ final class ObservableTests: BaseTCATestCase { } func testReplace() async { - XCTTODO("Ideally this would pass but we cannot detect this kind of mutation currently.") - + #if swift(<6.2) + if #available(iOS 17, macOS 14, tvOS 14, watchOS 10, *) { + XCTTODO("Ideally this would pass but we cannot detect this kind of mutation currently.") + } + #endif var state = ChildState(count: 42) - let countDidChange = self.expectation(description: "count.didChange") + let didChange = LockIsolated(false) withPerceptionTracking { _ = state.count } onChange: { - countDidChange.fulfill() + didChange.withValue { $0 = true } } state.replace(with: ChildState()) - await self.fulfillment(of: [countDidChange], timeout: 0) XCTAssertEqual(state.count, 0) + XCTAssert(didChange.withValue { $0 }) } func testReset() async { - XCTTODO("Ideally this would pass but we cannot detect this kind of mutation currently.") + #if swift(<6.2) + if #available(iOS 17, macOS 14, tvOS 14, watchOS 10, *) { + XCTTODO("Ideally this would pass but we cannot detect this kind of mutation currently.") + } + #endif var state = ChildState(count: 42) - let countDidChange = self.expectation(description: "count.didChange") + let didChange = LockIsolated(false) withPerceptionTracking { _ = state.count } onChange: { - countDidChange.fulfill() + didChange.withValue { $0 = true } } state.reset() - await self.fulfillment(of: [countDidChange], timeout: 0) XCTAssertEqual(state.count, 0) + XCTAssert(didChange.withValue { $0 }) } func testChildCountMutation() async { var state = ParentState() - let childCountDidChange = self.expectation(description: "child.count.didChange") + let childCountDidChange = LockIsolated(false) + let childDidChange = LockIsolated(false) withPerceptionTracking { _ = state.child.count } onChange: { - childCountDidChange.fulfill() + childCountDidChange.withValue { $0 = true } } withPerceptionTracking { _ = state.child } onChange: { - XCTFail("state.child should not change.") + childDidChange.withValue { $0 = true } } state.child.count += 1 - await self.fulfillment(of: [childCountDidChange], timeout: 0) XCTAssertEqual(state.child.count, 1) + XCTAssert(childCountDidChange.withValue { $0 }) + XCTAssert(!childDidChange.withValue { $0 }) } func testChildReset() async { var state = ParentState() - let childDidChange = self.expectation(description: "child.didChange") + let childCountDidChange = LockIsolated(false) + let childDidChange = LockIsolated(false) let child = state.child withPerceptionTracking { _ = child.count } onChange: { - XCTFail("child.count should not change.") + childCountDidChange.withValue { $0 = true } } withPerceptionTracking { _ = state.child } onChange: { - childDidChange.fulfill() + childDidChange.withValue { $0 = true } } state.child = ChildState(count: 42) - await self.fulfillment(of: [childDidChange], timeout: 0) XCTAssertEqual(state.child.count, 42) + XCTAssert(!childCountDidChange.withValue { $0 }) + XCTAssert(childDidChange.withValue { $0 }) } func testReplaceChild() async { @@ -210,23 +236,25 @@ final class ObservableTests: BaseTCATestCase { func testMutatePresentedOptional() async { var state = ParentState(optional: ChildState()) - let optionalCountDidChange = self.expectation(description: "optional.count.didChange") + let optionalDidChange = LockIsolated(false) + let optionalCountDidChange = LockIsolated(false) withPerceptionTracking { _ = state.optional } onChange: { - XCTFail("Optional should not change") + optionalDidChange.withValue { $0 = true } } let optional = state.optional withPerceptionTracking { _ = optional?.count } onChange: { - optionalCountDidChange.fulfill() + optionalCountDidChange.withValue { $0 = true } } state.optional?.count += 1 - await self.fulfillment(of: [optionalCountDidChange], timeout: 0) XCTAssertEqual(state.optional?.count, 1) + XCTAssert(!optionalDidChange.withValue { $0 }) + XCTAssert(optionalCountDidChange.withValue { $0 }) } func testPresentDestination() async { @@ -429,32 +457,38 @@ final class ObservableTests: BaseTCATestCase { ChildState(), ChildState(), ]) - let firstRowCountDidChange = self.expectation(description: "firstRowCountDidChange") + let rowsDidChange = LockIsolated(false) + let firstRowDidChange = LockIsolated(false) + let firstRowCountDidChange = LockIsolated(false) + let secondRowDidCountChange = LockIsolated(false) withPerceptionTracking { _ = state.rows } onChange: { - XCTFail("rows should not change") + rowsDidChange.withValue { $0 = true } } withPerceptionTracking { _ = state.rows[0] } onChange: { - XCTFail("rows[0] should not change") + firstRowDidChange.withValue { $0 = true } } withPerceptionTracking { _ = state.rows[0].count } onChange: { - firstRowCountDidChange.fulfill() + firstRowCountDidChange.withValue { $0 = true } } withPerceptionTracking { _ = state.rows[1].count } onChange: { - XCTFail("rows[1].count should not change") + secondRowDidCountChange.withValue { $0 = true } } state.rows[0].count += 1 XCTAssertEqual(state.rows[0].count, 1) - self.wait(for: [firstRowCountDidChange], timeout: 0) + XCTAssert(!rowsDidChange.withValue { $0 }) + XCTAssert(!firstRowDidChange.withValue { $0 }) + XCTAssert(firstRowCountDidChange.withValue { $0 }) + XCTAssert(!secondRowDidCountChange.withValue { $0 }) } func testPresents_NilToNonNil() { @@ -474,22 +508,24 @@ final class ObservableTests: BaseTCATestCase { func testPresents_Mutate() { var state = ParentState(presentation: ChildState()) - let presentationCountDidChange = self.expectation(description: "presentationCountDidChange") + let presentationDidChange = LockIsolated(false) + let presentationCountDidChange = LockIsolated(false) withPerceptionTracking { _ = state.presentation } onChange: { - XCTFail("presentation should not change") + presentationDidChange.withValue { $0 = true } } withPerceptionTracking { _ = state.presentation?.count } onChange: { - presentationCountDidChange.fulfill() + presentationCountDidChange.withValue { $0 = true } } state.presentation?.count += 1 XCTAssertEqual(state.presentation?.count, 1) - self.wait(for: [presentationCountDidChange], timeout: 0) + XCTAssert(!presentationDidChange.withValue { $0 }) + XCTAssert(presentationCountDidChange.withValue { $0 }) } func testStackState_AddElement() { @@ -514,32 +550,38 @@ final class ObservableTests: BaseTCATestCase { ChildState(), ]) ) - let firstElementCountDidChange = self.expectation(description: "firstElementCountDidChange") + let pathDidChange = LockIsolated(false) + let firstElementDidChange = LockIsolated(false) + let firstElementCountDidChange = LockIsolated(false) + let secondElementCountDidChange = LockIsolated(false) withPerceptionTracking { _ = state.path } onChange: { - XCTFail("path should not change") + pathDidChange.withValue { $0 = true } } withPerceptionTracking { _ = state.path[0] } onChange: { - XCTFail("path[0] should not change") + firstElementDidChange.withValue { $0 = true } } withPerceptionTracking { _ = state.path[0].count } onChange: { - firstElementCountDidChange.fulfill() + firstElementCountDidChange.withValue { $0 = true } } withPerceptionTracking { _ = state.path[1].count } onChange: { - XCTFail("path[1].count should not change") + secondElementCountDidChange.withValue { $0 = true } } state.path[id: 0]?.count += 1 XCTAssertEqual(state.path[0].count, 1) - self.wait(for: [firstElementCountDidChange], timeout: 0) + XCTAssert(!pathDidChange.withValue { $0 }) + XCTAssert(!firstElementDidChange.withValue { $0 }) + XCTAssert(firstElementCountDidChange.withValue { $0 }) + XCTAssert(!secondElementCountDidChange.withValue { $0 }) } func testCopy() { @@ -575,14 +617,16 @@ final class ObservableTests: BaseTCATestCase { func testArrayMutate() { var state = ParentState(children: [ChildState()]) + var didChange = LockIsolated(false) withPerceptionTracking { _ = state.children } onChange: { - XCTFail("children should not change") + didChange.withValue { $0 = true } } state.children[0].count += 1 + XCTAssert(!didChange.withValue { $0 }) } @MainActor