Skip to content

Commit

Permalink
Add support for @Dependency(MyDependency.self) (#172)
Browse files Browse the repository at this point in the history
* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* fixes

* wip

---------

Co-authored-by: Brandon Williams <[email protected]>
  • Loading branch information
stephencelis and mbrandonw committed Jan 22, 2024
1 parent d3fbf72 commit 4379eaa
Show file tree
Hide file tree
Showing 6 changed files with 257 additions and 91 deletions.
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

0 comments on commit 4379eaa

Please sign in to comment.