From 4379eaa2a81d04dbedd5a1874085bd7330da0182 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 22 Jan 2024 12:15:17 -0800 Subject: [PATCH] Add support for `@Dependency(MyDependency.self)` (#172) * wip * wip * wip * wip * wip * wip * wip * wip * wip * fixes * wip --------- Co-authored-by: Brandon Williams --- Sources/Dependencies/Dependency.swift | 59 +++++++ Sources/Dependencies/DependencyKey.swift | 25 +-- Sources/Dependencies/DependencyValues.swift | 145 ++++++++++-------- .../DependencyKeyTests.swift | 35 +++++ Tests/DependenciesTests/DependencyTests.swift | 15 ++ .../DependencyValuesTests.swift | 69 +++++++-- 6 files changed, 257 insertions(+), 91 deletions(-) diff --git a/Sources/Dependencies/Dependency.swift b/Sources/Dependencies/Dependency.swift index 73ca3d76..ffb329dd 100644 --- a/Sources/Dependencies/Dependency.swift +++ b/Sources/Dependencies/Dependency.swift @@ -97,6 +97,47 @@ public struct Dependency: @unchecked Sendable, _HasInitialValues { self.line = line } + /// Creates a dependency property to read a dependency object. + /// + /// Don't call this initializer directly. Instead, declare a property with the `Dependency` + /// property wrapper, and provide the dependency key of the value that the property should + /// reflect. + /// + /// For example, given a dependency key: + /// + /// ```swift + /// final class Settings: DependencyKey { + /// static let liveValue = Settings() + /// + /// // ... + /// } + /// ``` + /// + /// One can access the dependency using this property wrapper: + /// + /// ```swift + /// final class FeatureModel: ObservableObject { + /// @Dependency(Settings.self) var settings + /// + /// // ... + /// } + /// ``` + /// + /// - Parameter key: A dependency key to a specific resulting value. + public init( + _ key: Key.Type, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line + ) where Key.Value == Value { + self.init( + \DependencyValues.[HashableType(file: file, line: line)], + file: file, + fileID: fileID, + line: line + ) + } + /// The current value of the dependency property. public var wrappedValue: Value { #if DEBUG @@ -119,6 +160,24 @@ public struct Dependency: @unchecked Sendable, _HasInitialValues { } } +private struct HashableType: Hashable { + let file: StaticString + let line: UInt + static func == (lhs: Self, rhs: Self) -> Bool { + true + } + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(T.self)) + } +} + +fileprivate extension DependencyValues { + subscript(key: HashableType) -> Key.Value { + get { self[Key.self, file: key.file, line: key.line] } + set { self[Key.self, file: key.file, line: key.line] = newValue } + } +} + protocol _HasInitialValues { var initialValues: DependencyValues { get } } diff --git a/Sources/Dependencies/DependencyKey.swift b/Sources/Dependencies/DependencyKey.swift index 70378981..07995e50 100644 --- a/Sources/Dependencies/DependencyKey.swift +++ b/Sources/Dependencies/DependencyKey.swift @@ -210,25 +210,30 @@ extension DependencyKey { \(typeName(Value.self)) """ ) - let dependencyName = + + let (argument, override) = DependencyValues.currentDependency.name - .map { "@Dependency(\\.\($0))" } - ?? "A dependency" + .map { + "\($0)" == "subscript(_:)" + ? ("@Dependency(\(typeName(Self.self)).self)", "'\(typeName(Self.self)).self'") + : ("@Dependency(\\.\($0))", "'\($0)'") + } + ?? ("A dependency", "the dependency") + XCTFail( """ - \(dependencyName) has no test implementation, but was accessed from a test context: + \(argument) has no test implementation, but was accessed from a test context: \(dependencyDescription) Dependencies registered with the library are not allowed to use their default, live \ implementations when run from tests. - To fix, override \ - \(DependencyValues.currentDependency.name.map { "'\($0)'" } ?? "the dependency") with a \ - test value. If you are using the Composable Architecture, mutate the 'dependencies' \ - property on your 'TestStore'. Otherwise, use 'withDependencies' to define a scope for the \ - override. If you'd like to provide a default value for all tests, implement the \ - 'testValue' requirement of the 'DependencyKey' protocol. + To fix, override \(override) with a test value. If you are using the \ + Composable Architecture, mutate the 'dependencies' property on your 'TestStore'. \ + Otherwise, use 'withDependencies' to define a scope for the override. If you'd like to \ + provide a default value for all tests, implement the 'testValue' requirement of the \ + 'DependencyKey' protocol. """ ) #endif diff --git a/Sources/Dependencies/DependencyValues.swift b/Sources/Dependencies/DependencyValues.swift index d1abca76..7a99ca88 100644 --- a/Sources/Dependencies/DependencyValues.swift +++ b/Sources/Dependencies/DependencyValues.swift @@ -1,4 +1,5 @@ import Foundation +import XCTestDynamicOverlay /// A collection of dependencies that is globally available. /// @@ -81,9 +82,7 @@ import Foundation /// Read the article for more information. public struct DependencyValues: Sendable { @TaskLocal public static var _current = Self() - #if DEBUG - @TaskLocal static var isSetting = false - #endif + @TaskLocal static var isSetting = false @TaskLocal static var currentDependency = CurrentDependency() fileprivate var cachedValues = CachedValues() @@ -100,6 +99,12 @@ public struct DependencyValues: Sendable { #endif } + @_disfavoredOverload + public subscript(type: Key.Type) -> Key.Value { + get { self[type] } + set { self[type] = newValue } + } + /// Accesses the dependency value associated with a custom key. /// /// This subscript is typically only used when adding a computed property to ``DependencyValues`` @@ -123,9 +128,9 @@ public struct DependencyValues: Sendable { /// property wrapper. public subscript( key: Key.Type, - file: StaticString = #file, - function: StaticString = #function, - line: UInt = #line + file file: StaticString = #file, + function function: StaticString = #function, + line line: UInt = #line ) -> Key.Value where Key.Value: Sendable { get { guard let base = self.storage[ObjectIdentifier(key)]?.base, @@ -273,81 +278,89 @@ private final class CachedValues: @unchecked Sendable { function: StaticString = #function, line: UInt = #line ) -> Key.Value where Key.Value: Sendable { - self.lock.lock() - defer { self.lock.unlock() } - - let cacheKey = CacheKey(id: ObjectIdentifier(key), context: context) - guard let base = self.cached[cacheKey]?.base, let value = base as? Key.Value - else { - let value: Key.Value? - switch context { - case .live: - value = _liveValue(key) as? Key.Value - case .preview: - value = Key.previewValue - case .test: - value = Key.testValue - } + XCTFailContext.$current.withValue(XCTFailContext(file: file, line: line)) { + self.lock.lock() + defer { self.lock.unlock() } - guard let value = value + let cacheKey = CacheKey(id: ObjectIdentifier(key), context: context) + guard let base = self.cached[cacheKey]?.base, let value = base as? Key.Value else { - #if DEBUG - if !DependencyValues.isSetting { - var dependencyDescription = "" - if let fileID = DependencyValues.currentDependency.fileID, - let line = DependencyValues.currentDependency.line - { - dependencyDescription.append( - """ - Location: - \(fileID):\(line) + let value: Key.Value? + switch context { + case .live: + value = _liveValue(key) as? Key.Value + case .preview: + value = Key.previewValue + case .test: + value = Key.testValue + } - """ + guard let value = value + else { + #if DEBUG + if !DependencyValues.isSetting { + var dependencyDescription = "" + if let fileID = DependencyValues.currentDependency.fileID, + let line = DependencyValues.currentDependency.line + { + dependencyDescription.append( + """ + Location: + \(fileID):\(line) + + """ + ) + } + dependencyDescription.append( + Key.self == Key.Value.self + ? """ + Dependency: + \(typeName(Key.Value.self)) + """ + : """ + Key: + \(typeName(Key.self)) + Value: + \(typeName(Key.Value.self)) + """ ) - } - dependencyDescription.append( - Key.self == Key.Value.self - ? """ - Dependency: - \(typeName(Key.Value.self)) - """ - : """ - Key: - \(typeName(Key.self)) - Value: - \(typeName(Key.Value.self)) - """ - ) - runtimeWarn( - """ - "@Dependency(\\.\(function))" has no live implementation, but was accessed from a \ - live context. + var argument: String { + "\(function)" == "subscript(_:)" ? "\(typeName(Key.self)).self" : "\\.\(function)" + } - \(dependencyDescription) + runtimeWarn( + """ + @Dependency(\(argument)) has no live implementation, but was accessed from a live \ + context. - Every dependency registered with the library must conform to "DependencyKey", and \ - that conformance must be visible to the running application. + \(dependencyDescription) - To fix, make sure that "\(typeName(Key.self))" conforms to "DependencyKey" by \ - providing a live implementation of your dependency, and make sure that the \ - conformance is linked with this current application. - """, - file: DependencyValues.currentDependency.file ?? file, - line: DependencyValues.currentDependency.line ?? line - ) + Every dependency registered with the library must conform to 'DependencyKey', and \ + that conformance must be visible to the running application. + + To fix, make sure that '\(typeName(Key.self))' conforms to 'DependencyKey' by \ + providing a live implementation of your dependency, and make sure that the \ + conformance is linked with this current application. + """, + file: DependencyValues.currentDependency.file ?? file, + line: DependencyValues.currentDependency.line ?? line + ) + } + #endif + let value = Key.testValue + if !DependencyValues.isSetting { + self.cached[cacheKey] = AnySendable(value) } - #endif - let value = Key.testValue + return value + } + self.cached[cacheKey] = AnySendable(value) return value } - self.cached[cacheKey] = AnySendable(value) return value } - - return value } } diff --git a/Tests/DependenciesTests/DependencyKeyTests.swift b/Tests/DependenciesTests/DependencyKeyTests.swift index a23fc427..796a4545 100644 --- a/Tests/DependenciesTests/DependencyKeyTests.swift +++ b/Tests/DependenciesTests/DependencyKeyTests.swift @@ -154,6 +154,41 @@ final class DependencyKeyTests: XCTestCase { } #endif } + + func testDependencyKeyCascading_ImplementOnlyLive_NamedType() { + #if DEBUG && !os(Linux) && !os(WASI) && !os(Windows) + withDependencies { + $0.context = .test + } operation: { + @Dependency(LiveKey.self) var missingTestDependency: Int + let line = #line - 1 + XCTExpectFailure { + XCTAssertEqual(42, missingTestDependency) + } issueMatcher: { issue in + issue.compactDescription == """ + @Dependency(LiveKey.self) has no test implementation, but was accessed from a test \ + context: + + Location: + DependenciesTests/DependencyKeyTests.swift:\(line) + Key: + LiveKey + Value: + Int + + Dependencies registered with the library are not allowed to use their default, live \ + implementations when run from tests. + + To fix, override 'LiveKey.self' with a test value. If you are using the \ + Composable Architecture, mutate the 'dependencies' property on your 'TestStore'. \ + Otherwise, use 'withDependencies' to define a scope for the override. If you'd \ + like to provide a default value for all tests, implement the 'testValue' requirement \ + of the 'DependencyKey' protocol. + """ + } + } + #endif + } } private enum LiveKey: DependencyKey { diff --git a/Tests/DependenciesTests/DependencyTests.swift b/Tests/DependenciesTests/DependencyTests.swift index 33861440..4ccc03eb 100644 --- a/Tests/DependenciesTests/DependencyTests.swift +++ b/Tests/DependenciesTests/DependencyTests.swift @@ -206,6 +206,21 @@ final class DependencyTests: XCTestCase { XCTAssertEqual(user1.id, UUID(0)) XCTAssertEqual(user2.id, UUID(1)) } + + func testDependencyType() { + struct MyDependency: TestDependencyKey { + static var testValue: Int { 0 } + } + @Dependency(MyDependency.self) var int: Int + + XCTAssertEqual(int, 0) + + withDependencies { + $0[MyDependency.self] = 42 + } operation: { + XCTAssertEqual(int, 42) + } + } } private class Model { diff --git a/Tests/DependenciesTests/DependencyValuesTests.swift b/Tests/DependenciesTests/DependencyValuesTests.swift index 831d0b9b..790f4fba 100644 --- a/Tests/DependenciesTests/DependencyValuesTests.swift +++ b/Tests/DependenciesTests/DependencyValuesTests.swift @@ -27,7 +27,7 @@ final class DependencyValuesTests: XCTestCase { } } issueMatcher: { $0.compactDescription == """ - "@Dependency(\\.missingLiveDependency)" has no live implementation, but was accessed \ + @Dependency(\\.missingLiveDependency) has no live implementation, but was accessed \ from a live context. Location: @@ -37,10 +37,44 @@ final class DependencyValuesTests: XCTestCase { Value: Int - Every dependency registered with the library must conform to "DependencyKey", and that \ + Every dependency registered with the library must conform to 'DependencyKey', and that \ conformance must be visible to the running application. - To fix, make sure that "TestKey" conforms to "DependencyKey" by providing a live \ + To fix, make sure that 'TestKey' conforms to 'DependencyKey' by providing a live \ + implementation of your dependency, and make sure that the conformance is linked with \ + this current application. + """ + } + #endif + } + + func testMissingLiveValue_Type() { + #if DEBUG && !os(Linux) && !os(WASI) && !os(Windows) + var line = 0 + XCTExpectFailure { + withDependencies { + $0.context = .live + } operation: { + line = #line + 1 + @Dependency(TestKey.self) var missingLiveDependency: Int + _ = missingLiveDependency + } + } issueMatcher: { + $0.compactDescription == """ + @Dependency(TestKey.self) has no live implementation, but was accessed from a live \ + context. + + Location: + DependenciesTests/DependencyValuesTests.swift:\(line) + Key: + TestKey + Value: + Int + + Every dependency registered with the library must conform to 'DependencyKey', and that \ + conformance must be visible to the running application. + + To fix, make sure that 'TestKey' conforms to 'DependencyKey' by providing a live \ implementation of your dependency, and make sure that the conformance is linked with \ this current application. """ @@ -169,7 +203,7 @@ final class DependencyValuesTests: XCTestCase { XCTExpectFailure { $0.compactDescription.contains( """ - @Dependency(\\.reuseClient)" has no live implementation, but was accessed from a \ + @Dependency(\\.reuseClient) has no live implementation, but was accessed from a \ live context. """ ) @@ -197,17 +231,22 @@ final class DependencyValuesTests: XCTestCase { withDependencies { $0.context = .live } operation: { - #if DEBUG - XCTExpectFailure { - $0.compactDescription.contains( - """ - @Dependency(\\.reuseClient)" has no live implementation, but was accessed from a live \ - context. - """ - ) - } - #endif - XCTAssertEqual(reuseClient.count(), 0) + withDependencies { + XCTAssertEqual($0.reuseClient.count(), 0) + XCTAssertEqual(reuseClient.count(), 0) + } operation: { + #if DEBUG + XCTExpectFailure { + $0.compactDescription.contains( + """ + @Dependency(\\.reuseClient) has no live implementation, but was accessed from a \ + live context. + """ + ) + } + #endif + XCTAssertEqual(reuseClient.count(), 0) + } } #endif }