feat: Add TypedUserDefaults with tests.

This commit is contained in:
Shibo Lyu 2025-06-14 20:16:40 +08:00
parent b3326032f6
commit 3649c706d9
2 changed files with 238 additions and 0 deletions

View file

@ -0,0 +1,91 @@
import Foundation
/// A type-safe wrapper around UserDefaults that supports JSON-based Codable encoding/decoding.
///
/// `TypedUserDefaults` mimics Apple's UserDefaults API while providing type safety for complex data structures
/// that conform to ``TypedAppStorageValue``. Since ``TypedAppStorageValue`` includes the storage key and default value,
/// many parameters become optional in the API.
public class TypedUserDefaults {
/// The underlying UserDefaults store.
public let userDefaults: UserDefaults
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
/// The shared typed user defaults object.
public static let standard = TypedUserDefaults()
/// Creates a TypedUserDefaults instance with the specified UserDefaults store.
/// - Parameter userDefaults: The underlying UserDefaults store. Defaults to UserDefaults.standard.
public init(userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
}
// MARK: - Reading Values
/// Returns the typed object associated with the specified key.
/// - Parameters:
/// - type: The type of object to retrieve.
/// - key: The key to retrieve the value for. If nil, uses the type's appStorageKey.
/// - defaultValue: The default value to return if no object exists for the key. If nil, uses the type's default value.
/// - Returns: The typed object, or the type's default value if no object exists for the key.
public func object<T: TypedAppStorageValue>(
of type: T.Type,
forKey key: String? = nil,
defaultValue: T? = nil
) -> T {
let storageKey = key ?? T.appStorageKey
guard let jsonString = userDefaults.string(forKey: storageKey),
let data = jsonString.data(using: .utf8),
let decodedValue = try? decoder.decode(T.self, from: data)
else {
return defaultValue ?? T.defaultValue
}
return decodedValue
}
// MARK: - Writing Values
/// Sets the value of the specified typed object for the given key.
/// - Parameters:
/// - value: The typed object to store.
/// - key: The key to store the value under. If nil, uses the type's appStorageKey.
public func set<T: TypedAppStorageValue>(
_ value: T,
forKey key: String? = nil
) {
let storageKey = key ?? T.appStorageKey
guard let data = try? encoder.encode(value),
let jsonString = String(data: data, encoding: .utf8)
else {
return
}
userDefaults.set(jsonString, forKey: storageKey)
}
// MARK: - Removing Values
/// Removes the value associated with the specified key for the given type.
/// - Parameters:
/// - type: The type of object to remove.
/// - key: The key to remove the value for. If nil, uses the type's appStorageKey.
public func removeObject<T: TypedAppStorageValue>(
of type: T.Type,
forKey key: String? = nil
) {
let storageKey = key ?? T.appStorageKey
userDefaults.removeObject(forKey: storageKey)
}
// MARK: - UserDefaults Forwarding
/// Synchronizes any changes made to the shared user defaults.
/// - Returns: true if the synchronization was successful, false otherwise.
@discardableResult
public func synchronize() -> Bool {
return userDefaults.synchronize()
}
}

View file

@ -0,0 +1,147 @@
import Foundation
import Testing
@testable import TypedAppStorage
// MARK: - Test Models
struct TestUser: TypedAppStorageValue, Equatable {
static var appStorageKey = "testUser"
static var defaultValue = TestUser(name: "Anonymous", age: 0)
let name: String
let age: Int
}
struct TestSettings: TypedAppStorageValue, Equatable {
static var appStorageKey = "testSettings"
static var defaultValue = TestSettings(theme: .light, notifications: true)
enum Theme: String, Codable {
case light, dark
}
let theme: Theme
let notifications: Bool
}
// MARK: - Tests
@Suite("TypedUserDefaults Tests")
struct TypedUserDefaultsTests {
// Helper to create a clean test environment
private func createTestDefaults() -> TypedUserDefaults {
let userDefaults = UserDefaults()
userDefaults.removePersistentDomain(forName: Bundle.main.bundleIdentifier ?? "test")
return TypedUserDefaults(userDefaults: userDefaults)
}
@Test("Should return default value when no stored value exists")
func testDefaultValueRetrieval() {
let typedDefaults = createTestDefaults()
let user = typedDefaults.object(of: TestUser.self)
#expect(user == TestUser.defaultValue)
#expect(user.name == "Anonymous")
#expect(user.age == 0)
}
@Test("Should store and retrieve typed objects correctly")
func testStoreAndRetrieve() {
let typedDefaults = createTestDefaults()
let testUser = TestUser(name: "John Doe", age: 30)
// Store the value
typedDefaults.set(testUser)
// Retrieve the value
let retrievedUser = typedDefaults.object(of: TestUser.self)
#expect(retrievedUser == testUser)
#expect(retrievedUser.name == "John Doe")
#expect(retrievedUser.age == 30)
}
@Test("Should handle custom keys and default values")
func testCustomKeysAndDefaults() {
let typedDefaults = createTestDefaults()
let customUser = TestUser(name: "Jane Smith", age: 25)
let customDefault = TestUser(name: "Custom Default", age: 99)
let customKey = "customUserKey"
// Store with custom key
typedDefaults.set(customUser, forKey: customKey)
// Retrieve with custom key and custom default
let retrievedUser = typedDefaults.object(
of: TestUser.self, forKey: customKey, defaultValue: customDefault)
#expect(retrievedUser == customUser)
// Test retrieval from non-existent key with custom default
let nonExistentUser = typedDefaults.object(
of: TestUser.self, forKey: "nonExistentKey", defaultValue: customDefault)
#expect(nonExistentUser == customDefault)
#expect(nonExistentUser.name == "Custom Default")
#expect(nonExistentUser.age == 99)
}
@Test("Should remove stored objects correctly")
func testRemoveObject() {
let typedDefaults = createTestDefaults()
let testUser = TestUser(name: "To Be Removed", age: 40)
// Store the value
typedDefaults.set(testUser)
// Verify it's stored
let storedUser = typedDefaults.object(of: TestUser.self)
#expect(storedUser == testUser)
// Remove the value
typedDefaults.removeObject(of: TestUser.self)
// Verify it returns default value after removal
let removedUser = typedDefaults.object(of: TestUser.self)
#expect(removedUser == TestUser.defaultValue)
#expect(removedUser.name == "Anonymous")
#expect(removedUser.age == 0)
}
@Test("Should handle complex data types and synchronization")
func testComplexTypesAndSynchronization() {
let typedDefaults = createTestDefaults()
let testSettings = TestSettings(theme: .dark, notifications: false)
// Store complex type
typedDefaults.set(testSettings)
// Test synchronization
let syncResult = typedDefaults.synchronize()
#expect(syncResult == true)
// Retrieve and verify complex type
let retrievedSettings = typedDefaults.object(of: TestSettings.self)
#expect(retrievedSettings == testSettings)
#expect(retrievedSettings.theme == .dark)
#expect(retrievedSettings.notifications == false)
// Test with custom key for complex type
let customKey = "customSettings"
typedDefaults.set(testSettings, forKey: customKey)
let customRetrievedSettings = typedDefaults.object(of: TestSettings.self, forKey: customKey)
#expect(customRetrievedSettings == testSettings)
// Remove with custom key
typedDefaults.removeObject(of: TestSettings.self, forKey: customKey)
let removedCustomSettings = typedDefaults.object(of: TestSettings.self, forKey: customKey)
#expect(removedCustomSettings == TestSettings.defaultValue)
#expect(removedCustomSettings.theme == .light)
#expect(removedCustomSettings.notifications == true)
}
}