Skip to content

Commit d3ee4f6

Browse files
authored
Add a new testing interface - wait(for:timeout:until) (#80)
* Add a new testing functionality wait(until:) * Update interface to wait for an specified atom * Update documentation * Remove unnecessary @mainactor
1 parent e0a765d commit d3ee4f6

File tree

8 files changed

+153
-41
lines changed

8 files changed

+153
-41
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1061,7 +1061,8 @@ A context that can simulate any scenarios in which atoms are used from a view or
10611061
|:--|:--|
10621062
|[unwatch(_:)](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomtestcontext/unwatch(_:))|Simulates a scenario in which the atom is no longer watched.|
10631063
|[override(_:with:)](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomtestcontext/override(_:with:)-1ce4h)|Overwrites the output of a specific atom or all atoms of the given type with the fixed value.|
1064-
|[waitForUpdate(timeout:)](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomtestcontext/waitforupdate(timeout:))|Waits until any of atoms watched through this context is updated.|
1064+
|[waitForUpdate(timeout:)](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomtestcontext/waitforupdate(timeout:))|Waits until any of the atoms watched through this context have been updated.|
1065+
|[wait(for:timeout:until:)](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomtestcontext/wait(for:timeout:until:))|Waits for the given atom until it will be a certain state.|
10651066
|[onUpdate](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomtestcontext/onupdate)|Sets a closure that notifies there has been an update to one of the atoms.|
10661067

10671068
<details><summary><code>📖 Expand to see example</code></summary>

Sources/Atoms/Context/AtomTestContext.swift

Lines changed: 99 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ public struct AtomTestContext: AtomWatchableContext {
2323
nonmutating set { state.onUpdate = newValue }
2424
}
2525

26-
/// Waits until any of atoms watched through this context is updated for up to
27-
/// the specified timeout, and then return a boolean value indicating whether an update is done.
26+
/// Waits until any of the atoms watched through this context have been updated up to the
27+
/// specified timeout, and then returns a boolean value indicating whether an update is done.
2828
///
2929
/// ```swift
3030
/// func testAsyncUpdate() async {
@@ -41,45 +41,86 @@ public struct AtomTestContext: AtomWatchableContext {
4141
/// }
4242
/// ```
4343
///
44-
/// - Parameter interval: The maximum timeout interval that this function can wait until
45-
/// the next update. The default timeout interval is nil.
44+
/// - Parameter duration: The maximum duration that this function can wait until
45+
/// the next update. The default timeout interval is nil.
4646
/// - Returns: A boolean value indicating whether an update is done.
4747
@discardableResult
48-
public func waitForUpdate(timeout interval: TimeInterval? = nil) async -> Bool {
49-
let updates = AsyncStream<Void> { continuation in
50-
let cancellable = state.notifier.sink(
51-
receiveCompletion: { completion in
52-
continuation.finish()
53-
},
54-
receiveValue: {
55-
continuation.yield()
56-
}
57-
)
58-
59-
continuation.onTermination = { termination in
60-
switch termination {
61-
case .cancelled:
62-
cancellable.cancel()
48+
public func waitForUpdate(timeout duration: TimeInterval? = nil) async -> Bool {
49+
await withTaskGroup(of: Bool.self) { group in
50+
let updates = state.makeUpdateStream()
6351

64-
case .finished:
65-
break
52+
group.addTask { @MainActor in
53+
var iterator = updates.makeAsyncIterator()
54+
await iterator.next()
55+
return true
56+
}
6657

67-
@unknown default:
68-
break
58+
if let duration {
59+
group.addTask {
60+
try? await Task.sleep(seconds: duration)
61+
return false
6962
}
7063
}
64+
65+
let didUpdate = await group.next() ?? false
66+
group.cancelAll()
67+
68+
return didUpdate
7169
}
70+
}
7271

73-
return await withTaskGroup(of: Bool.self) { group in
74-
group.addTask {
75-
var iterator = updates.makeAsyncIterator()
76-
await iterator.next()
77-
return true
72+
/// Waits for the given atom until it will be a certain state up to the specified timeout,
73+
/// and then returns a boolean value indicating whether an update is done.
74+
///
75+
/// ```swift
76+
/// func testAsyncUpdate() async {
77+
/// let context = AtomTestContext()
78+
///
79+
/// let initialPhase = context.watch(AsyncCalculationAtom().phase)
80+
/// XCTAssertEqual(initialPhase, .suspending)
81+
///
82+
/// let didUpdate = await context.wait(for: AsyncCalculationAtom().phase, until: \.isSuccess)
83+
/// let currentPhase = context.watch(AsyncCalculationAtom().phase)
84+
///
85+
/// XCTAssertTure(didUpdate)
86+
/// XCTAssertEqual(currentPhase, .success(123))
87+
/// }
88+
/// ```
89+
///
90+
/// - Parameters:
91+
/// - atom: An atom that this method waits updating to a certain state.
92+
/// - duration: The maximum duration that this function can wait until
93+
/// the next update. The default timeout interval is nil.
94+
/// - predicate: A predicate that determines when to stop waiting.
95+
///
96+
/// - Returns: A boolean value indicating whether an update is done.
97+
///
98+
@discardableResult
99+
public func wait<Node: Atom>(
100+
for atom: Node,
101+
timeout duration: TimeInterval? = nil,
102+
until predicate: @escaping (Node.Loader.Value) -> Bool
103+
) async -> Bool {
104+
await withTaskGroup(of: Bool.self) { group in
105+
let updates = state.makeUpdateStream()
106+
107+
group.addTask { @MainActor in
108+
guard !predicate(read(atom)) else {
109+
return false
110+
}
111+
112+
for await _ in updates {
113+
if predicate(read(atom)) {
114+
return true
115+
}
116+
}
117+
118+
return false
78119
}
79120

80-
if let interval {
121+
if let duration {
81122
group.addTask {
82-
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
123+
try? await Task.sleep(seconds: duration)
83124
return false
84125
}
85126
}
@@ -261,10 +302,37 @@ private extension AtomTestContext {
261302
let store = AtomStore()
262303
let token = ScopeKey.Token()
263304
let container = SubscriptionContainer()
264-
let notifier = PassthroughSubject<Void, Never>()
265305
var overrides = [OverrideKey: any AtomOverrideProtocol]()
266306
var onUpdate: (() -> Void)?
267307

308+
private let notifier = PassthroughSubject<Void, Never>()
309+
310+
func makeUpdateStream() -> AsyncStream<Void> {
311+
AsyncStream { continuation in
312+
let cancellable = notifier.sink(
313+
receiveCompletion: { _ in
314+
continuation.finish()
315+
},
316+
receiveValue: {
317+
continuation.yield()
318+
}
319+
)
320+
321+
continuation.onTermination = { termination in
322+
switch termination {
323+
case .cancelled:
324+
cancellable.cancel()
325+
326+
case .finished:
327+
break
328+
329+
@unknown default:
330+
break
331+
}
332+
}
333+
}
334+
}
335+
268336
func notifyUpdate() {
269337
onUpdate?()
270338
notifier.send()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
internal extension Task where Success == Never, Failure == Never {
2+
static func sleep(seconds duration: Double) async throws {
3+
try await sleep(nanoseconds: UInt64(duration * 1_000_000_000))
4+
}
5+
}

Tests/AtomsTests/Atom/AsyncSequenceAtomTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ final class AsyncSequenceAtomTests: XCTestCase {
1616
do {
1717
// Value
1818
pipe.continuation.yield(0)
19-
await context.waitForUpdate()
19+
await context.wait(for: atom, until: \.isSuccess)
2020

2121
XCTAssertEqual(context.watch(atom).value, 0)
2222
}
2323

2424
do {
2525
// Failure
2626
pipe.continuation.finish(throwing: URLError(.badURL))
27-
await context.waitForUpdate()
27+
await context.wait(for: atom, until: \.isFailure)
2828

2929
XCTAssertEqual(context.watch(atom).error as? URLError, URLError(.badURL))
3030
}

Tests/AtomsTests/Atom/PublisherAtomTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,15 @@ final class PublisherAtomTests: XCTestCase {
2020
// Value
2121
subject.send(0)
2222

23-
await context.waitForUpdate()
23+
await context.wait(for: atom, until: \.isSuccess)
2424
XCTAssertEqual(context.watch(atom), .success(0))
2525
}
2626

2727
do {
2828
// Error
2929
subject.send(completion: .failure(URLError(.badURL)))
3030

31-
await context.waitForUpdate()
31+
await context.wait(for: atom, until: \.isFailure)
3232
XCTAssertEqual(context.watch(atom), .failure(URLError(.badURL)))
3333
}
3434

Tests/AtomsTests/Context/AtomTestContextTests.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,44 @@ final class AtomTestContextTests: XCTestCase {
5050
XCTAssertFalse(didUpdate1)
5151
}
5252

53+
func testWaitFor() async {
54+
let atom = TestStateAtom(defaultValue: 0)
55+
let context = AtomTestContext()
56+
57+
context.watch(atom)
58+
59+
for i in 0..<3 {
60+
Task {
61+
try? await Task.sleep(seconds: Double(i))
62+
context[atom] += 1
63+
}
64+
}
65+
66+
let didUpdate0 = await context.wait(for: atom) {
67+
$0 == 0
68+
}
69+
70+
XCTAssertFalse(didUpdate0)
71+
72+
let didUpdate1 = await context.wait(for: atom) {
73+
$0 == 3
74+
}
75+
76+
XCTAssertTrue(didUpdate1)
77+
78+
let didUpdate2 = await context.wait(for: atom, timeout: 1) {
79+
$0 == 100
80+
}
81+
82+
XCTAssertFalse(didUpdate2)
83+
84+
let didUpdate3 = await context.wait(for: atom) {
85+
$0 == 3
86+
}
87+
88+
XCTAssertFalse(didUpdate3)
89+
}
90+
5391
func testOverride() {
5492
let atom0 = TestValueAtom(value: 100)
5593
let atom1 = TestStateAtom(defaultValue: 200)

Tests/AtomsTests/Core/Loader/AtomLoaderContextTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ final class AtomLoaderContextTests: XCTestCase {
7272
) { _, _ in }
7373

7474
await context.transaction { _ in
75-
try? await Task.sleep(nanoseconds: 0)
75+
try? await Task.sleep(seconds: 0)
7676
}
7777

7878
XCTAssertTrue(isCommitted)

Tests/AtomsTests/Modifier/TaskPhaseModifierTests.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import XCTest
66
@MainActor
77
final class TaskPhaseModifierTests: XCTestCase {
88
func testPhase() async {
9-
let atom = TestTaskAtom(value: 0)
9+
let atom = TestTaskAtom(value: 0).phase
1010
let context = AtomTestContext()
1111

12-
XCTAssertEqual(context.watch(atom.phase), .suspending)
12+
XCTAssertEqual(context.watch(atom), .suspending)
1313

14-
await context.waitForUpdate(timeout: 1)
14+
await context.wait(for: atom, until: \.isSuccess)
1515

16-
XCTAssertEqual(context.watch(atom.phase), .success(0))
16+
XCTAssertEqual(context.watch(atom), .success(0))
1717
}
1818

1919
func testKey() {

0 commit comments

Comments
 (0)