From fdeeb2caaae119f80f358e1b87f092e2a5bfe2a1 Mon Sep 17 00:00:00 2001 From: David Idol Date: Tue, 19 May 2026 15:13:11 -0400 Subject: [PATCH] Improve testability and add package tests --- Package.swift | 12 +- Sources/iCloudKVKey.swift | 228 ++++++++++-------- Sources/iCloudKVStore.swift | 101 ++++++++ .../InMemoryiCloudKVStoreTests.swift | 51 ++++ .../SharingCloudTests/iCloudKVKeyTests.swift | 175 ++++++++++++++ 5 files changed, 471 insertions(+), 96 deletions(-) create mode 100644 Sources/iCloudKVStore.swift create mode 100644 Tests/SharingCloudTests/InMemoryiCloudKVStoreTests.swift create mode 100644 Tests/SharingCloudTests/iCloudKVKeyTests.swift diff --git a/Package.swift b/Package.swift index b913451..8ccde17 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.0.0"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.5.1"), ], targets: [ .target( @@ -26,8 +27,15 @@ let package = Package( dependencies: [ .product(name: "Sharing", package: "swift-sharing"), ], - path: "", - sources: ["Sources"] + path: "Sources" + ), + .testTarget( + name: "SharingCloudTests", + dependencies: [ + "SharingCloud", + .product(name: "DependenciesTestSupport", package: "swift-dependencies"), + ], + path: "Tests/SharingCloudTests" ), ] ) diff --git a/Sources/iCloudKVKey.swift b/Sources/iCloudKVKey.swift index 552999d..4a80fe2 100644 --- a/Sources/iCloudKVKey.swift +++ b/Sources/iCloudKVKey.swift @@ -18,70 +18,84 @@ public extension SharedReaderKey { /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to an integer iCloud key-value store. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to a double iCloud key-value store. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to a string iCloud key-value store. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to a URL iCloud key-value store. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to an iCloud key-value store as data. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to a date iCloud key-value store. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to an integer iCloud key-value store, transforming @@ -89,12 +103,14 @@ public extension SharedReaderKey { /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. static func iCloudKV>( - _ key: String + _ key: String, store: (any iCloudKVStore)? = nil ) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to a string iCloud key-value store, transforming @@ -102,82 +118,98 @@ public extension SharedReaderKey { /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. static func iCloudKV>( - _ key: String + _ key: String, store: (any iCloudKVStore)? = nil ) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to an optional boolean iCloud key-value store. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to an optional integer iCloud key-value store. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to an optional double iCloud key-value store. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to an optional string iCloud key-value store. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to an optional URL iCloud key-value store. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to an iCloud key-value store as optional data. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to an optional date iCloud key-value store. /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. - static func iCloudKV(_ key: String) -> Self + static func iCloudKV(_ key: String, store: (any iCloudKVStore)? = nil) -> Self where Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to an integer iCloud key-value store, transforming @@ -185,12 +217,14 @@ public extension SharedReaderKey { /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. static func iCloudKV( - _ key: String + _ key: String, store: (any iCloudKVStore)? = nil ) -> Self where Value.RawValue == Int, Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } /// Creates a shared key that can read and write to a string iCloud key-value store, transforming @@ -198,12 +232,14 @@ public extension SharedReaderKey { /// /// - Parameters: /// - key: The key to read and write the value to in the iCloud key-value store. + /// - store: The iCloud key-value store to read and write to. A value of `nil` uses + /// ``DependencyValues/defaultiCloudKVStore``. /// - Returns: An iCloud key-value store shared key. static func iCloudKV( - _ key: String + _ key: String, store: (any iCloudKVStore)? = nil ) -> Self where Value.RawValue == String, Self == iCloudKVKey { - iCloudKVKey(key) + iCloudKVKey(key, store: store) } } @@ -211,15 +247,17 @@ public extension SharedReaderKey { public struct iCloudKVKey: SharedKey { private let lookup: any Lookup private let key: String - private nonisolated(unsafe) let store: NSUbiquitousKeyValueStore + private let store: UncheckedSendable public var id: iCloudKVKeyID { - iCloudKVKeyID(key: key) + iCloudKVKeyID(key: key, storeID: ObjectIdentifier(store.wrappedValue)) } - private init(lookup: some Lookup, key: String) { + private init(lookup: some Lookup, key: String, store: (any iCloudKVStore)?) { + @Dependency(\.defaultiCloudKVStore) var defaultStore self.lookup = lookup self.key = key + self.store = UncheckedSendable(store ?? defaultStore) // Check key length limitation if key.lengthOfBytes(using: .utf8) > 64 { @@ -232,92 +270,92 @@ public struct iCloudKVKey: SharedKey { """ ) } - - store = .default } - fileprivate init(_ key: String) where Value == Bool { - self.init(lookup: CastableLookup(), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == Bool { + self.init(lookup: CastableLookup(), key: key, store: store) } - fileprivate init(_ key: String) where Value == Int { - self.init(lookup: CastableLookup(), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == Int { + self.init(lookup: CastableLookup(), key: key, store: store) } - fileprivate init(_ key: String) where Value == Double { - self.init(lookup: CastableLookup(), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == Double { + self.init(lookup: CastableLookup(), key: key, store: store) } - fileprivate init(_ key: String) where Value == String { - self.init(lookup: CastableLookup(), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == String { + self.init(lookup: CastableLookup(), key: key, store: store) } - fileprivate init(_ key: String) where Value == URL { - self.init(lookup: URLLookup(), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == URL { + self.init(lookup: URLLookup(), key: key, store: store) } - fileprivate init(_ key: String) where Value == Data { - self.init(lookup: CastableLookup(), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == Data { + self.init(lookup: CastableLookup(), key: key, store: store) } - fileprivate init(_ key: String) where Value == Date { - self.init(lookup: CastableLookup(), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == Date { + self.init(lookup: CastableLookup(), key: key, store: store) } - fileprivate init(_ key: String) where Value: RawRepresentable { - self.init(lookup: RawRepresentableLookup(base: CastableLookup()), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value: RawRepresentable { + self.init(lookup: RawRepresentableLookup(base: CastableLookup()), key: key, store: store) } - fileprivate init(_ key: String) where Value: RawRepresentable { - self.init(lookup: RawRepresentableLookup(base: CastableLookup()), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value: RawRepresentable { + self.init(lookup: RawRepresentableLookup(base: CastableLookup()), key: key, store: store) } - fileprivate init(_ key: String) where Value == Bool? { - self.init(lookup: OptionalLookup(base: CastableLookup()), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == Bool? { + self.init(lookup: OptionalLookup(base: CastableLookup()), key: key, store: store) } - fileprivate init(_ key: String) where Value == Int? { - self.init(lookup: OptionalLookup(base: CastableLookup()), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == Int? { + self.init(lookup: OptionalLookup(base: CastableLookup()), key: key, store: store) } - fileprivate init(_ key: String) where Value == Double? { - self.init(lookup: OptionalLookup(base: CastableLookup()), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == Double? { + self.init(lookup: OptionalLookup(base: CastableLookup()), key: key, store: store) } - fileprivate init(_ key: String) where Value == String? { - self.init(lookup: OptionalLookup(base: CastableLookup()), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == String? { + self.init(lookup: OptionalLookup(base: CastableLookup()), key: key, store: store) } - fileprivate init(_ key: String) where Value == URL? { - self.init(lookup: OptionalLookup(base: URLLookup()), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == URL? { + self.init(lookup: OptionalLookup(base: URLLookup()), key: key, store: store) } - fileprivate init(_ key: String) where Value == Data? { - self.init(lookup: OptionalLookup(base: CastableLookup()), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == Data? { + self.init(lookup: OptionalLookup(base: CastableLookup()), key: key, store: store) } - fileprivate init(_ key: String) where Value == Date? { - self.init(lookup: OptionalLookup(base: CastableLookup()), key: key) + fileprivate init(_ key: String, store: (any iCloudKVStore)?) where Value == Date? { + self.init(lookup: OptionalLookup(base: CastableLookup()), key: key, store: store) } - fileprivate init>(_ key: String) + fileprivate init>(_ key: String, store: (any iCloudKVStore)?) where Value == R? { self.init( lookup: OptionalLookup(base: RawRepresentableLookup(base: CastableLookup())), - key: key + key: key, + store: store ) } - fileprivate init>(_ key: String) + fileprivate init>(_ key: String, store: (any iCloudKVStore)?) where Value == R? { self.init( lookup: OptionalLookup(base: RawRepresentableLookup(base: CastableLookup())), - key: key + key: key, + store: store ) } public func load(context: LoadContext, continuation: LoadContinuation) { - store.synchronize() + store.wrappedValue.synchronize() continuation.resume(with: .success(lookupValue(default: context.initialValue))) } @@ -329,7 +367,7 @@ public struct iCloudKVKey: SharedKey { // Register for iCloud key-value store changes nonisolated(unsafe) let iCloudStoreDidChange = NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, - object: store, + object: store.wrappedValue, queue: nil ) { notification in guard @@ -379,7 +417,7 @@ public struct iCloudKVKey: SharedKey { } } - if !store.synchronize() { + if !store.wrappedValue.synchronize() { reportIssue( """ Failed to synchronize iCloud key-value store. @@ -392,12 +430,13 @@ public struct iCloudKVKey: SharedKey { // Also refresh on application becoming active nonisolated(unsafe) let willEnterForeground: (any NSObjectProtocol)? if let willEnterForegroundNotificationName { + let foregroundStore = store willEnterForeground = NotificationCenter.default.addObserver( forName: willEnterForegroundNotificationName, object: nil, queue: .main ) { _ in - store.synchronize() + foregroundStore.wrappedValue.synchronize() } } else { willEnterForeground = nil @@ -412,12 +451,12 @@ public struct iCloudKVKey: SharedKey { } public func save(_ value: Value, context _: SaveContext, continuation: SaveContinuation) { - lookup.saveValue(value, to: store, at: key) + lookup.saveValue(value, to: store.wrappedValue, at: key) continuation.resume() } private func lookupValue(default initialValue: Value?) -> Value? { - lookup.loadValue(from: store, at: key, default: initialValue) + lookup.loadValue(from: store.wrappedValue, at: key, default: initialValue) } } @@ -429,6 +468,7 @@ extension iCloudKVKey: CustomStringConvertible { public struct iCloudKVKeyID: Hashable { fileprivate let key: String + fileprivate let storeID: ObjectIdentifier } // For local state tracking @@ -436,20 +476,20 @@ private enum SharediCloudKVLocals { @TaskLocal static var isSetting = false } -// Lookup protocol for NSUbiquitousKeyValueStore +// Lookup protocol for iCloudKVStore private protocol Lookup: Sendable { associatedtype Value: Sendable func loadValue( - from store: NSUbiquitousKeyValueStore, + from store: any iCloudKVStore, at key: String, default defaultValue: Value? ) -> Value? - func saveValue(_ newValue: Value, to store: NSUbiquitousKeyValueStore, at key: String) + func saveValue(_ newValue: Value, to store: any iCloudKVStore, at key: String) } private struct CastableLookup: Lookup { func loadValue( - from store: NSUbiquitousKeyValueStore, + from store: any iCloudKVStore, at key: String, default defaultValue: Value? ) -> Value? { @@ -466,7 +506,7 @@ private struct CastableLookup: Lookup { return value } - func saveValue(_ newValue: Value, to store: NSUbiquitousKeyValueStore, at key: String) { + func saveValue(_ newValue: Value, to store: any iCloudKVStore, at key: String) { SharediCloudKVLocals.$isSetting.withValue(true) { store.set(newValue, forKey: key) } @@ -476,7 +516,7 @@ private struct CastableLookup: Lookup { private struct URLLookup: Lookup { typealias Value = URL - func loadValue(from store: NSUbiquitousKeyValueStore, at key: String, default defaultValue: URL?) -> URL? { + func loadValue(from store: any iCloudKVStore, at key: String, default defaultValue: URL?) -> URL? { guard let stringValue = store.string(forKey: key), let url = URL(string: stringValue) else { guard !SharediCloudKVLocals.isSetting @@ -491,7 +531,7 @@ private struct URLLookup: Lookup { return url } - func saveValue(_ newValue: URL, to store: NSUbiquitousKeyValueStore, at key: String) { + func saveValue(_ newValue: URL, to store: any iCloudKVStore, at key: String) { SharediCloudKVLocals.$isSetting.withValue(true) { store.set(newValue.absoluteString, forKey: key) } @@ -502,13 +542,13 @@ private struct RawRepresentableLookup Value? { base.loadValue(from: store, at: key, default: defaultValue?.rawValue) .flatMap(Value.init(rawValue:)) } - func saveValue(_ newValue: Value, to store: NSUbiquitousKeyValueStore, at key: String) { + func saveValue(_ newValue: Value, to store: any iCloudKVStore, at key: String) { base.saveValue(newValue.rawValue, to: store, at: key) } } @@ -516,14 +556,14 @@ private struct RawRepresentableLookup: Lookup { let base: Base func loadValue( - from store: NSUbiquitousKeyValueStore, at key: String, default defaultValue: Base.Value?? + from store: any iCloudKVStore, at key: String, default defaultValue: Base.Value?? ) -> Base.Value?? { base.loadValue(from: store, at: key, default: defaultValue ?? nil) .flatMap(Optional.some) ?? .none } - func saveValue(_ newValue: Base.Value?, to store: NSUbiquitousKeyValueStore, at key: String) { + func saveValue(_ newValue: Base.Value?, to store: any iCloudKVStore, at key: String) { if let newValue { base.saveValue(newValue, to: store, at: key) } else { diff --git a/Sources/iCloudKVStore.swift b/Sources/iCloudKVStore.swift new file mode 100644 index 0000000..be36d17 --- /dev/null +++ b/Sources/iCloudKVStore.swift @@ -0,0 +1,101 @@ +#if canImport(AppKit) || canImport(UIKit) || canImport(WatchKit) +import Dependencies +import Foundation + +/// A type that abstracts the storage operations used by ``iCloudKVKey``. +/// +/// `NSUbiquitousKeyValueStore` conforms to this protocol, and an +/// ``InMemoryiCloudKVStore`` is provided for tests and previews where the +/// `ubiquity-kvstore-identifier` entitlement is unavailable. +/// +/// Override ``DependencyValues/defaultiCloudKVStore`` to control which store +/// is used by `@Shared(.iCloudKV(...))`. +public protocol iCloudKVStore: AnyObject { + func object(forKey key: String) -> Any? + func string(forKey key: String) -> String? + func set(_ value: Any?, forKey key: String) + func removeObject(forKey key: String) + @discardableResult + func synchronize() -> Bool +} + +extension NSUbiquitousKeyValueStore: iCloudKVStore {} + +/// An in-memory ``iCloudKVStore`` suitable for tests and previews. +/// +/// Reads and writes go to an in-process dictionary; `synchronize()` is a +/// no-op that always succeeds. +public final class InMemoryiCloudKVStore: iCloudKVStore, @unchecked Sendable { + private let lock = NSLock() + private var values: [String: Any] = [:] + + public init() {} + + public func object(forKey key: String) -> Any? { + lock.lock() + defer { lock.unlock() } + return values[key] + } + + public func string(forKey key: String) -> String? { + object(forKey: key) as? String + } + + public func set(_ value: Any?, forKey key: String) { + lock.lock() + defer { lock.unlock() } + if let value { + values[key] = value + } else { + values.removeValue(forKey: key) + } + } + + public func removeObject(forKey key: String) { + set(nil, forKey: key) + } + + @discardableResult + public func synchronize() -> Bool { true } +} + +extension DependencyValues { + /// The default iCloud key-value store used by ``SharedReaderKey/iCloudKV(_:)``. + /// + /// In live contexts this is `NSUbiquitousKeyValueStore.default`. In tests and previews this + /// is an ``InMemoryiCloudKVStore`` so that features remain hermetic and the + /// `ubiquity-kvstore-identifier` entitlement is not required. + /// + /// Override at app entry to opt into an in-memory store during UI testing, for example: + /// + /// ```swift + /// @main + /// struct MyApp: App { + /// init() { + /// if ProcessInfo.processInfo.environment["UITesting"] == "true" { + /// prepareDependencies { + /// $0.defaultiCloudKVStore = InMemoryiCloudKVStore() + /// } + /// } + /// } + /// // ... + /// } + /// ``` + public var defaultiCloudKVStore: any iCloudKVStore { + get { self[DefaultiCloudKVStoreKey.self].wrappedValue } + set { self[DefaultiCloudKVStoreKey.self] = UncheckedSendable(newValue) } + } +} + +private enum DefaultiCloudKVStoreKey: DependencyKey { + static var liveValue: UncheckedSendable { + UncheckedSendable(NSUbiquitousKeyValueStore.default) + } + static var previewValue: UncheckedSendable { + UncheckedSendable(InMemoryiCloudKVStore()) + } + static var testValue: UncheckedSendable { + UncheckedSendable(InMemoryiCloudKVStore()) + } +} +#endif diff --git a/Tests/SharingCloudTests/InMemoryiCloudKVStoreTests.swift b/Tests/SharingCloudTests/InMemoryiCloudKVStoreTests.swift new file mode 100644 index 0000000..584df28 --- /dev/null +++ b/Tests/SharingCloudTests/InMemoryiCloudKVStoreTests.swift @@ -0,0 +1,51 @@ +#if canImport(AppKit) || canImport(UIKit) || canImport(WatchKit) +import Foundation +import SharingCloud +import Testing + +@Suite struct InMemoryiCloudKVStoreTests { + @Test func setAndGetObject() { + let store = InMemoryiCloudKVStore() + store.set("hello", forKey: "k") + #expect(store.object(forKey: "k") as? String == "hello") + } + + @Test func setNilRemovesKey() { + let store = InMemoryiCloudKVStore() + store.set("hello", forKey: "k") + store.set(nil, forKey: "k") + #expect(store.object(forKey: "k") == nil) + } + + @Test func removeObject() { + let store = InMemoryiCloudKVStore() + store.set(42, forKey: "k") + store.removeObject(forKey: "k") + #expect(store.object(forKey: "k") == nil) + } + + @Test func stringAccessorReturnsString() { + let store = InMemoryiCloudKVStore() + store.set("hi", forKey: "k") + #expect(store.string(forKey: "k") == "hi") + } + + @Test func stringAccessorReturnsNilForNonString() { + let store = InMemoryiCloudKVStore() + store.set(42, forKey: "k") + #expect(store.string(forKey: "k") == nil) + } + + @Test func synchronizeAlwaysSucceeds() { + #expect(InMemoryiCloudKVStore().synchronize()) + } + + @Test func independentInstancesDoNotShareState() { + let a = InMemoryiCloudKVStore() + let b = InMemoryiCloudKVStore() + a.set("only-in-a", forKey: "k") + #expect(a.object(forKey: "k") as? String == "only-in-a") + #expect(b.object(forKey: "k") == nil) + } +} +#endif diff --git a/Tests/SharingCloudTests/iCloudKVKeyTests.swift b/Tests/SharingCloudTests/iCloudKVKeyTests.swift new file mode 100644 index 0000000..4090432 --- /dev/null +++ b/Tests/SharingCloudTests/iCloudKVKeyTests.swift @@ -0,0 +1,175 @@ +#if canImport(AppKit) || canImport(UIKit) || canImport(WatchKit) +import Dependencies +import Foundation +import Sharing +import SharingCloud +import Testing + +@Suite struct iCloudKVKeyTests { + @Dependency(\.defaultiCloudKVStore) var store + + @Test func defaultIsUsedAsInMemoryStoreInTests() { + #expect(store is InMemoryiCloudKVStore) + } + + @Test func bool() { + @Shared(.iCloudKV("bool")) var bool = true + #expect(store.object(forKey: "bool") as? Bool == true) + $bool.withLock { $0 = false } + #expect(store.object(forKey: "bool") as? Bool == false) + #expect(bool == false) + } + + @Test func int() { + @Shared(.iCloudKV("int")) var int = 42 + #expect(store.object(forKey: "int") as? Int == 42) + $int.withLock { $0 = 1729 } + #expect(store.object(forKey: "int") as? Int == 1729) + #expect(int == 1729) + } + + @Test func double() { + @Shared(.iCloudKV("double")) var double = 1.2 + #expect(store.object(forKey: "double") as? Double == 1.2) + $double.withLock { $0 = 3.4 } + #expect(store.object(forKey: "double") as? Double == 3.4) + #expect(double == 3.4) + } + + @Test func string() { + @Shared(.iCloudKV("string")) var string = "Blob" + #expect(store.string(forKey: "string") == "Blob") + $string.withLock { $0 = "Blob, Jr." } + #expect(store.string(forKey: "string") == "Blob, Jr.") + #expect(string == "Blob, Jr.") + } + + @Test func url() { + let initial = URL(fileURLWithPath: "/dev") + let updated = URL(fileURLWithPath: "/tmp") + @Shared(.iCloudKV("url")) var url = initial + #expect(store.string(forKey: "url") == initial.absoluteString) + $url.withLock { $0 = updated } + #expect(store.string(forKey: "url") == updated.absoluteString) + #expect(url == updated) + } + + @Test func data() { + @Shared(.iCloudKV("data")) var data = Data([4, 2]) + #expect(store.object(forKey: "data") as? Data == Data([4, 2])) + $data.withLock { $0 = Data([1, 7, 2, 9]) } + #expect(store.object(forKey: "data") as? Data == Data([1, 7, 2, 9])) + #expect(data == Data([1, 7, 2, 9])) + } + + @Test func date() { + let initial = Date(timeIntervalSinceReferenceDate: 0) + let updated = Date(timeIntervalSince1970: 0) + @Shared(.iCloudKV("date")) var date = initial + #expect(store.object(forKey: "date") as? Date == initial) + $date.withLock { $0 = updated } + #expect(store.object(forKey: "date") as? Date == updated) + #expect(date == updated) + } + + @Test func rawRepresentableInt() { + struct ID: RawRepresentable, Equatable { + var rawValue: Int + } + @Shared(.iCloudKV("raw-int")) var id = ID(rawValue: 42) + #expect(store.object(forKey: "raw-int") as? Int == 42) + $id.withLock { $0 = ID(rawValue: 1729) } + #expect(store.object(forKey: "raw-int") as? Int == 1729) + #expect(id == ID(rawValue: 1729)) + } + + @Test func rawRepresentableString() { + struct Name: RawRepresentable, Equatable { + var rawValue: String + } + @Shared(.iCloudKV("raw-string")) var name = Name(rawValue: "Blob") + #expect(store.string(forKey: "raw-string") == "Blob") + $name.withLock { $0 = Name(rawValue: "Blob, Jr.") } + #expect(store.string(forKey: "raw-string") == "Blob, Jr.") + #expect(name == Name(rawValue: "Blob, Jr.")) + } + + @Test func optionalStartsNil() { + @Shared(.iCloudKV("opt-bool")) var bool: Bool? + #expect(bool == nil) + #expect(store.object(forKey: "opt-bool") == nil) + } + + @Test func optionalRoundTripsAndClears() { + @Shared(.iCloudKV("opt-int")) var int: Int? = 1 + #expect(store.object(forKey: "opt-int") as? Int == 1) + $int.withLock { $0 = 2 } + #expect(store.object(forKey: "opt-int") as? Int == 2) + $int.withLock { $0 = nil } + #expect(store.object(forKey: "opt-int") == nil) + #expect(int == nil) + } + + @Test func optionalURL() { + let initial = URL(fileURLWithPath: "/dev") + @Shared(.iCloudKV("opt-url")) var url: URL? = initial + #expect(store.string(forKey: "opt-url") == initial.absoluteString) + $url.withLock { $0 = nil } + #expect(store.object(forKey: "opt-url") == nil) + #expect(url == nil) + } + + @Test func explicitStoreOverride() { + let custom = InMemoryiCloudKVStore() + @Shared(.iCloudKV("override", store: custom)) var value = "explicit" + #expect(custom.string(forKey: "override") == "explicit") + #expect(store.object(forKey: "override") == nil) + } + + @Test func dependencyOverride() { + let custom = InMemoryiCloudKVStore() + withDependencies { + $0.defaultiCloudKVStore = custom + } operation: { + @Shared(.iCloudKV("dep-override")) var value = 99 + #expect(custom.object(forKey: "dep-override") as? Int == 99) + } + #expect(store.object(forKey: "dep-override") == nil) + } + + @Test func separateTestsGetIsolatedStores() { + // Side-effect: write a value that we'll assert is NOT visible in other tests. + @Shared(.iCloudKV("isolation")) var int = 7 + #expect(store.object(forKey: "isolation") as? Int == 7) + } + + @Test func separateTestsGetIsolatedStoresControl() { + // If dependency scoping is working, the side-effect from the test above is not visible here. + #expect(store.object(forKey: "isolation") == nil) + } + + @Test func description() { + let key = iCloudKVKey.iCloudKV("greeting") + #expect(String(describing: key) == #".iCloudKV("greeting")"#) + } + + @Test func idDiffersByKey() { + let a = iCloudKVKey.iCloudKV("a") + let b = iCloudKVKey.iCloudKV("b") + #expect(a.id != b.id) + } + + @Test func idDiffersByStore() { + let a = iCloudKVKey.iCloudKV("k", store: InMemoryiCloudKVStore()) + let b = iCloudKVKey.iCloudKV("k", store: InMemoryiCloudKVStore()) + #expect(a.id != b.id) + } + + @Test func idEqualForSameKeyAndStore() { + let custom = InMemoryiCloudKVStore() + let a = iCloudKVKey.iCloudKV("k", store: custom) + let b = iCloudKVKey.iCloudKV("k", store: custom) + #expect(a.id == b.id) + } +} +#endif