Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for @Dependency(MyDependency.self) #172

Merged
merged 13 commits into from
Jan 22, 2024
59 changes: 59 additions & 0 deletions Sources/Dependencies/Dependency.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,47 @@ public struct Dependency<Value>: @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: TestDependencyKey>(
_ key: Key.Type,
file: StaticString = #file,
fileID: StaticString = #fileID,
line: UInt = #line
) where Key.Value == Value {
self.init(
\DependencyValues.[HashableType<Key>(file: file, line: line)],
file: file,
fileID: fileID,
line: line
)
}

/// The current value of the dependency property.
public var wrappedValue: Value {
#if DEBUG
Expand All @@ -119,6 +160,24 @@ public struct Dependency<Value>: @unchecked Sendable, _HasInitialValues {
}
}

private struct HashableType<T>: 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: TestDependencyKey>(key: HashableType<Key>) -> 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 }
}
25 changes: 15 additions & 10 deletions Sources/Dependencies/DependencyKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
145 changes: 79 additions & 66 deletions Sources/Dependencies/DependencyValues.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import XCTestDynamicOverlay

/// A collection of dependencies that is globally available.
///
Expand Down Expand Up @@ -81,9 +82,7 @@ import Foundation
/// Read the article <doc:RegisteringDependencies> 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()
Expand All @@ -100,6 +99,12 @@ public struct DependencyValues: Sendable {
#endif
}

@_disfavoredOverload
public subscript<Key: TestDependencyKey>(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``
Expand All @@ -123,9 +128,9 @@ public struct DependencyValues: Sendable {
/// property wrapper.
public subscript<Key: TestDependencyKey>(
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,
Expand Down Expand Up @@ -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
}
}

Expand Down
35 changes: 35 additions & 0 deletions Tests/DependenciesTests/DependencyKeyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions Tests/DependenciesTests/DependencyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading