Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Example/Example/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import SwiftUI
struct ContentView: View {
// AppStorage is backed by UserDefaults so this works too!
@AppStorage(.contentTitle) var title: String?
@AppStorage(.contentSortOrder) var sortOrder: ContentSortOrder = .descending
@ObservedObject var viewModel = ContentViewModel()

var body: some View {
Expand All @@ -37,7 +38,7 @@ struct ContentView: View {
.frame(maxWidth: .infinity)
} else {
List {
ForEach(viewModel.items, id: \.self) { item in
ForEach(viewModel.items.sorted(by: sortOrder.compare(lhs:rhs:)), id: \.self) { item in
Text("\(item, formatter: viewModel.dateFormatter)")
}
.onDelete(perform: { viewModel.items.remove(atOffsets: $0) })
Expand Down
16 changes: 16 additions & 0 deletions Example/ExampleKit/Sources/ExampleKit/ExamplePreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,20 @@ public extension UserDefaults.Key {

/// User defaults key representing the items displayed in the list of `ContentView`
static let contentItems = Self("ContentItems")

/// The order used to sort the items that are displayed in `ContentView`
static let contentSortOrder = Self("ContentSortOrder")
}

public enum ContentSortOrder: String {
case ascending, descending

public func compare<T: Comparable>(lhs: T, rhs: T) -> Bool {
switch self {
case .ascending:
return lhs < rhs
case .descending:
return lhs > rhs
}
}
}
53 changes: 36 additions & 17 deletions Example/ExampleUITests/ExampleUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,44 +25,63 @@ import SwiftUserDefaults
import XCTest

class ExampleUITests: XCTestCase {
func testNoItemsPlaceholder() {
struct Configuration: LaunchArgumentEncodable {
@UserDefaultOverride(.contentTitle)
var title: String = "Example App (Test)"

@UserDefaultOverride(.contentItems)
var items: [Date] = []

@UserDefaultOverride(.contentSortOrder)
var sortOrder: ContentSortOrder = .descending

var deviceLocale: Locale = Locale(identifier: "en_US")

var additionalLaunchArguments: [String] {
// Type `Locale` doesn't match how we want to represent the `AppleLocale` UserDefault so we'll encode it manually
var container = UserDefaults.ValueContainer()
container.set(deviceLocale.identifier, forKey: UserDefaults.Key("AppleLocale"))

return container.launchArguments
}
}

func testNoItemsPlaceholder() throws {
// Configure UserDefaults to ensure that there are no items
// Use the UserDefaults.Key constants from ExampleKit to keep test code in sync
var container = UserDefaults.ValueContainer()
container.set("Example App", forKey: .contentTitle)
container.set(Array<Date>(), forKey: .contentItems)
// The default definition of `Configuration` sets sensible defaults to ensure a consistent (empty) state.
let configuration = Configuration()

// Launch the app with the user defaults
let app = XCUIApplication()
app.launchArguments = container.launchArguments
app.launchArguments = try configuration.encodeLaunchArguments()
app.launch()

// Ensure the placeholder is set properly
XCTAssertTrue(app.navigationBars["Example App"].exists)
XCTAssertTrue(app.navigationBars["Example App (Test)"].exists)
XCTAssertTrue(app.staticTexts["No Items"].exists)
}

func testDeleteItem() {
func testDeleteItem() throws {
let calendar = Calendar.current
let startDate = calendar.date(from: DateComponents(year: 2021, month: 6, day: 1, hour: 9, minute: 10))!

// Configure UserDefaults to contain items with known dates
// Use 'String' keys for a simplified extension (avoiding UserDefaults.Key)
var container = UserDefaults.ValueContainer()
container.set(["fr_FR"], forKey: UserDefaults.Key("AppleLanguages"))
container.set("Example App", forKey: .contentTitle)
container.set([
// Configure a more complex scenario to test by overriding various values
var configuration = Configuration()
configuration.deviceLocale = Locale(identifier: "fr_FR")
configuration.sortOrder = .ascending
configuration.title = "Example App"
configuration.items = [
startDate,
calendar.date(byAdding: .day, value: 1, to: startDate)!,
calendar.date(byAdding: .day, value: 2, to: startDate)!,
calendar.date(byAdding: .day, value: 3, to: startDate)!,
calendar.date(byAdding: .day, value: 4, to: startDate)!,
calendar.date(byAdding: .day, value: 5, to: startDate)!
], forKey: .contentItems)
]

// Launch the app with the user defaults
// Launch the app with the user default overrides
let app = XCUIApplication()
app.launchArguments = container.launchArguments
app.launchArguments = try configuration.encodeLaunchArguments()
app.launch()

// Find a known cell, ensure it exists
Expand Down
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,15 +198,25 @@ import MyAppCommon
import SwiftUserDefaults
import XCTest

struct MyAppConfiguration: LaunchArgumentEncodable {
@UserDefaultOverride(.currentLevel)
var currentLevel: Int?

@UserDefaultOverride(.userName)
var userName: String?

@UserDefaultOverride(.userGUID)
var userGUID = "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"
}

final class MyAppTests: XCTestCase {
func testMyApp() {
var container = UserDefaults.ValueContainer()
container.set(8, forKey: .currentLevel)
container.set("John Doe", forKey: .userName)
container.set("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", forKey: .userGUID)
func testMyApp() throws {
var configuration = MyAppConfiguration()
container.currentLevel = 8
container.userName = "John Doe"

let app = XCUIApplication()
app.launchArguments = container.launchArguments
app.launchArguments = try configuration.encodeLaunchArguments()
app.launch()

// ...
Expand Down
48 changes: 48 additions & 0 deletions Sources/SwiftUserDefaults/LaunchArgumentEncodable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation

/// A protocol used by container types that can have their representations encoded into launch arguments via the ``encodeLaunchArguments()`` method.
///
/// This protocol works exclusively in conjunction with the ``UserDefaultOverride`` property wrapper.
public protocol LaunchArgumentEncodable {
/// Additional values to be appended to the result of `collectLaunchArguments()`.
///
/// A default implementation is provided that returns an empty array.
var additionalLaunchArguments: [String] { get }

/// An array of types that represent UserDefault key/value overrides to be converted into launch arguments.
///
/// A default implementation is provided that uses reflection to collect these values from the receiver.
/// You are free to override and provide your own implementation if you would prefer.
var userDefaultOverrides: [UserDefaultOverrideRepresentable] { get }
}

public extension LaunchArgumentEncodable {
var additionalLaunchArguments: [String] {
[]
}

/// Uses reflection to collect properties that conform to `UserDefaultOverrideRepresentable` from the receiver.
var userDefaultOverrides: [UserDefaultOverrideRepresentable] {
Mirror(reflecting: self)
.children
.compactMap { $0.value as? UserDefaultOverrideRepresentable }
}

/// Collects the complete array of launch arguments from the receiver.
///
/// The contents of the return value is built by using Reflection to look for all `@UserDefaultOverride` property wrapper instances. See ``UserDefaultOverride`` for more information.
///
/// In addition to overrides, the contents of `additionalLaunchArguments` is appended to the return value.
func encodeLaunchArguments() throws -> [String] {
// Map the overrides into a container
var container = UserDefaults.ValueContainer()
for userDefaultOverride in userDefaultOverrides {
// Add the storable value into the container only if it wasn't nil
guard let value = try userDefaultOverride.getValue() else { continue }
container.set(value, forKey: userDefaultOverride.key)
}

// Return the collected user default overrides along with any additional arguments
return container.launchArguments + additionalLaunchArguments
}
}
177 changes: 177 additions & 0 deletions Sources/SwiftUserDefaults/UserDefaultOverride.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import Foundation

/// A protocol to help erase generic type information from ``UserDefaultOverride`` when attempting to obtain the key value pair.
public protocol UserDefaultOverrideRepresentable {
/// The key of the user default value that should be overidden.
var key: UserDefaults.Key { get }

/// The value of the user default value that shoul be overridden, or nil if an override should not be applied.
func getValue() throws -> UserDefaultsStorable?
}

/// A property wrapper used for marking types as a value that should be used as an override in `UserDefaults`.
///
/// On its own, `@UserDefaultOverride` or `LaunchOverrides` cannot override values stored in `UserDefaults`, but they can provide an array of launch arguments that you can then pass to a process. There are two scenarios where you might find this useful:
///
/// 1. Running UI Tests via XCTest, you might set `XCUIApplication`'s `launchArguments` array before calling `launch()`.
/// 2. Invoking a `Process`, you might pass values to the `arguments` array.
///
/// **UI Test Example**
///
/// When using SwiftUserDefaults, if you define `UserDefaults.Key` definitions and other model types in a separate framework target (in this example, `MyFramework`), you can then share them between your application target and your UI test target:
///
/// ```swift
/// import SwiftUserDefaults
///
/// public extension UserDefaults.Key {
/// public static let user = Self("User")
/// public static let state = Self("State")
/// public static let isLegacyUser = Self("LegacyUser")
/// }
///
/// public struct User: Codable {
/// public var name: String
///
/// public init(name: String) {
/// self.name = name
/// }
/// }
///
/// public enum State: String {
/// case registered, unregistered
/// }
/// ```
/// To easily manage overrides in your UI Testing target, import your framework target and define a container that conforms to `LaunchArgumentEncodable`. In this container, use the `@UserDefaultOverride` property wrapper to build up a configuration of overrides that match usage in your app:
///
/// ```swift
/// import MyFramework
/// import SwiftUserDefaults
///
/// struct AppConfiguration: LaunchArgumentEncodable {
/// // An optional Codable property, encoded to data using the `.plist` strategy.
/// @UserDefaultOverride(.user, strategy: .plist)
/// var user: User?
///
/// // A RawRepresentable enum with a default value, encoded to it's backing `rawValue` (a String).
/// @UserDefaultOverride(.state)
/// var state: State = .unregistered
///
/// // An optional primitive type (Bool). When `nil`, values will not be used as an override since null cannot be represented.
/// @UserDefaultOverride(.isLegacyUser)
/// var isLegacyUser: Bool?
///
/// // A convenient place to define other launch arguments that don't relate to `UserDefaults`.
/// var additionalLaunchArguments: [String] {
/// ["UI-Testing"]
/// }
/// }
/// ```
///
/// Finally, in your test cases, create and configure an instance of your container type and use the `collectLaunchArguments()` method to pass the overrides into your `XCUIApplication` and perform the UI tests like normal. The overrides will be picked up by `UserDefaults` instances in your app to help you in testing pre-configured states.
///
/// ```swift
/// import SwiftUserDefaults
/// import XCTest
///
/// class MyAppUITestCase: XCTestCase {
/// func testScenario() throws {
/// // Create a configuration, update the overrides
/// var configuration = AppConfiguration()
/// configuration.user = User(name: "John")
/// configuration.state = .registered
///
/// // Create the test app, assign the launch arguments and launch the process.
/// let app = XCUIApplication()
/// app.launchArguments = try configuration.encodeLaunchArguments()
/// app.launch()
///
/// // The launch arguments will look like the following:
/// app.launchArguments
/// // ["-User", "<data>...</data>", "-State", "<string>registered</string>", "UI-Testing"]
///
/// // ...
/// }
/// }
/// ```
@propertyWrapper
public struct UserDefaultOverride<Value>: UserDefaultOverrideRepresentable {
let valueGetter: () -> Value
let valueSetter: (Value) -> Void
let storableValue: () throws -> UserDefaultsStorable?

public let key: UserDefaults.Key

public func getValue() throws -> UserDefaultsStorable? {
try storableValue()
}

public var wrappedValue: Value {
get {
valueGetter()
}
set {
valueSetter(newValue)
}
}

public var projectedValue: UserDefaultOverrideRepresentable {
self
}

init(
wrappedValue defaultValue: Value,
key: UserDefaults.Key,
transform: @escaping (Value) throws -> UserDefaultsStorable?
) {
var value: Value = defaultValue

self.key = key
self.valueGetter = { value }
self.valueSetter = { value = $0 }
self.storableValue = {
guard let value = try transform(value) else { return nil }
return value
}
}

public init(
wrappedValue defaultValue: Value,
_ key: UserDefaults.Key
) where Value: UserDefaultsStorable {
self.init(wrappedValue: defaultValue, key: key, transform: { $0 })
}

public init<T: UserDefaultsStorable>(
_ key: UserDefaults.Key
) where Value == T? {
self.init(wrappedValue: nil, key: key, transform: { $0 })
}

public init(
wrappedValue defaultValue: Value,
_ key: UserDefaults.Key
) where Value: RawRepresentable, Value.RawValue: UserDefaultsStorable {
self.init(wrappedValue: defaultValue, key: key, transform: { $0.rawValue })
}

public init<T: RawRepresentable>(
_ key: UserDefaults.Key
) where Value == T?, T.RawValue: UserDefaultsStorable {
self.init(wrappedValue: nil, key: key, transform: { $0?.rawValue })
}

public init(
wrappedValue defaultValue: Value,
_ key: UserDefaults.Key,
strategy: UserDefaults.CodingStrategy
) where Value: Encodable {
self.init(wrappedValue: defaultValue, key: key, transform: { try strategy.encode($0) })
}

public init<T: Encodable>(
_ key: UserDefaults.Key,
strategy: UserDefaults.CodingStrategy
) where Value == T? {
self.init(wrappedValue: nil, key: key, transform: { try $0.flatMap({ try strategy.encode($0) }) })
}
}
Loading