From 3649c706d93e16ee137ae1c729e81aa59beaa2d9 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Sat, 14 Jun 2025 20:16:40 +0800 Subject: [PATCH] feat: Add TypedUserDefaults with tests. --- .../TypedAppStorage/TypedUserDefaults.swift | 91 +++++++++++ .../TypedUserDefaultsTests.swift | 147 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 Sources/TypedAppStorage/TypedUserDefaults.swift create mode 100644 Tests/TypedAppStorageTests/TypedUserDefaultsTests.swift diff --git a/Sources/TypedAppStorage/TypedUserDefaults.swift b/Sources/TypedAppStorage/TypedUserDefaults.swift new file mode 100644 index 0000000..360afab --- /dev/null +++ b/Sources/TypedAppStorage/TypedUserDefaults.swift @@ -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( + 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( + _ 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( + 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() + } +} diff --git a/Tests/TypedAppStorageTests/TypedUserDefaultsTests.swift b/Tests/TypedAppStorageTests/TypedUserDefaultsTests.swift new file mode 100644 index 0000000..cbc815d --- /dev/null +++ b/Tests/TypedAppStorageTests/TypedUserDefaultsTests.swift @@ -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) + } +}