Skip to content

Commit 10e8c65

Browse files
0xLeifgoogle-labs-jules[bot]gemini-code-assist[bot]
authored
Improve tests and refine documentation (#133)
* Improve tests and refine documentation - Enhanced AppStateTests with tests for various data types, initial value closures, logging, and concurrent access scenarios. - Refactored concurrency tests to use async/await and TaskGroup. - Refined docstrings in Application.swift and Application+public.swift for clarity, consistency, and detail. - Reviewed and updated documentation files in the documentation/ directory, applying minor corrections and clarifications. Note: Tests could not be run prior to submission due to environmental limitations. * Fix: Correct state assignment in concurrency tests - Fixed an issue in `testConcurrentWrites` and `testConcurrentReadsAndWrites` where initial state values for the test were being assigned incorrectly. - Ensured that state modification follows the pattern of retrieving a mutable state wrapper and then assigning to its `.value` property. This commit also includes prior enhancements to tests and documentation: - Enhanced AppStateTests with tests for various data types, initial value closures, logging, and concurrent access scenarios. - Refactored concurrency tests to use async/await and TaskGroup. - Refined docstrings in Application.swift and Application+public.swift for clarity, consistency, and detail. - Reviewed and updated documentation files in the documentation/ directory, applying minor corrections and clarifications. Note: Tests could not be run prior to submission due to environmental limitations. * Fix: Thoroughly correct state assignment in tests - Meticulously corrected all instances in AppStateTests.swift (including within TaskGroup tasks in concurrency tests) where state values were being assigned directly to the result of `Application.state(keyPath).value`. - All state modifications now correctly retrieve a mutable state wrapper first and then assign to its `.value` property, ensuring proper API usage. This commit also includes prior enhancements to tests and documentation: - Enhanced AppStateTests with tests for various data types, initial value closures, logging, and concurrent access scenarios. - Refactored concurrency tests to use async/await and TaskGroup. - Refined docstrings in Application.swift and Application+public.swift for clarity, consistency, and detail. - Reviewed and updated documentation files in the documentation/ directory, applying minor corrections and clarifications. Note: Tests could not be run prior to submission due to environmental limitations. * Fix: Address actor isolation and state assignment in tests - Corrected all state value assignments in concurrency tests (`testConcurrentWrites`, `testConcurrentReadsAndWrites`) to use `await MainActor.run` for mutating/accessing `@MainActor` isolated properties like `State.value`. - Ensured `Application.state(keyPath)` calls from non-isolated tasks are properly awaited. - Fixed `testInitialValueClosureIsCalledOnce` to use `Application.shared.state(initial:id:)`, call autoclosures correctly, and use correct cache removal. - Added `try` for `Task.sleep` calls. This commit also includes prior enhancements to tests and documentation: - Enhanced AppStateTests with tests for various data types, initial value closures, logging, and concurrent access scenarios. - Refactored concurrency tests to use async/await and TaskGroup. - Refined docstrings in Application.swift and Application+public.swift for clarity, consistency, and detail. - Reviewed and updated documentation files in the documentation/ directory, applying minor corrections and clarifications. Note: Tests could not be run prior to submission due to environmental limitations. * Fix: Final corrections for actor isolation & state in tests - Thoroughly addressed all remaining actor isolation issues in concurrency tests (`testConcurrentWrites`, `testConcurrentReadsAndWrites`). Ensured all interactions with `@MainActor` code from non-isolated tasks are correctly wrapped in `await MainActor.run { ... }`. - Corrected `Application.shared.cache.remove(key:)` usage in `testInitialValueClosureIsCalledOnce`. - Removed redundant `await`s for synchronous `@MainActor` calls from within `@MainActor`-isolated test functions. - Ensured `try await` is used for `Task.sleep`. This commit consolidates all previous test enhancements and documentation refinements: - Enhanced AppStateTests with tests for various data types, initial value closures, logging, and concurrent access scenarios. - Refactored concurrency tests to use async/await and TaskGroup. - Refined docstrings in Application.swift and Application+public.swift. - Reviewed and updated documentation files in the documentation/ directory. Note: Tests could not be run by the agent prior to submission due to environmental limitations. * Fix: Correct cache remove call in tests - Changed `Application.shared.cache.remove(key: testKey)` to `Application.shared.cache.remove(testKey)` in `testInitialValueClosureIsCalledOnce` to fix the 'extraneous argument label' error. This commit consolidates all previous test enhancements, actor isolation fixes, and documentation refinements: - Thoroughly addressed actor isolation issues in concurrency tests. - Corrected state value assignments throughout tests. - Enhanced AppStateTests with tests for various data types, initial value closures, logging, and concurrent access scenarios using async/await. - Refined docstrings in Application.swift and Application+public.swift. - Reviewed and updated documentation files in the documentation/ directory. Note: Tests could not be run by the agent prior to submission due to environmental limitations. * Update AppStateTests.swift * Update test case * Fix bad check for test * Update AppStateTests.swift * Fix: Improve test isolation and correct FAQ - Modified AppStateTests.setUp to re-initialize Application.shared for better test isolation. - Updated AppStateTests.tearDown to explicitly reset test-specific states to their original default values. - Corrected the FAQ documentation (faq.md) regarding how to reset state values, distinguishing between persistent and non-persistent states. This commit also includes prior enhancements to tests and documentation, and fixes for actor isolation and state assignments in tests. Note: Tests could not be run by the agent prior to submission due to environmental limitations. * Fix tests * Update Tests/AppStateTests/AppStateTests.swift Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update documentation/faq.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Fix PR comments * Update Sources/AppState/Application/Application+public.swift Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update Sources/AppState/Application/Types/State/Application+State.swift Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent cae68a0 commit 10e8c65

File tree

8 files changed

+608
-194
lines changed

8 files changed

+608
-194
lines changed

Sources/AppState/Application/Application+public.swift

Lines changed: 461 additions & 169 deletions
Large diffs are not rendered by default.

Sources/AppState/Application/Application.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,27 @@ open class Application: NSObject {
2525
@MainActor
2626
static var isLoggingEnabled: Bool = false
2727

28+
/// A recursive lock to ensure thread-safe access to shared resources within the Application instance.
2829
let lock: NSRecursiveLock
2930

30-
/// Cache to store values
31+
/// The underlying cache used to store all state and dependency values.
3132
let cache: Cache<String, Any>
3233

3334
#if !os(Linux) && !os(Windows)
35+
/// A set to store cancellables for Combine subscriptions, ensuring they are properly managed and released.
3436
private var bag: Set<AnyCancellable> = Set()
3537

3638
deinit { bag.removeAll() }
3739
#endif
3840

39-
/// Default init used as the default Application, but also any custom implementation of Application. You should never call this function, but instead should use `Application.promote(to: CustomApplication.self)`.
41+
/// Initializes a new instance of `Application`.
42+
///
43+
/// This initializer is used for the default `Application.shared` instance and any custom `Application` subclasses.
44+
/// You should typically not call this directly. To use a custom application subclass, call `Application.promote(to: CustomApplication.self)`
45+
/// early in your application's lifecycle.
46+
///
47+
/// - Parameter setup: A closure that is called after the Application instance is initialized, allowing for custom setup logic.
48+
/// This can be used, for example, to register custom dependencies or perform initial state configuration.
4049
public required init(
4150
setup: (Application) -> Void = { _ in }
4251
) {
@@ -112,5 +121,8 @@ open class Application: NSObject {
112121
}
113122

114123
#if !os(Linux) && !os(Windows)
124+
/// Conform `Application` to `ObservableObject` to allow SwiftUI views to subscribe to its changes.
125+
/// This enables the `@ObservedObject` property wrapper to work with `Application.shared` or custom instances,
126+
/// triggering view updates when `objectWillChange` is published.
115127
extension Application: ObservableObject { }
116128
#endif

Sources/AppState/Application/Types/Helper/MutableApplicationState.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ public protocol MutableApplicationState {
1212
/// The actual value that this state holds. It can be both retrieved and modified.
1313
@MainActor
1414
var value: Value { get set }
15+
16+
/// The function used to reset the state to its initial value.
17+
@MainActor
18+
mutating func reset()
1519
}
1620

1721
extension MutableApplicationState {

Sources/AppState/Application/Types/State/Application+State.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ extension Application {
2323
/// A private backing storage for the value.
2424
private var _value: Value
2525

26+
/// The initial value of the state.
27+
private let initial: Value
28+
2629
/// The current state value.
2730
@MainActor
2831
public var value: Value {
@@ -85,14 +88,22 @@ extension Application {
8588
*/
8689
init(
8790
type: StateType,
88-
initial value: Value,
91+
initial: @autoclosure () -> Value,
8992
scope: Scope
9093
) {
9194
self.type = type
92-
self._value = value
95+
let initialValue = initial()
96+
self._value = initialValue
97+
self.initial = initialValue
9398
self.scope = scope
9499
}
95100

101+
/// Resets the value to the initial value.
102+
@MainActor
103+
public mutating func reset() {
104+
value = initial
105+
}
106+
96107
@MainActor
97108
public var logValue: String {
98109
switch type {

Tests/AppStateTests/AppStateTests.swift

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,32 @@ fileprivate extension Application {
2121
var colors: State<[String: String]> {
2222
state(initial: ["primary": "#A020F0"])
2323
}
24+
25+
var count: State<Int> {
26+
state(initial: 42)
27+
}
28+
29+
var piValue: State<Double> {
30+
state(initial: 3.14159)
31+
}
32+
33+
var customStruct: State<TestStruct> {
34+
state(initial: TestStruct(id: 1, name: "InitialStruct"))
35+
}
36+
37+
var customEnum: State<TestEnum> {
38+
state(initial: .caseA)
39+
}
40+
}
41+
42+
fileprivate struct TestStruct: Equatable, Codable {
43+
let id: Int
44+
let name: String
45+
}
46+
47+
fileprivate enum TestEnum: Equatable, Codable {
48+
case caseA
49+
case caseB(String)
2450
}
2551

2652
@MainActor
@@ -63,21 +89,24 @@ fileprivate struct ExampleView {
6389
#endif
6490

6591
final class AppStateTests: XCTestCase {
66-
@MainActor
6792
override func setUp() async throws {
68-
Application.logging(isEnabled: true)
69-
}
93+
try await super.setUp()
7094

71-
@MainActor
72-
override func tearDown() async throws {
73-
let applicationDescription = Application.description
74-
Application.logger.debug("AppStateTests \(applicationDescription)")
75-
76-
var username: Application.State = Application.state(\.username)
77-
78-
username.value = "Leif"
79-
}
95+
await MainActor.run {
96+
// Reset all states to their initial values to ensure test isolation.
97+
Application.reset(\.username)
98+
Application.reset(\.customEnum)
99+
Application.reset(\.customStruct)
100+
Application.reset(\.piValue)
101+
Application.reset(\.count)
102+
Application.reset(\.date)
103+
Application.reset(\.colors)
104+
Application.reset(\.isLoading)
80105

106+
// Ensure logging is enabled.
107+
Application.logging(isEnabled: true)
108+
}
109+
}
81110
@MainActor
82111
func testState() async {
83112
var appState: Application.State = Application.state(\.username)
@@ -117,4 +146,52 @@ final class AppStateTests: XCTestCase {
117146

118147
XCTAssertEqual(viewModel.username, "Hello, ViewModel")
119148
}
149+
150+
@MainActor
151+
func testStateWithDifferentDataTypes() async {
152+
// Test Int
153+
var countState: Application.State<Int> = Application.state(\.count)
154+
XCTAssertEqual(countState.value, 42)
155+
countState.value = 100
156+
XCTAssertEqual(Application.state(\.count).value, 100)
157+
158+
// Test Double
159+
var piState: Application.State<Double> = Application.state(\.piValue)
160+
XCTAssertEqual(piState.value, 3.14159)
161+
piState.value = 3.14
162+
XCTAssertEqual(Application.state(\.piValue).value, 3.14)
163+
164+
// Test Dictionary
165+
var colorsState: Application.State<[String: String]> = Application.state(\.colors)
166+
XCTAssertEqual(colorsState.value["primary"], "#A020F0")
167+
colorsState.value["secondary"] = "#FFFFFF"
168+
XCTAssertEqual(Application.state(\.colors).value["secondary"], "#FFFFFF")
169+
170+
// Test Custom Struct
171+
var structState: Application.State<TestStruct> = Application.state(\.customStruct)
172+
XCTAssertEqual(structState.value, TestStruct(id: 1, name: "InitialStruct"))
173+
structState.value = TestStruct(id: 2, name: "UpdatedStruct")
174+
XCTAssertEqual(Application.state(\.customStruct).value, TestStruct(id: 2, name: "UpdatedStruct"))
175+
176+
// Test Custom Enum
177+
var enumState: Application.State<TestEnum> = Application.state(\.customEnum)
178+
XCTAssertEqual(enumState.value, .caseA)
179+
enumState.value = .caseB("TestValue")
180+
XCTAssertEqual(Application.state(\.customEnum).value, .caseB("TestValue"))
181+
}
182+
183+
@MainActor
184+
func testLoggingToggle() {
185+
// Assuming default is true from setUp
186+
XCTAssertTrue(Application.isLoggingEnabled)
187+
Application.logger.debug("This should be logged from testLoggingToggle.")
188+
189+
Application.logging(isEnabled: false)
190+
XCTAssertFalse(Application.isLoggingEnabled)
191+
Application.logger.debug("This should NOT be logged from testLoggingToggle.") // This won't be asserted, just for manual check if needed
192+
193+
Application.logging(isEnabled: true)
194+
XCTAssertTrue(Application.isLoggingEnabled)
195+
Application.logger.debug("This should be logged again from testLoggingToggle.")
196+
}
120197
}

documentation/advanced-usage.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ private extension Application {
7272
}
7373
```
7474

75-
While this approach is valid for sharing state and dependencies across the application, it is not advised because it relies on manually managing IDs. This can lead to potential conflicts and bugs if IDs are not managed correctly. This behavior is more of a side effect of how the IDs work in AppState, rather than a recommended practice.
75+
While this approach is valid for sharing state and dependencies across the application by reusing the same string `id`, it is generally discouraged. It relies on manually managing these string IDs, which can lead to:
76+
- Accidental ID collisions if the same ID is used for different intended states/dependencies.
77+
- Difficulty in tracking where a state/dependency is defined versus accessed.
78+
- Reduced code clarity and maintainability.
79+
The `initial` value provided in subsequent definitions with the same ID will be ignored if the state/dependency has already been initialized by its first access. This behavior is more of a side effect of how the ID-based caching works in AppState, rather than a recommended primary pattern for defining shared data. Prefer defining states and dependencies as unique computed properties in `Application` extensions (which automatically generate unique internal IDs if no explicit `id` is provided to the factory method).
7680

7781
### 3.2 Restricted State and Dependency Access
7882

documentation/best-practices.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ This guide provides best practices to help you use AppState efficiently and effe
77
AppState is versatile and suitable for both shared and localized state management. It's ideal for data that needs to be shared across multiple components, persist across views or user sessions, or be managed at the component level. However, overuse can lead to unnecessary complexity.
88

99
### Recommendation:
10-
- Use AppState for critical application-wide data and shared state, but avoid using it for small, localized data that doesn't need to persist or be accessed across different components.
10+
- Use AppState for data that truly needs to be application-wide, shared across distant components, or requires AppState's specific persistence/synchronization features.
11+
- For state that is local to a single SwiftUI view or a close hierarchy of views, prefer SwiftUI's built-in tools like `@State`, `@StateObject`, `@ObservedObject`, or `@EnvironmentObject`.
1112

1213
## 2. Maintain a Clean AppState
1314

@@ -45,8 +46,9 @@ The `@Constant` feature lets you define read-only constants that can be shared a
4546
For larger applications, consider breaking your AppState into smaller, more manageable modules. Each module can have its own state and dependencies, which are then composed into the overall AppState. This can make your AppState easier to understand, test, and maintain.
4647

4748
### Recommendation:
48-
- Divide AppState into logical modules to manage state and dependencies at a more granular level.
49-
- Compose modules into the main AppState to maintain modularity and separation of concerns.
49+
- Organize your `Application` extensions into separate Swift files or even separate Swift modules, grouped by feature or domain. This naturally modularizes the definitions.
50+
- When defining states or dependencies using factory methods like `state(initial:feature:id:)`, utilize the `feature` parameter to provide a namespace, e.g., `state(initial: 0, feature: "UserProfile", id: "score")`. This helps in organizing and preventing ID collisions if manual IDs are used.
51+
- Avoid creating multiple instances of `Application`. Stick to extending and using the shared singleton (`Application.shared`).
5052

5153
## 7. Leverage Just-In-Time Creation
5254

documentation/faq.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,27 @@ This short FAQ addresses common questions developers may have when using **AppSt
44

55
## How do I reset a state value?
66

7-
Each `State` provides a `reset()` function that restores the value to the `initial` one defined in your `Application` extension. Call `reset()` when you need to clear user data or return to a default configuration.
7+
For persistent states like `StoredState`, `FileState`, and `SyncState`, you can reset them to their initial values using the static `reset` functions on the `Application` type.
88

9+
For example, to reset a `StoredState<Bool>`:
910
```swift
10-
@AppState(\.counter) var counter: Int
11-
12-
func clearCounter() {
13-
counter.reset() // Resets to the initial value
11+
extension Application {
12+
var hasCompletedOnboarding: StoredState<Bool> { storedState(initial: false, id: "onboarding_complete") }
1413
}
14+
15+
// Somewhere in your code
16+
Application.reset(storedState: \.hasCompletedOnboarding)
1517
```
18+
This will reset the value in `UserDefaults` back to `false`. Similar `reset` functions exist for `FileState`, `SyncState`, and `SecureState`.
19+
20+
For non-persistent `State`, you can reset it the same way as persistent states:
21+
```swift
22+
extension Application {
23+
var counter: State<Int> { state(initial: 0) }
24+
}
25+
26+
// To reset:
27+
Application.reset(\.counter)
1628

1729
## Can I use AppState with asynchronous tasks?
1830

0 commit comments

Comments
 (0)