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
43 changes: 43 additions & 0 deletions Sources/Dependencies/Dependency.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,42 @@ 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.[\Key.self], file: file, fileID: fileID, line: line)
}

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

fileprivate extension DependencyValues {
subscript<Key: TestDependencyKey>(_: KeyPath<Key, Key>) -> Key.Value {
get { self[Key.self] }
set { self[Key.self] = newValue }
}
}

protocol _HasInitialValues {
var initialValues: DependencyValues { get }
}
29 changes: 17 additions & 12 deletions Sources/Dependencies/DependencyKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
/// implementation module.
///
/// See the <doc:LivePreviewTest> article for more information.
public protocol DependencyKey: TestDependencyKey {
public protocol DependencyKey<Value>: TestDependencyKey {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snuck this feature into the branch, but could extract to a separate PR...

/// The live value for the dependency key.
///
/// This is the value used by default when running the application in a simulator or on a device.
Expand Down Expand Up @@ -118,7 +118,7 @@ public protocol DependencyKey: TestDependencyKey {
/// return a default value suitable for Xcode previews, or the ``testValue``, if left unimplemented.
///
/// See ``DependencyKey`` to define a static, default value for the live application.
public protocol TestDependencyKey {
public protocol TestDependencyKey<Value> {
/// The associated type representing the type of the dependency key's value.
associatedtype Value: Sendable = Self

Expand Down 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
24 changes: 19 additions & 5 deletions Sources/Dependencies/DependencyValues.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,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 Down Expand Up @@ -319,17 +325,21 @@ private final class CachedValues: @unchecked Sendable {
"""
)

var argument: String {
"\(function)" == "subscript(_:)" ? "\(typeName(Key.self)).self" : "\\.\(function)"
}

runtimeWarn(
"""
"@Dependency(\\.\(function))" has no live implementation, but was accessed from a \
live context.
@Dependency(\(argument)) has no live implementation, but was accessed from a live \
context.

\(dependencyDescription)

Every dependency registered with the library must conform to "DependencyKey", and \
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 \
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.
""",
Expand All @@ -338,7 +348,11 @@ private final class CachedValues: @unchecked Sendable {
)
}
#endif
return Key.testValue
let value = Key.testValue
if !DependencyValues.isSetting {
self.cached[cacheKey] = AnySendable(value)
}
return value
}

self.cached[cacheKey] = AnySendable(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

XCTAssertEqual(int, 0)

withDependencies {
$0[MyDependency.self] = 42
} operation: {
XCTAssertEqual(int, 42)
}
}
}

private class Model {
Expand Down
52 changes: 41 additions & 11 deletions Tests/DependenciesTests/DependencyValuesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
"""
Expand Down Expand Up @@ -169,19 +203,15 @@ 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.
"""
)
}
#endif
XCTAssertEqual(reuseClient.count(), 0)
reuseClient.setCount(-42)
XCTAssertEqual(
reuseClient.count(),
0,
"Don't cache dependency when using a test value in a live context"
)
XCTAssertEqual(reuseClient.count(), -42)
}

XCTAssertEqual(reuseClient.count(), 42)
Expand All @@ -205,8 +235,8 @@ final class DependencyValuesTests: XCTestCase {
XCTExpectFailure {
$0.compactDescription.contains(
"""
@Dependency(\\.reuseClient)" has no live implementation, but was accessed from a live \
context.
@Dependency(\\.reuseClient) has no live implementation, but was accessed from a \
live context.
"""
)
}
Expand Down
Loading